forked from Keelrleek/SoulStream
3555 lines
152 KiB
Svelte
3555 lines
152 KiB
Svelte
<script>
|
||
// The Library — the power user's side of the app, where a collection gets
|
||
// kept, searched and played. Rebuilt 2026-08-05 from the approved prototype
|
||
// (research/library/, HANDOFF-2026-08-05-library-redesign.md §3-4d) with
|
||
// Kyle's picked settings baked in rather than offered as variants:
|
||
//
|
||
// uncatalogued Equal — a record is a record; nothing marks provenance
|
||
// no artwork Generated — deterministic art from the record's own name
|
||
// gaps Summary — one line per shelf, opening into full dimmed cards
|
||
// within artist Split by type — Albums/EPs/Singles under their own heads
|
||
// toolbar Card — a floating rounded card, pinned under the glass bar
|
||
// index rail On — every grouping, every view, gutter always reserved
|
||
// list header Joined to the bar — the card's own last row, one pin
|
||
// entrance Wave — the records arrive; the chrome never moves
|
||
// cards 150px — the artist page's own card width
|
||
//
|
||
// Every shape here is the app's: the release card and grid are the artist
|
||
// page's, the tab row and sort menu the discography's, the inline expander
|
||
// is CatalogArtist's class for class with its animation numbers unchanged.
|
||
// Styles are copied because Svelte scopes them per component — the same
|
||
// trade CatalogDiscography made, with provenance comments at each block.
|
||
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,
|
||
refreshLibrary,
|
||
playAlbum,
|
||
player,
|
||
ui,
|
||
getArt,
|
||
getCatalogArt,
|
||
getCatalogArtistPage,
|
||
openCatalogArtist,
|
||
openCatalogAlbum,
|
||
openAlbumByName,
|
||
RECENTS_FLOOR_SECS,
|
||
recentsStore,
|
||
importResume,
|
||
goToView,
|
||
} from "../lib/state.svelte.js";
|
||
import {
|
||
fileRelease,
|
||
CAT_ORDER,
|
||
CAT_LABEL,
|
||
CAT_ONE,
|
||
} from "../lib/localReleaseType.js";
|
||
import { accentFromImage } from "../lib/color.js";
|
||
// The same splitter the album page, the Now Playing panel and the artist
|
||
// page's expander use. A fourth copy is how they would start disagreeing.
|
||
import { splitDescription } from "../lib/editions.js";
|
||
|
||
// ================================================================ state
|
||
// The page's shape survives a restart — the same courtesy the player bar
|
||
// extends to its expanded state. View state only; never data.
|
||
const VIEW_KEY = "library.view.v1";
|
||
const savedView = (() => {
|
||
try { return JSON.parse(localStorage.getItem(VIEW_KEY)) ?? {}; } catch { return {}; }
|
||
})();
|
||
let view = $state(savedView.view ?? "shelves"); // shelves | grid | list
|
||
let group = $state(savedView.group ?? "artist"); // artist|decade|format|rtype|added|none
|
||
let sortKey = $state(savedView.sort ?? "artist");
|
||
let desc = $state(savedView.desc ?? true);
|
||
$effect(() => {
|
||
const snapshot = JSON.stringify({ view, group, sort: sortKey, desc });
|
||
try { localStorage.setItem(VIEW_KEY, snapshot); } catch { /* full/blocked */ }
|
||
});
|
||
|
||
let q = $state("");
|
||
let menuOpen = $state(false);
|
||
let expandedShelves = $state({}); // shelf name -> true (past the 18-card cap)
|
||
let rescanning = $state(false);
|
||
|
||
// Per-album aggregates of what the files carry — length, bytes, bitrate,
|
||
// formats, last played. One backend call for the whole library.
|
||
let statsById = $state({});
|
||
let statsLoaded = $state(false);
|
||
async function loadStats() {
|
||
try {
|
||
const rows = await api.libraryAlbumStats(RECENTS_FLOOR_SECS);
|
||
statsById = Object.fromEntries(rows.map((s) => [s.album_id, s]));
|
||
statsLoaded = true;
|
||
} catch {
|
||
// Backend unavailable (dev in a browser): the page still renders,
|
||
// the stat row and file columns just stay quiet.
|
||
statsLoaded = true;
|
||
}
|
||
}
|
||
|
||
// ---- the catalogue side of Summary -----------------------------------
|
||
// "N of M released" and the gap line exist only where the app knows the
|
||
// artist's catalogue by an ANCHORED id — a followed artist. The join from
|
||
// a shelf's tag-derived name to that id is by normalized name, which is
|
||
// 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 }
|
||
|
||
/** "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 {
|
||
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({}); // 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, "");
|
||
|
||
async function loadFollowNames() {
|
||
try {
|
||
const list = await api.followedArtists();
|
||
followsByName = Object.fromEntries(
|
||
list.map((f) => [normKey(f.name), { artistId: f.artist_id, name: f.name }]),
|
||
);
|
||
} catch { /* backend unavailable */ }
|
||
}
|
||
|
||
// Artist ids resolved by clicking an unfollowed shelf head, kept so the
|
||
// resolution happens once per artist and the counts survive a restart.
|
||
// Deliberately separate from follows: resolving is not following.
|
||
const IDS_KEY = "library.artistIds.v1";
|
||
let resolvedByName = $state((() => {
|
||
try { return JSON.parse(localStorage.getItem(IDS_KEY)) ?? {}; } catch { return {}; }
|
||
})());
|
||
function rememberArtistId(name, artistId) {
|
||
resolvedByName = { ...resolvedByName, [normKey(name)]: { artistId, name } };
|
||
try { localStorage.setItem(IDS_KEY, JSON.stringify(resolvedByName)); } catch { /* ignore */ }
|
||
}
|
||
|
||
/** The anchored provider id for a shelf, or null. A follow is one source;
|
||
* a click-resolved id is the other. Both are anchors the USER established
|
||
* — following, or picking. Neither is a silent name→provider guess, which
|
||
* is what fuses namesakes (one Wand id holding seven different bands). */
|
||
function followFor(artistName) {
|
||
return followsByName[normKey(artistName)]
|
||
?? resolvedByName[normKey(artistName)]
|
||
?? null;
|
||
}
|
||
|
||
async function toggleGaps(artistName) {
|
||
// 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[key] && !gapLoading[key]) {
|
||
gapLoading = { ...gapLoading, [key]: true };
|
||
try {
|
||
const page = await getCatalogArtistPage(follow.artistId);
|
||
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
|
||
const ownedTitles = new Set(
|
||
libraryStore.albums
|
||
.filter((a) => normKey(a.artist) === normKey(artistName))
|
||
.map((a) => normKey(a.title)),
|
||
);
|
||
gapRows = {
|
||
...gapRows,
|
||
[key]: released.filter((r) => !ownedTitles.has(normKey(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]?.length || gapLoading[key] === false) {
|
||
gapOpen = { ...gapOpen, [key]: true };
|
||
}
|
||
}
|
||
|
||
// ---- opening a shelf's artist ----------------------------------------
|
||
// Every artist shelf is clickable. Where an id is already anchored (a
|
||
// follow, or an earlier resolution) it opens immediately; otherwise the
|
||
// click — and only the click — searches the catalogue for the name.
|
||
//
|
||
// "Confident" is deliberately NOT "take the top hit" — picking the most
|
||
// popular same-named artist is how a library ends up filed under the wrong
|
||
// band. But it is not "exactly one exact match" either: measured against
|
||
// this catalogue, GUM returns 3 exact matches at 3707 / 90 / 20 fans, Ween
|
||
// 3 at 18808 / 32 / 1, Panda Bear 3 at 27536 / 6 / 1. Those are Deezer's
|
||
// near-empty duplicate entries, and asking which Ween you meant when the
|
||
// alternative has ONE fan is noise, not a question.
|
||
//
|
||
// So the tie-break is the app's OWN namesake rule, not a new one: metadata's
|
||
// `deezer_exact_name_state` (crates/metadata/src/lib.rs:11626) calls a name
|
||
// owned when the leader has BING_NAME_OWNER_FAN_RATIO times the top rival
|
||
// and clears BING_NAME_OWNER_MIN_FANS itself. Those constants were tuned on
|
||
// exactly this shape — "exact-name rows are dominated by dead duplicates and
|
||
// typo entries (crazy_p, Pond x6)". Anything short of that asks.
|
||
//
|
||
// Corroboration on real data: this picks 13456 for Panda Bear, which is the
|
||
// id the existing follow already holds, and 1156937 for GUM, whose 90-fan
|
||
// rival is the empty-md5 placeholder entry.
|
||
const AMBIGUITY_RATIO = 4; // BING_NAME_OWNER_FAN_RATIO
|
||
const AMBIGUITY_FLOOR = 25; // BING_NAME_OWNER_MIN_FANS, on the LEADER
|
||
function chooseArtist(name, artists) {
|
||
const exact = artists.filter((a) => normKey(a.name) === normKey(name));
|
||
if (exact.length === 1) return { pick: exact[0] };
|
||
if (exact.length > 1) {
|
||
// Search already arrives ranked by fans; sort defensively anyway.
|
||
const byFans = [...exact].sort((x, y) => (y.fans ?? 0) - (x.fans ?? 0));
|
||
const lead = byFans[0].fans ?? 0;
|
||
const rival = byFans[1].fans ?? 0;
|
||
const ownsTheName = lead > 0
|
||
&& (rival === 0
|
||
|| (lead >= rival * AMBIGUITY_RATIO && lead >= AMBIGUITY_FLOOR));
|
||
if (ownsTheName) return { pick: byFans[0] };
|
||
return { options: byFans };
|
||
}
|
||
// Nothing matched the name exactly. The near-misses are worth offering
|
||
// (a tag that reads "POLYSICS!"), but never auto-selected.
|
||
return { options: artists };
|
||
}
|
||
let picker = $state(null); // { name, options: [...], searching } | null
|
||
let resolving = $state({}); // artist name -> true while searching
|
||
|
||
function openArtistPage(artistId, name) {
|
||
openCatalogArtist(artistId, { id: artistId, name });
|
||
}
|
||
|
||
async function openShelfArtist(name) {
|
||
const anchored = followFor(name);
|
||
if (anchored) { openArtistPage(anchored.artistId, name); return; }
|
||
if (resolving[name]) return;
|
||
resolving = { ...resolving, [name]: true };
|
||
try {
|
||
const res = await api.catalogSearch(name);
|
||
const verdict = chooseArtist(name, res?.artists ?? []);
|
||
if (verdict.pick) {
|
||
rememberArtistId(name, verdict.pick.id);
|
||
openArtistPage(verdict.pick.id, name);
|
||
} else {
|
||
picker = { name, options: verdict.options.slice(0, 8) };
|
||
}
|
||
} catch (err) {
|
||
// Never swallowed silently: a swallowed network failure is what
|
||
// made "descriptions take minutes" look like slowness for a week.
|
||
console.warn("[library] artist resolve failed for", name, err);
|
||
// A failed search is NOT "no such artist" — the catalogue never
|
||
// answered. Saying "no match" here is the same mistake as reading
|
||
// `0 groups` as "this artist has no releases" when the request
|
||
// simply died: it blames the data for a transport problem, and it
|
||
// invites the user to conclude their tag is wrong. Offer a retry.
|
||
picker = { name, options: [], failed: true };
|
||
}
|
||
resolving = { ...resolving, [name]: false };
|
||
}
|
||
|
||
// CatalogSearch's own fans formatter, verbatim — the picker's rows should
|
||
// read the way search results read.
|
||
const fmtFans = (n) => {
|
||
if (n == null) return null;
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
||
return String(n);
|
||
};
|
||
|
||
function pickArtist(option) {
|
||
const name = picker?.name;
|
||
picker = null;
|
||
if (!name || !option) return;
|
||
rememberArtistId(name, option.id);
|
||
openArtistPage(option.id, name);
|
||
}
|
||
|
||
// A future or unresolved release date is not "released" — same rule as the
|
||
// artist page's Upcoming tab (CatalogArtist `isFutureOrUnresolvedDate`).
|
||
const todayKey = (() => {
|
||
const now = new Date();
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||
})();
|
||
function isUpcomingRow(a) {
|
||
const value = a.release_date;
|
||
if (!value) return false;
|
||
if (value.length >= 10) return value.slice(0, 10) > todayKey;
|
||
if (value.length >= 7) return value.slice(0, 7) >= todayKey.slice(0, 7);
|
||
if (value.length >= 4) return value.slice(0, 4) >= todayKey.slice(0, 4);
|
||
return false;
|
||
}
|
||
|
||
// ================================================================ rows
|
||
const rows = $derived.by(() => {
|
||
return libraryStore.albums.map((a) => {
|
||
const s = statsById[a.id];
|
||
const row = {
|
||
...a,
|
||
secs: s?.secs ?? 0,
|
||
bytes: s?.bytes ?? 0,
|
||
avgKbps: s?.avg_kbps ?? null,
|
||
depth: s?.bit_depth && s?.sample_rate
|
||
? `${s.bit_depth}/${s.sample_rate % 1000 === 0 ? s.sample_rate / 1000 : (s.sample_rate / 1000).toFixed(1)}`
|
||
: null,
|
||
formats: s?.formats ?? [],
|
||
lastPlayedAt: s?.last_played_at ?? null,
|
||
sortName: (a.artist || "").replace(/^(the|a|an)\s+/i, ""),
|
||
};
|
||
// The app's own filing (localReleaseType.js: CatalogArtist's title
|
||
// heuristics + the approved track-count/length floor).
|
||
row.cat = fileRelease(row);
|
||
return row;
|
||
});
|
||
});
|
||
|
||
const corpus = $derived.by(() => {
|
||
if (!q) return rows;
|
||
const needle = q.toLowerCase();
|
||
return rows.filter((a) => `${a.artist} ${a.title}`.toLowerCase().includes(needle));
|
||
});
|
||
|
||
// ---- groupings --------------------------------------------------------
|
||
const GROUPS = {
|
||
// `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",
|
||
of: (a) => ((a.formats[0] || "—").toUpperCase() + (a.depth ? ` ${a.depth}` : "")),
|
||
},
|
||
rtype: { label: "Type", of: (a) => CAT_LABEL[a.cat] },
|
||
added: {
|
||
label: "Added",
|
||
of: (a) => (a.added_at
|
||
? new Date(a.added_at * 1000).toLocaleDateString(undefined, { year: "numeric", month: "long" })
|
||
: "—"),
|
||
},
|
||
none: { label: "None", of: () => "" },
|
||
};
|
||
|
||
// ---- sorts ------------------------------------------------------------
|
||
const cmpName = (x, y) => x.sortName.localeCompare(y.sortName) || (x.title ?? "").localeCompare(y.title ?? "");
|
||
const SORTS = {
|
||
artist: { label: "Artist", cmp: cmpName, desc: false },
|
||
title: {
|
||
label: "Title", desc: false,
|
||
cmp: (x, y) => (x.title ?? "").replace(/^(the|a|an)\s+/i, "").localeCompare((y.title ?? "").replace(/^(the|a|an)\s+/i, "")),
|
||
},
|
||
year: { label: "Release year", cmp: (x, y) => (y.year ?? -1) - (x.year ?? -1) || cmpName(x, y) },
|
||
added: { label: "Recently added", cmp: (x, y) => (y.added_at ?? 0) - (x.added_at ?? 0) },
|
||
played: { label: "Last played", cmp: (x, y) => (y.lastPlayedAt ?? 0) - (x.lastPlayedAt ?? 0) },
|
||
neglect: {
|
||
label: "Never played first",
|
||
cmp: (x, y) => (x.lastPlayedAt ?? 0) - (y.lastPlayedAt ?? 0) || cmpName(x, y),
|
||
desc: false,
|
||
},
|
||
length: { label: "Length", cmp: (x, y) => y.secs - x.secs },
|
||
tracks: { label: "Track count", cmp: (x, y) => (y.track_count ?? 0) - (x.track_count ?? 0) },
|
||
size: { label: "Size on disk", cmp: (x, y) => y.bytes - x.bytes },
|
||
bitrate: { label: "Bitrate", cmp: (x, y) => (y.avgKbps ?? 0) - (x.avgKbps ?? 0) },
|
||
format: {
|
||
label: "Format", desc: false,
|
||
cmp: (x, y) => (x.formats[0] ?? "~").localeCompare(y.formats[0] ?? "~") || cmpName(x, y),
|
||
},
|
||
rtype: {
|
||
label: "Release type", desc: false,
|
||
cmp: (x, y) => CAT_ORDER.indexOf(x.cat) - CAT_ORDER.indexOf(y.cat) || cmpName(x, y),
|
||
},
|
||
complete: {
|
||
label: "Least complete", desc: false,
|
||
cmp: (x, y) => completeness(x.artist) - completeness(y.artist) || cmpName(x, y),
|
||
},
|
||
};
|
||
|
||
function completeness(artist) {
|
||
const total = gapTotals[foldKey(artist)]?.total || 0;
|
||
if (!total) return 1;
|
||
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();
|
||
}
|
||
|
||
const grouped = $derived.by(() => {
|
||
const g = GROUPS[group];
|
||
if (group === "none") return [["", sortList(corpus)]];
|
||
const by = new Map();
|
||
for (const a of corpus) {
|
||
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 = [...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"
|
||
? (x, y) => x.replace(/^(the|a|an)\s+/i, "").localeCompare(y.replace(/^(the|a|an)\s+/i, ""))
|
||
: group === "decade" ? (x, y) => String(y).localeCompare(String(x))
|
||
: group === "added"
|
||
? (x, y) => (Date.parse(`1 ${y}`) || -Infinity) - (Date.parse(`1 ${x}`) || -Infinity)
|
||
: (x, y) => String(x).localeCompare(String(y)));
|
||
if (group === "artist" && sortKey === "complete") {
|
||
keys.sort((x, y) => completeness(x) - completeness(y));
|
||
}
|
||
return keys.map((k) => [k, sortList(named.get(k))]);
|
||
});
|
||
|
||
// Albums, EPs and singles under their own headings INSIDE an artist — the
|
||
// separation the artist page makes with tabs, made here with rules. Only
|
||
// categories the artist has appear, in TAB_DEFS order; a shelf holding one
|
||
// category gets no headings at all.
|
||
function splitByCategory(list) {
|
||
const by = new Map();
|
||
for (const a of list) {
|
||
if (!by.has(a.cat)) by.set(a.cat, []);
|
||
by.get(a.cat).push(a);
|
||
}
|
||
if (by.size < 2) return [[null, list]];
|
||
return CAT_ORDER.filter((c) => by.has(c)).map((c) => [CAT_LABEL[c], by.get(c)]);
|
||
}
|
||
|
||
// ---- the derived stat row --------------------------------------------
|
||
const totals = $derived.by(() => ({
|
||
albums: corpus.length,
|
||
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),
|
||
never: statsLoaded ? corpus.filter((a) => !a.lastPlayedAt).length : 0,
|
||
}));
|
||
|
||
// ================================================================ units
|
||
const pad2 = (n) => String(n).padStart(2, "0");
|
||
const dur = (s) => (!s ? "" : s >= 3600
|
||
? `${Math.floor(s / 3600)}:${pad2(Math.floor((s % 3600) / 60))}:${pad2(s % 60)}`
|
||
: `${Math.floor(s / 60)}:${pad2(s % 60)}`);
|
||
function longDur(s) {
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
if (d) return `${d}d ${h}h`;
|
||
const m = Math.round((s % 3600) / 60);
|
||
return h ? `${h}h ${m}m` : `${m}m`;
|
||
}
|
||
function fmtBytes(n) {
|
||
if (!n) return "—";
|
||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(n >= 10737418240 ? 0 : 1)} GB`;
|
||
if (n >= 1048576) return `${Math.round(n / 1048576)} MB`;
|
||
return `${Math.round(n / 1024)} KB`;
|
||
}
|
||
function since(ts) {
|
||
if (!ts) return "never";
|
||
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||
if (d <= 0) return "today";
|
||
if (d === 1) return "yesterday";
|
||
if (d < 7) return `${d}d ago`;
|
||
if (d < 31) return `${Math.round(d / 7)}w ago`;
|
||
if (d < 365) return `${Math.round(d / 30.4)}mo ago`;
|
||
return `${(d / 365).toFixed(1)}y ago`;
|
||
}
|
||
|
||
function subFor(a) {
|
||
if (sortKey === "added") return since(a.added_at);
|
||
if (sortKey === "played" || sortKey === "neglect") return since(a.lastPlayedAt);
|
||
if (sortKey === "size") return fmtBytes(a.bytes);
|
||
if (sortKey === "length") return dur(a.secs);
|
||
if (sortKey === "tracks") return `${a.track_count} tracks`;
|
||
if (sortKey === "rtype") return CAT_ONE[a.cat] + (a.year ? ` · ${a.year}` : "");
|
||
if (sortKey === "bitrate" || sortKey === "format") {
|
||
return (a.formats[0] || "").toUpperCase() + (a.avgKbps ? ` · ${a.avgKbps}k` : "");
|
||
}
|
||
return a.artist + (a.year ? ` · ${a.year}` : "");
|
||
}
|
||
const chipFor = (a) => (group === "artist" && a.year ? String(a.year) : null);
|
||
|
||
// ====================================================== generated artwork
|
||
// A record no provider will ever know has no cover and never will. Built
|
||
// from the record's own name — same name, same art, always — with a texture
|
||
// that follows what the thing IS (approved on the prototype; §3 of the
|
||
// handoff). `>>>` everywhere: a signed shift of an unsigned hash goes
|
||
// negative, and a negative index/hue was two real bugs.
|
||
function hash(s) {
|
||
let h = 2166136261;
|
||
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||
return h >>> 0;
|
||
}
|
||
function genFor(a) {
|
||
const h = hash(`${a.artist} — ${a.title}`);
|
||
const hue = h % 360;
|
||
const hue2 = (hue + 24 + ((h >>> 9) % 40)) % 360;
|
||
const pat = a.cat === "live" ? "rings" : a.cat === "other" ? "tape" : "plain";
|
||
return { hue, hue2, pat, icon: GEN_ICON[a.cat] ?? "disc" };
|
||
}
|
||
const GEN_ICON = {
|
||
live: "users", compilation: "layers", soundtrack: "preview-reel", remix: "shuffle",
|
||
other: "history", mixtape: "playlist", album: "disc", ep: "disc", single: "disc",
|
||
};
|
||
// A generated cover knows its own hue, so its card carries the accent.
|
||
const genAccent = (a) => {
|
||
const { hue } = genFor(a);
|
||
return `--accent:hsl(${hue} 48% 54%);--accent-hover:hsl(${hue} 54% 66%)`;
|
||
};
|
||
|
||
// ---- per-record accents -----------------------------------------------
|
||
// A record's chrome tints from its OWN cover rather than the global accent
|
||
// — CatalogArtist's scoped-accent pattern, and the same `--accent` /
|
||
// `--accent-hover` override string it writes. What differs is the SOURCE:
|
||
// its covers come off a CDN, so it needs a two-slot queue with timeouts to
|
||
// avoid 100 proxy round trips. Local art is already a decoded data URL in
|
||
// coverUrls by the time a card is on screen, so extraction hangs off that
|
||
// instead — no extra fetches, and it inherits loadCover's viewport gate.
|
||
//
|
||
// The GLOBAL accent is untouched: it keeps following the playing track, so
|
||
// the sidebar and playbar do not move when you point at a record.
|
||
let albumAccents = $state({}); // album id -> override string
|
||
const accentInFlight = new Set();
|
||
function extractAlbumAccent(id, dataUrl) {
|
||
if (!dataUrl || albumAccents[id] !== undefined || accentInFlight.has(id)) return;
|
||
accentInFlight.add(id);
|
||
accentFromImage(dataUrl).then((colors) => {
|
||
accentInFlight.delete(id);
|
||
// Never store a null palette: extraction fails transiently (a
|
||
// decode that lost its race with a re-render), and caching the
|
||
// failure would leave that record on the global accent forever.
|
||
if (colors) {
|
||
albumAccents[id] = `--accent:${colors.accent};--accent-hover:${colors.hover}`;
|
||
}
|
||
}).catch(() => accentInFlight.delete(id));
|
||
}
|
||
/** A record's own palette as a scoped override — extracted from its cover,
|
||
* or the generated cover's hue when it has no art. `undefined` means stay
|
||
* on the global accent, which is only the moment before extraction lands. */
|
||
const accentFor = (a) =>
|
||
!a ? undefined : (albumAccents[a.id] ?? (!a.art_path ? genAccent(a) : undefined));
|
||
|
||
// The record the eye is on. Drives the page's ambience glow only —
|
||
// everything else is scoped to its own card. (`highlightAccent` is derived
|
||
// down with openId, which it also reads.)
|
||
let hoverId = $state(null);
|
||
|
||
// ================================================================ covers
|
||
// Local art loads through getArt (budgeted data-URL cache in state), only
|
||
// for cards near the viewport — the pattern the old page proved out,
|
||
// reduced: the shared cache in state does the byte budgeting.
|
||
let coverUrls = $state({}); // album id -> data url
|
||
const coverInFlight = new Set();
|
||
function loadCover(node, album) {
|
||
let current = album;
|
||
let alive = true;
|
||
const load = () => {
|
||
const { id, art_path } = current;
|
||
if (!art_path) return;
|
||
// A cover already in the map still needs its palette: coverUrls
|
||
// outlives the cards (it is the page's cache), so a remount — a
|
||
// view or grouping change — takes this path with no fetch at all.
|
||
if (coverUrls[id] !== undefined) { extractAlbumAccent(id, coverUrls[id]); return; }
|
||
if (coverInFlight.has(id)) return;
|
||
coverInFlight.add(id);
|
||
getArt(art_path).then((url) => {
|
||
coverInFlight.delete(id);
|
||
if (alive && url) {
|
||
coverUrls[current.id] = url;
|
||
extractAlbumAccent(current.id, url);
|
||
}
|
||
}).catch(() => coverInFlight.delete(id));
|
||
};
|
||
const observer = new IntersectionObserver((entries) => {
|
||
for (const e of entries) if (e.isIntersecting) load();
|
||
}, { root: null, rootMargin: "600px" });
|
||
observer.observe(node);
|
||
return {
|
||
update(next) { current = next; },
|
||
destroy() { alive = false; observer.disconnect(); },
|
||
};
|
||
}
|
||
|
||
// Shelf heads carry the artist's portrait, through the same budgeted getArt
|
||
// path as the covers. Keyed by PATH, not by album id: every album on a
|
||
// shelf carries the same artist_art_path and only one of them should fetch.
|
||
let artistUrls = $state({}); // artist_art_path -> data url
|
||
const artistInFlight = new Set();
|
||
function loadShelfArt(node, path) {
|
||
let current = path;
|
||
let alive = true;
|
||
const load = () => {
|
||
const p = current;
|
||
if (!p || artistUrls[p] !== undefined || artistInFlight.has(p)) return;
|
||
artistInFlight.add(p);
|
||
getArt(p).then((url) => {
|
||
artistInFlight.delete(p);
|
||
if (alive && url) artistUrls[p] = url;
|
||
}).catch(() => artistInFlight.delete(p));
|
||
};
|
||
const observer = new IntersectionObserver((entries) => {
|
||
for (const e of entries) if (e.isIntersecting) load();
|
||
}, { root: null, rootMargin: "600px" });
|
||
observer.observe(node);
|
||
return {
|
||
update(next) { current = next; },
|
||
destroy() { alive = false; observer.disconnect(); },
|
||
};
|
||
}
|
||
|
||
// ---- ambience: the banner's colour is what you are LISTENING to -------
|
||
// The wash is the playing record's artwork, blurred — the page's tie to
|
||
// the music, which is what the approved mockup shows. Its rule there is
|
||
// "owned albums with a cover, sorted by last_played_at, take the first",
|
||
// which on the mockup's frozen data happens to land on the playing record;
|
||
// in a live app the playing record is simply known, so it is read directly.
|
||
//
|
||
// Only when NOTHING is playing does it fall back to the collection itself
|
||
// (the most recent library play, then the newest record with art), so the
|
||
// banner is never bare on a cold start.
|
||
let ambUrl = $state(null);
|
||
$effect(() => {
|
||
const item = player.queue[player.queueIndex] ?? null;
|
||
// A local track carries a file path, a streamed one a provider URL —
|
||
// the same split NowPlaying makes for this very artwork.
|
||
const live = item?.art
|
||
? { src: item.art, get: getArt }
|
||
: item?.artUrl
|
||
? { src: item.artUrl, get: getCatalogArt }
|
||
: null;
|
||
const fallback = live ? null : (
|
||
recentsStore.albums.find((r) => r.kind === "library" && r.art_path)?.art_path
|
||
?? libraryStore.albums.find((a) => a.art_path)?.art_path
|
||
?? null
|
||
);
|
||
const src = live?.src ?? fallback;
|
||
if (!src) { ambUrl = null; return; }
|
||
const get = live?.get ?? getArt;
|
||
let alive = true;
|
||
// Deliberately not cleared first: the outgoing wash holds until the
|
||
// incoming one has decoded, so a track change crossfades (below)
|
||
// instead of dropping to bare page for a frame.
|
||
get(src).then((url) => { if (alive && url) ambUrl = url; });
|
||
return () => { alive = false; };
|
||
});
|
||
|
||
// ============================================================== expander
|
||
// CatalogArtist's inline album expander, exactly — same classes, same
|
||
// numbers. What differs is the CONTENT (a library knows size on disk and
|
||
// bitrate, not Last.fm plays) and the reveal line (read off the pinned
|
||
// toolbar live, because its height depends on wrap + the joined header).
|
||
const SWAP_MS = 400; // CatalogArtist's own
|
||
const SWAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // quintOut
|
||
const quintOut = (t) => 1 - Math.pow(1 - t, 5);
|
||
const SWAP_PARTS = [
|
||
{ sel: ".rail-glow, .rail-shade", delay: 0, lift: false },
|
||
{ sel: ".rail-art-stage", delay: 0, lift: true },
|
||
{ sel: ".rail-title", delay: 60, lift: true },
|
||
{ sel: ".rail-meta", delay: 90, lift: true },
|
||
{ sel: ".rail-stats", delay: 105, lift: true },
|
||
{ sel: ".rail-cta", delay: 120, lift: true },
|
||
{ sel: ".xp-tracks", delay: 90, lift: true },
|
||
// The editorial is deliberately NOT here — it uses `use:partAnim`
|
||
// instead. See the note on that function: measured, neither this list
|
||
// nor an `in:` transition ever fired for it.
|
||
];
|
||
const LOADING_GRACE = 160;
|
||
|
||
let openId = $state(null);
|
||
let openTracks = $state([]);
|
||
let openLoading = $state(false);
|
||
let loadingVisible = $state(false);
|
||
let loadGraceTimer;
|
||
|
||
const openAlbum = $derived(rows.find((a) => a.id === openId) ?? null);
|
||
|
||
// ---- the open record's editorial ---------------------------------------
|
||
// Description, genre and label, exactly as the artist page's expander shows
|
||
// them (`loadAlbumInfo` there). The artist id is passed when one is
|
||
// anchored — a follow, or a click-resolved id — and null otherwise, which
|
||
// is what `catalog_album_info` expects: its own identity gate decides
|
||
// whether a name-only lookup is safe, and that gate is the thing that keeps
|
||
// a namesake's prose off this record.
|
||
let openInfo = $state(null); // { description, genre, label } | null
|
||
let descExpanded = $state(false);
|
||
const albumInfoCache = new Map(); // "artistId|title" -> Promise<info|null>
|
||
const INFO_CACHE_MAX = 64;
|
||
/** Lede + body for the open record, from the shared splitter. */
|
||
const railDesc = $derived(splitDescription(openInfo?.description));
|
||
|
||
function fetchAlbumInfo(artistId, artist, title) {
|
||
const key = `${artistId ?? ""}|${normKey(title)}`;
|
||
if (!albumInfoCache.has(key)) {
|
||
albumInfoCache.set(key, api.catalogAlbumInfo(artistId, artist, title).catch(() => null));
|
||
while (albumInfoCache.size > INFO_CACHE_MAX) {
|
||
albumInfoCache.delete(albumInfoCache.keys().next().value);
|
||
}
|
||
}
|
||
return { key, promise: albumInfoCache.get(key) };
|
||
}
|
||
|
||
async function loadAlbumInfo(album) {
|
||
openInfo = null;
|
||
descExpanded = false;
|
||
if (!album?.title) return;
|
||
const anchored = followFor(album.artist);
|
||
const wanted = album.id;
|
||
const { key, promise } = fetchAlbumInfo(anchored?.artistId ?? null, album.artist, album.title);
|
||
const info = await promise;
|
||
// A load-time network burst can time one source out. Show what arrived,
|
||
// but do not pin a partial result for the session — the next open
|
||
// retries, and the backend caches each source so a retry is cheap.
|
||
if (!info || !info.description || (!info.genre && !info.label)) {
|
||
albumInfoCache.delete(key);
|
||
}
|
||
if (openId === wanted) openInfo = info;
|
||
}
|
||
|
||
// ---- the record's own page ---------------------------------------------
|
||
// A library row has no catalogue id — it is a folder of files. The join to
|
||
// one is by normalized title WITHIN an anchored artist's own catalogue,
|
||
// which is the same join `toggleGaps` already makes to decide what is
|
||
// missing, and it reuses that page's warm cache. Without an anchor there is
|
||
// nothing to join against, so the title simply stays plain text.
|
||
let openCatalogId = $state(null);
|
||
|
||
async function resolveCatalogAlbum(album) {
|
||
openCatalogId = null;
|
||
const anchored = album ? followFor(album.artist) : null;
|
||
if (!anchored || !album?.title) return;
|
||
const wanted = album.id;
|
||
try {
|
||
const page = await getCatalogArtistPage(anchored.artistId);
|
||
// Same-titled records collapse to one normalized key (American
|
||
// Football × 3, Weezer × 7). The artist page's rows are dated, so
|
||
// the local folder's year picks the right one; only then does the
|
||
// first row stand in.
|
||
const same = (page?.albums ?? []).filter(
|
||
(r) => normKey(r.title) === normKey(album.title),
|
||
);
|
||
const match =
|
||
(album.year != null
|
||
&& same.find((r) =>
|
||
String(r.release_date ?? "").startsWith(String(album.year)),
|
||
))
|
||
|| same[0];
|
||
if (openId === wanted && match) openCatalogId = match.id;
|
||
} catch { /* no link; the title stays plain */ }
|
||
}
|
||
|
||
/** This record's artwork in the shared full-window viewer — the same one
|
||
* the Now Playing cover and the artist page's expander rail open. The
|
||
* canonical FILE path, not the data URL the rail paints: getArt's cache is
|
||
* byte-budgeted for thumbnails and the point of the viewer is the master
|
||
* resolution (3000px-class for library albums). A generated cover has no
|
||
* file behind it, so there is nothing to open. */
|
||
function openArtwork() {
|
||
if (!openAlbum?.art_path) return;
|
||
ui.artwork = {
|
||
src: openAlbum.art_path,
|
||
local: true,
|
||
title: openAlbum.title ?? "",
|
||
subtitle: openAlbum.artist ?? "",
|
||
};
|
||
}
|
||
|
||
// The pointer wins while it is over a record; it settles back to the open
|
||
// one when it leaves, and to the global accent when neither applies.
|
||
const highlightAccent = $derived(
|
||
albumAccents[hoverId] ?? albumAccents[openId] ?? undefined,
|
||
);
|
||
|
||
function toggleExpander(album) {
|
||
if (openId === album.id) { closeExpander(); return; }
|
||
openId = album.id;
|
||
openTracks = [];
|
||
openLoading = true;
|
||
loadingVisible = false;
|
||
clearTimeout(loadGraceTimer);
|
||
// Nothing at all until the load has outlasted LOADING_GRACE: a warm
|
||
// open resolves in tens of ms and used to flash a skeleton on its way
|
||
// past (CatalogArtist's own rule).
|
||
loadGraceTimer = setTimeout(() => { if (openLoading) loadingVisible = true; }, LOADING_GRACE);
|
||
const id = album.id;
|
||
api.albumTracks(id).then((tracks) => {
|
||
if (openId !== id) return;
|
||
openTracks = tracks;
|
||
openLoading = false;
|
||
}).catch(() => {
|
||
if (openId !== id) return;
|
||
openTracks = [];
|
||
openLoading = false;
|
||
});
|
||
// Both off the tracklist's critical path: the panel opens on the local
|
||
// files it already has, and the editorial lands into it.
|
||
void loadAlbumInfo(album);
|
||
void resolveCatalogAlbum(album);
|
||
}
|
||
function closeExpander() {
|
||
openId = null;
|
||
openTracks = [];
|
||
openLoading = false;
|
||
openInfo = null;
|
||
openCatalogId = null;
|
||
descExpanded = false;
|
||
}
|
||
// A view or grouping change closes the panel — CatalogArtist's own rule
|
||
// for a tab change: the open expander lives inside the grid being
|
||
// replaced, and its row means nothing in the new arrangement.
|
||
function setView(v) { if (view !== v) { view = v; closeExpander(); bumpDeal(); } }
|
||
function setGroup(g) {
|
||
if (group !== g) { group = g; expandedShelves = {}; gapOpen = {}; closeExpander(); bumpDeal(); }
|
||
}
|
||
|
||
// ---- grid columns, for the expander's row arithmetic ------------------
|
||
// Fixed tracks (150px cards, 16px column gap) exactly as the artist page.
|
||
const CARD_W = 150;
|
||
const GRID_GAP = 16;
|
||
let gridColsV = $state(0);
|
||
const gridColsMap = new Map(); // section key -> column count
|
||
function measureCols(node, key) {
|
||
let currentKey = key;
|
||
const measure = () => {
|
||
const w = node.clientWidth;
|
||
const cols = Math.max(1, Math.floor((w + GRID_GAP) / (CARD_W + GRID_GAP)));
|
||
if (gridColsMap.get(currentKey) !== cols) {
|
||
gridColsMap.set(currentKey, cols);
|
||
gridColsV++;
|
||
}
|
||
};
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(node);
|
||
measure();
|
||
return {
|
||
update(nextKey) { currentKey = nextKey; measure(); },
|
||
destroy() { ro.disconnect(); },
|
||
};
|
||
}
|
||
/** Index in `items` after which the expander renders (-1 = not here): the
|
||
* last card of the open album's row — CatalogArtist `expanderIndex`. */
|
||
function expanderIndex(items, sectionKey) {
|
||
void gridColsV;
|
||
const idx = items.findIndex((a) => a.id === openId);
|
||
if (idx < 0) return -1;
|
||
const cols = gridColsMap.get(sectionKey) || 1;
|
||
return Math.min(Math.floor(idx / cols) * cols + cols - 1, items.length - 1);
|
||
}
|
||
/** Notch x: centred under the open card — CatalogArtist `notchLeft`. */
|
||
function notchLeft(items, sectionKey) {
|
||
void gridColsV;
|
||
const idx = items.findIndex((a) => a.id === openId);
|
||
const cols = gridColsMap.get(sectionKey) || 1;
|
||
const col = idx >= 0 ? idx % cols : 0;
|
||
return `${col * (CARD_W + GRID_GAP) + CARD_W / 2 - 6}px`;
|
||
}
|
||
|
||
// ---- the expander's motion, CatalogArtist's numbers -------------------
|
||
const reducedMotion = () =>
|
||
typeof matchMedia === "function"
|
||
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
/** Open/close: the height is read LIVE per frame and kept monotonic —
|
||
* the tracklist lands mid-flight, and a captured height animates to a
|
||
* stale number (the 348→465px snap the app's comment records). The
|
||
* 16px margin skirt retires in step so a collapsed panel moves no row. */
|
||
function expandSlide(node, { duration = 460 } = {}) {
|
||
if (reducedMotion()) return { duration: 1 };
|
||
let full = node.scrollHeight;
|
||
const skirt = parseFloat(getComputedStyle(node).marginBottom) || 0;
|
||
return {
|
||
duration,
|
||
easing: quintOut,
|
||
tick: (t, u) => {
|
||
full = Math.max(full, node.scrollHeight);
|
||
if (t === 1) {
|
||
node.style.cssText = node.style.cssText.replace(
|
||
/(?:overflow|height|min-height|opacity|transform|margin-bottom):[^;]*;?/g,
|
||
"",
|
||
);
|
||
return;
|
||
}
|
||
node.style.overflow = "hidden";
|
||
node.style.minHeight = "0px";
|
||
node.style.height = `${t * full}px`;
|
||
node.style.opacity = `${Math.min(1, t / 0.74)}`;
|
||
node.style.transform = `translateY(${-8 * u}px)`;
|
||
node.style.marginBottom = `${skirt * t}px`;
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The waterfall, as an action so it runs however the panel came to exist
|
||
* (CatalogArtist `staggerIn` — an `in:` transition misses first renders).
|
||
* To each element's OWN resting opacity: .rail-glow rests at 0.32, and
|
||
* animating it to 1 is the bright-then-dark bleed. */
|
||
function staggerIn(node) {
|
||
if (reducedMotion()) return;
|
||
for (const { sel, delay, lift } of SWAP_PARTS) {
|
||
for (const el of node.querySelectorAll(sel)) {
|
||
const rest = getComputedStyle(el).opacity || "1";
|
||
el.animate(
|
||
[
|
||
{ opacity: 0, transform: lift ? "translateY(7px)" : "none" },
|
||
{ opacity: rest, transform: "none" },
|
||
],
|
||
{
|
||
duration: SWAP_MS * 0.7,
|
||
delay: delay * (SWAP_MS / 360),
|
||
easing: SWAP_EASE,
|
||
fill: "backwards",
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Fade+lift ONE element from its own mount, with the same WAAPI call
|
||
* `staggerIn` makes.
|
||
*
|
||
* The description needs this because neither of the other two mechanisms
|
||
* ever fired for it, and that was measured, not assumed:
|
||
*
|
||
* - `staggerIn` is an action on .xp-panel and queries for its parts when
|
||
* that element is created. On a warm open the description block is
|
||
* created microseconds LATER — same flush, same frame — so the query
|
||
* finds nothing.
|
||
* - Svelte plays an `in:` only for an element added by a later UPDATE.
|
||
* Landing in the same flush is not a later update, so the intro is
|
||
* skipped. Audited on a warm open: every other part reached opacity 0
|
||
* on its way in; .rail-lede/.rail-desc/.rail-pills sat at 1 for all
|
||
* 168 sampled frames, with no animation of any kind.
|
||
*
|
||
* An action always runs when its own element is created, whichever flush
|
||
* that happens to be, so this covers the warm and cold paths identically.
|
||
* Delays are quoted at the mock's 360ms baseline and scaled like the rest.
|
||
*/
|
||
function partAnim(node, { delay = 0 } = {}) {
|
||
if (reducedMotion()) return;
|
||
const rest = getComputedStyle(node).opacity || "1";
|
||
node.animate(
|
||
[
|
||
{ opacity: 0, transform: "translateY(7px)" },
|
||
{ opacity: rest, transform: "none" },
|
||
],
|
||
{
|
||
duration: SWAP_MS * 0.7,
|
||
delay: delay * (SWAP_MS / 360),
|
||
easing: SWAP_EASE,
|
||
fill: "backwards",
|
||
},
|
||
);
|
||
}
|
||
|
||
/** One part of the swap, per track row — CatalogArtist `partIn`. */
|
||
function partIn(node, { delay = 0, duration = SWAP_MS * 0.7 } = {}) {
|
||
if (reducedMotion()) return { duration: 1 };
|
||
const rest = parseFloat(getComputedStyle(node).opacity) || 1;
|
||
return {
|
||
delay,
|
||
duration,
|
||
easing: quintOut,
|
||
css: (t, u) => `opacity: ${t * rest}; transform: translateY(${7 * u}px);`,
|
||
};
|
||
}
|
||
|
||
/** The outgoing panel of a same-row switch: out of flow so the incoming
|
||
* one has the stage to itself — CatalogArtist `panelLeave`. */
|
||
function panelLeave(node) {
|
||
node.style.position = "absolute";
|
||
node.style.left = "0";
|
||
node.style.right = "0";
|
||
node.style.top = "0";
|
||
node.style.pointerEvents = "none";
|
||
return {
|
||
duration: reducedMotion() ? 1 : SWAP_MS * 0.4,
|
||
easing: quintOut,
|
||
css: (t) => `opacity: ${t}`,
|
||
};
|
||
}
|
||
|
||
/** Hold the stage at an eased height that TRACKS the panel rather than
|
||
* animating once to a snapshot — CatalogArtist `followStageHeight`. */
|
||
function followStageHeight(stage, panel, fromH) {
|
||
const seq = String((+(stage.dataset.swapSeq || 0)) + 1);
|
||
stage.dataset.swapSeq = seq;
|
||
stage.style.overflow = "hidden";
|
||
stage.style.height = `${fromH}px`;
|
||
void stage.offsetHeight;
|
||
stage.style.transition = `height ${SWAP_MS}ms ${SWAP_EASE}`;
|
||
let settleTimer = 0;
|
||
let ro = null;
|
||
const finish = () => {
|
||
if (stage.dataset.swapSeq !== seq) return;
|
||
ro?.disconnect();
|
||
clearTimeout(settleTimer);
|
||
stage.style.transition = "";
|
||
stage.style.height = "";
|
||
stage.style.overflow = "";
|
||
delete stage.dataset.swapSeq;
|
||
};
|
||
const apply = () => {
|
||
if (stage.dataset.swapSeq !== seq) return;
|
||
const h = panel.getBoundingClientRect().height;
|
||
if (h > 0 && stage.style.height !== `${h}px`) stage.style.height = `${h}px`;
|
||
clearTimeout(settleTimer);
|
||
settleTimer = setTimeout(finish, SWAP_MS + 60);
|
||
};
|
||
ro = new ResizeObserver(apply);
|
||
ro.observe(panel);
|
||
apply();
|
||
setTimeout(finish, 4000);
|
||
}
|
||
|
||
/** The incoming panel of a same-row switch — CatalogArtist `panelEnter`:
|
||
* Svelte only times this; staggerIn carries the visible motion. */
|
||
function panelEnter(node) {
|
||
const quick = reducedMotion();
|
||
const stage = node.parentElement;
|
||
const leaving = stage?.querySelector(".xp-panel[style*='absolute']");
|
||
if (stage && leaving && !quick) {
|
||
followStageHeight(stage, node, leaving.getBoundingClientRect().height);
|
||
}
|
||
return { duration: quick ? 1 : SWAP_MS, css: () => "" };
|
||
}
|
||
|
||
/** Lift the open row to the line under the pinned toolbar, 420ms. The
|
||
* line is read off the BAR live each frame — its height depends on
|
||
* whether the controls wrapped and whether the column header is joined —
|
||
* and the scroll reserve is stood up before the first frame so the range
|
||
* can hold the target (the prototype's xpReveal).
|
||
*
|
||
* The reserve is ONE number on the page root (`--xp-reserve`, spent as end
|
||
* padding), not a min-height on the grid or section holding the panel.
|
||
* Two reasons, both measured:
|
||
*
|
||
* - On the grid it was computed as if the grid were the last thing on the
|
||
* page, so it ignored the shelves below and reserved a whole viewport
|
||
* that was already there: 1102px of floor under a 626px grid, a 476px
|
||
* empty band between the open panel and the shelf's gap line.
|
||
* - Per-host, a switch reads the OTHER host's in-flight reserve as real
|
||
* content — the range it is about to give back — and under-reserves by
|
||
* exactly that. Switching up a section parked the row 166px below the
|
||
* bar with nothing left to correct with.
|
||
*
|
||
* One writer at the very end of the page has neither problem, and what it
|
||
* adds reads as room to scroll rather than a hole inside a shelf. */
|
||
function revealIntoView(node) {
|
||
if (reducedMotion()) return;
|
||
const pane = node.closest(".content");
|
||
const row = node.previousElementSibling;
|
||
const page = node.closest(".lib-page");
|
||
if (!pane || !row || !page) return;
|
||
const line = () => (toolbarEl
|
||
? toolbarEl.getBoundingClientRect().bottom + 10
|
||
: pane.getBoundingClientRect().top + 100);
|
||
const standing = () => parseFloat(page.style.getPropertyValue("--xp-reserve")) || 0;
|
||
|
||
/** Height outgoing expanders are still holding through their 460ms
|
||
* collapse. Both terms below have to discount it, or each is computed
|
||
* against a page mid-switch: `above` because the row is drifting up
|
||
* under us, `all` because the page is about to be shorter. Zero on a
|
||
* plain open, and on a same-row switch (that one keeps its
|
||
* .album-expander and swaps the panel inside it). */
|
||
const vanishing = (aboveRowOnly) => {
|
||
const rowTop = row.getBoundingClientRect().top;
|
||
let h = 0;
|
||
for (const el of page.querySelectorAll(".album-expander")) {
|
||
if (el === node) continue;
|
||
const r = el.getBoundingClientRect();
|
||
if (aboveRowOnly && r.bottom > rowTop + 1) continue;
|
||
h += r.height + (parseFloat(getComputedStyle(el).marginBottom) || 0);
|
||
}
|
||
return h;
|
||
};
|
||
|
||
/** Where the row is going, in the pane's scroll coordinates. */
|
||
const rowTarget = () => row.getBoundingClientRect().top - line()
|
||
+ pane.scrollTop - vanishing(true);
|
||
|
||
/** The page's settled height: what it measures now, less the reserve it
|
||
* is already carrying and less everything mid-collapse. Taken from the
|
||
* page's own box rather than pane.scrollHeight, which is PINNED at
|
||
* clientHeight while the content is shorter than the viewport and so
|
||
* cannot tell "short page" from "exactly one viewport". */
|
||
const settledContent = () => page.getBoundingClientRect().bottom
|
||
- (pane.getBoundingClientRect().top + pane.clientTop - pane.scrollTop)
|
||
+ (parseFloat(getComputedStyle(pane).paddingBottom) || 0)
|
||
- standing() - vanishing(false);
|
||
|
||
const reserve = () => Math.max(0, Math.round(
|
||
rowTarget() + pane.clientHeight - settledContent(),
|
||
));
|
||
// Re-stood every frame, RAISING only: a fall shrinks the range under a
|
||
// running ease, and the pane is parked on the clamp by construction, so
|
||
// the browser takes the whole difference in one frame. Falls belong to
|
||
// destroy(), which hands the whole reserve back at once.
|
||
const stand = () => {
|
||
const need = reserve();
|
||
if (standing() < need) page.style.setProperty("--xp-reserve", `${need}px`);
|
||
};
|
||
/** The one safe moment to LOWER it: everything has settled, so switching
|
||
* from a deep record to a shallow one need not keep the deep one's
|
||
* reserve as dead scroll at the end of the page (measured 521px). It
|
||
* falls only as far as the pane can afford — dropping past the point
|
||
* where the clamp overtakes where the pane is parked is taken in a
|
||
* single frame, and that is the yank raise-only exists to prevent. */
|
||
const relax = () => {
|
||
const need = reserve();
|
||
const held = standing();
|
||
if (need >= held) return;
|
||
const afford = Math.max(0, Math.floor(
|
||
pane.scrollHeight - pane.clientHeight - pane.scrollTop,
|
||
));
|
||
const to = Math.max(need, held - afford);
|
||
if (to < held) page.style.setProperty("--xp-reserve", `${to}px`);
|
||
};
|
||
stand();
|
||
const from = pane.scrollTop;
|
||
const t0 = performance.now();
|
||
// The user's own scrolling wins immediately.
|
||
let cancelled = false;
|
||
const cancel = () => { cancelled = true; };
|
||
pane.addEventListener("wheel", cancel, { once: true, passive: true });
|
||
let raf = requestAnimationFrame(function step(now) {
|
||
raf = null;
|
||
if (cancelled || !node.isConnected) return;
|
||
stand();
|
||
const t = Math.min(1, (now - t0) / 420);
|
||
// Clamped to what the range can actually hold: easing at a target it
|
||
// cannot reach is what makes the open judder.
|
||
const target = Math.max(
|
||
0, Math.min(rowTarget(), pane.scrollHeight - pane.clientHeight),
|
||
);
|
||
if (t < 1) {
|
||
pane.scrollTop = from + (target - from) * (1 - (1 - t) ** 3);
|
||
raf = requestAnimationFrame(step);
|
||
return;
|
||
}
|
||
// A switch's outgoing panel collapses for 460ms — longer than this
|
||
// 420ms ease — so its last frames move the row after the ease has
|
||
// stopped: measured 6px, the row parked 4 under the bar instead of
|
||
// 10. Track the target until nothing is vanishing any more. The
|
||
// target moves ~1px a frame by then, so this reads as the tail of
|
||
// the same motion rather than a correction.
|
||
pane.scrollTop = target;
|
||
if (now - t0 < 1000 && vanishing(false) > 0) {
|
||
raf = requestAnimationFrame(step);
|
||
return;
|
||
}
|
||
relax();
|
||
});
|
||
return {
|
||
destroy() {
|
||
if (raf) cancelAnimationFrame(raf);
|
||
pane.removeEventListener("wheel", cancel);
|
||
// A switch mounts the incoming panel — and stands its reserve —
|
||
// before this one's outro ends. Clearing unconditionally would
|
||
// drop the reserve the new reveal is relying on, in the frame
|
||
// before anything puts it back. Only the last panel out pays.
|
||
const others = [...page.querySelectorAll(".album-expander")]
|
||
.filter((el) => el !== node);
|
||
if (!others.length) page.style.removeProperty("--xp-reserve");
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---- playback ---------------------------------------------------------
|
||
function playOpen(startIndex = 0) {
|
||
if (!openAlbum || !openTracks.length) return;
|
||
playAlbum(openTracks, `${openAlbum.artist} — ${openAlbum.title}`, startIndex, openAlbum.art_path);
|
||
}
|
||
async function playWholeAlbum(album) {
|
||
try {
|
||
const tracks = album.id === openId && openTracks.length
|
||
? openTracks
|
||
: await api.albumTracks(album.id);
|
||
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) {
|
||
return tracks[0]?.path ?? null;
|
||
}
|
||
|
||
// ================================================================ chrome
|
||
// fit(), as reactive measurements: the toolbar's wrap is measured (a
|
||
// hard-coded breakpoint goes stale), --tb-h is MEASURED off the bar (a
|
||
// typed 50px parked the joined header behind a two-row bar at 1152), and
|
||
// the table drops columns by tier so a record's name is never squeezed out.
|
||
let pageEl = $state(null);
|
||
let toolbarEl = $state(null);
|
||
let narrow = $state(false);
|
||
let tbH = $state(50);
|
||
let paneH = $state(760);
|
||
let colsTier = $state("all");
|
||
const COL_TIERS = [
|
||
{ name: "all", min: 960 },
|
||
{ name: "b", min: 842 },
|
||
{ name: "c", min: 686 },
|
||
{ name: "d", min: 0 },
|
||
];
|
||
function measureChrome() {
|
||
if (!pageEl || !toolbarEl) return;
|
||
const pane = pageEl.closest(".content");
|
||
if (pane) paneH = pane.clientHeight;
|
||
// Every child on the bar's first row; the column-tier decision first,
|
||
// because it changes the bar's height when the header is joined.
|
||
const room = pageEl.clientWidth;
|
||
colsTier = (COL_TIERS.find((t) => room >= t.min) ?? COL_TIERS[COL_TIERS.length - 1]).name;
|
||
const kids = [...toolbarEl.children].filter((el) => !el.classList.contains("lb-th"));
|
||
const gap = parseFloat(getComputedStyle(toolbarEl).columnGap) || 14;
|
||
const need = kids.reduce((n, el) => n + el.scrollWidth, 0) + gap * (kids.length - 1);
|
||
narrow = need > toolbarEl.clientWidth;
|
||
tbH = Math.round(toolbarEl.getBoundingClientRect().height);
|
||
}
|
||
$effect(() => {
|
||
if (!pageEl || !toolbarEl) return;
|
||
const ro = new ResizeObserver(measureChrome);
|
||
ro.observe(pageEl);
|
||
ro.observe(toolbarEl);
|
||
measureChrome();
|
||
return () => ro.disconnect();
|
||
});
|
||
$effect(() => {
|
||
// Structural knobs that change what the bar holds or the page's width.
|
||
void view; void group; void q; void narrow;
|
||
tick().then(measureChrome);
|
||
});
|
||
|
||
// ---- the index rail ---------------------------------------------------
|
||
// Every grouping gets a label; the 34px gutter is reserved by the page
|
||
// whether or not this grouping has anything to index, so no control click
|
||
// ever changes the width of the content column (handoff §4b.1).
|
||
const RAIL_LABEL = {
|
||
artist: (k) => k.replace(/^(the|a|an)\s+/i, "").charAt(0).toUpperCase(),
|
||
decade: (k) => (/^\d/.test(k) ? String(k).slice(0, 4) : "—"),
|
||
format: (k) => {
|
||
const [fmt, depth] = String(k).split(" ");
|
||
if (!/^[A-Z0-9]/.test(fmt)) return "—";
|
||
return depth ? fmt.slice(0, 2) + depth.slice(0, 2) : fmt.slice(0, 4);
|
||
},
|
||
rtype: (k) => String(k).slice(0, 3),
|
||
added: (k) => ((/\d{4}/.exec(k) || ["—"])[0]),
|
||
};
|
||
// Group = None indexes the SORT instead — the initial of the artist or
|
||
// title, the decade of the year, the year of a date.
|
||
const FLAT_INDEX = {
|
||
artist: (a) => (a.sortName || "?").charAt(0).toUpperCase(),
|
||
complete: (a) => (a.sortName || "?").charAt(0).toUpperCase(),
|
||
title: (a) => (a.title || "?").replace(/^(the|a|an)\s+/i, "").charAt(0).toUpperCase(),
|
||
year: (a) => (a.year ? String(Math.floor(a.year / 10) * 10) : "—"),
|
||
added: (a) => (a.added_at ? String(new Date(a.added_at * 1000).getFullYear()) : "—"),
|
||
played: (a) => (a.lastPlayedAt ? String(new Date(a.lastPlayedAt * 1000).getFullYear()) : "—"),
|
||
neglect: (a) => (a.lastPlayedAt ? String(new Date(a.lastPlayedAt * 1000).getFullYear()) : "never"),
|
||
format: (a) => ((a.formats[0] || "—").toUpperCase().slice(0, 4)),
|
||
rtype: (a) => String(CAT_ONE[a.cat] || "").slice(0, 3),
|
||
};
|
||
const flatAnchors = $derived.by(() => {
|
||
if (group !== "none") return null;
|
||
const fn = FLAT_INDEX[sortKey];
|
||
if (!fn || !grouped.length) return null;
|
||
const at = new Map();
|
||
const items = [];
|
||
const seen = new Set();
|
||
for (const a of grouped[0][1]) {
|
||
const l = fn(a);
|
||
if (!l || seen.has(l)) continue;
|
||
seen.add(l);
|
||
items.push(l);
|
||
at.set(a.id, l);
|
||
}
|
||
return items.length > 1 ? { items, at } : null;
|
||
});
|
||
const railItems = $derived.by(() => {
|
||
if (group === "none") {
|
||
return flatAnchors ? flatAnchors.items.map((l) => [l, l]) : [];
|
||
}
|
||
const label = RAIL_LABEL[group] ?? ((k) => String(k).slice(0, 4));
|
||
const seen = new Set();
|
||
const items = [];
|
||
for (const [k] of grouped) {
|
||
const l = label(k);
|
||
if (!l || seen.has(l)) continue;
|
||
seen.add(l);
|
||
items.push([l, k]);
|
||
}
|
||
return items.length > 1 ? items : [];
|
||
});
|
||
const anchorId = (k) => `libg-${String(k).replace(/[^a-zA-Z0-9]/g, "") || "x"}`;
|
||
|
||
/** A rail click LANDS: `content-visibility` makes unpainted heights an
|
||
* estimate, so one scroll overshoots by up to ~1100px — correct until the
|
||
* target stops moving, bail on the user's wheel (handoff §4b.2). */
|
||
function jumpTo(key) {
|
||
const pane = pageEl?.closest(".content");
|
||
if (!pane) return;
|
||
const id = anchorId(key);
|
||
const delta = () => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return null;
|
||
const clear = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||
const top = pane.getBoundingClientRect().top
|
||
+ parseFloat(getComputedStyle(pane).paddingTop || 0);
|
||
return el.getBoundingClientRect().top - top - clear;
|
||
};
|
||
const first = delta();
|
||
if (first == null) return;
|
||
pane.scrollTo({ top: pane.scrollTop + first, behavior: "smooth" });
|
||
let quiet = 0;
|
||
let frames = 0;
|
||
let cancelled = false;
|
||
const stop = () => { cancelled = true; };
|
||
pane.addEventListener("wheel", stop, { passive: true });
|
||
const settle = () => {
|
||
if (cancelled) return pane.removeEventListener("wheel", stop);
|
||
const d = delta();
|
||
if (d == null) return pane.removeEventListener("wheel", stop);
|
||
if (Math.abs(d) > 1) { pane.scrollTop += d; quiet = 0; } else quiet++;
|
||
if (quiet < 8 && frames++ < 90) requestAnimationFrame(settle);
|
||
else pane.removeEventListener("wheel", stop);
|
||
};
|
||
setTimeout(settle, 420);
|
||
}
|
||
|
||
// ---- the entrance: Wave -----------------------------------------------
|
||
// The records arrive in a sweep down (and slightly across) the screen the
|
||
// reader is looking at; the chrome — hero, toolbar, column header — never
|
||
// moves. Delay comes from POSITION, normalized to the topmost thing that
|
||
// is both moving and on screen; delays are pulled forward over the next
|
||
// few frames as content-visibility geometry lands (handoff §4b.5b-5d).
|
||
const WAVE = { rise: 14, dur: 300, span: 420, skew: 0.18 };
|
||
const DEAL_SEL = ".lib-shelf-head, .lib-subhead, .lib-group-rule, .release,"
|
||
+ " .lb-tr:not(.lb-th), .album-expander, .lib-gapline, .lib-more";
|
||
let dealSeq = $state(1);
|
||
let dealGen = 0;
|
||
function bumpDeal() { dealSeq++; }
|
||
$effect(() => {
|
||
void dealSeq;
|
||
if (!pageEl || reducedMotion()) return;
|
||
const gen = ++dealGen;
|
||
tick().then(() => { if (gen === dealGen) dealEntrance(); });
|
||
});
|
||
function dealEntrance() {
|
||
const pane = pageEl?.closest(".content");
|
||
if (!pane) return;
|
||
const gen = dealGen;
|
||
const anims = runDeal(pane, null);
|
||
let n = 0;
|
||
const retime = () => {
|
||
if (gen !== dealGen) return;
|
||
runDeal(pane, anims);
|
||
if (++n < 5) requestAnimationFrame(retime);
|
||
};
|
||
requestAnimationFrame(retime);
|
||
}
|
||
function runDeal(pane, existing) {
|
||
const box = pane.getBoundingClientRect();
|
||
const slack = box.height;
|
||
const items = [];
|
||
for (const el of pageEl.querySelectorAll(DEAL_SEL)) {
|
||
const r = el.getBoundingClientRect();
|
||
if (r.bottom <= box.top - slack || r.top >= box.bottom + slack) continue;
|
||
const dy = Math.max(0, Math.min(1, (r.top - box.top) / Math.max(1, box.height)));
|
||
const dx = Math.max(0, Math.min(1, (r.left - box.left) / Math.max(1, box.width)));
|
||
items.push({
|
||
el,
|
||
score: dy * (1 - WAVE.skew) + dx * WAVE.skew,
|
||
inView: r.bottom > box.top && r.top < box.bottom,
|
||
});
|
||
}
|
||
const out = existing || new Map();
|
||
if (!items.length) return out;
|
||
const seen = items.filter((it) => it.inView);
|
||
const first = Math.min(...(seen.length ? seen : items).map((it) => it.score));
|
||
for (const { el, score } of items) {
|
||
const delay = Math.round(Math.max(0, score - first) * WAVE.span);
|
||
const prev = out.get(el);
|
||
if (!prev) {
|
||
// On a retime pass, never START one: an element outside the
|
||
// slack a frame ago is at full strength now, and animating it
|
||
// from zero would be a flash rather than an entrance.
|
||
if (existing) continue;
|
||
out.set(el, el.animate(
|
||
[
|
||
{ opacity: 0, transform: `translateY(${WAVE.rise}px)` },
|
||
{ opacity: 1, transform: "none" },
|
||
],
|
||
{ duration: WAVE.dur, delay, easing: "cubic-bezier(.2,.7,.3,1)", fill: "backwards" },
|
||
));
|
||
} else {
|
||
const was = prev.effect.getTiming().delay;
|
||
// Closer is a skip-ahead; back is a rewind — allowed only while
|
||
// the thing has not arrived yet, where it is invisible.
|
||
const hidden = (prev.currentTime || 0) < was;
|
||
if (delay < was - 8 || (hidden && delay > was + 8)) {
|
||
prev.effect.updateTiming({ delay });
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- header actions ---------------------------------------------------
|
||
async function rescan() {
|
||
if (rescanning) return;
|
||
rescanning = true;
|
||
try {
|
||
// Prune deletions + index additions on the backend FIRST — the
|
||
// refetch below otherwise re-reads the same stale rows, which is
|
||
// exactly what this button used to do.
|
||
await api.rescanLibrary().catch((e) => console.warn("rescan:", e));
|
||
await refreshLibrary();
|
||
await loadStats();
|
||
} finally {
|
||
rescanning = false;
|
||
}
|
||
}
|
||
|
||
// ---- boot -------------------------------------------------------------
|
||
$effect(() => {
|
||
loadStats();
|
||
loadFollowNames();
|
||
});
|
||
|
||
// Close popovers on outside interaction.
|
||
function onWindowClick(e) {
|
||
if (menuOpen && !e.target.closest(".sortwrap")) menuOpen = false;
|
||
}
|
||
|
||
const PER_SHELF = 18;
|
||
</script>
|
||
|
||
<svelte:window onclick={onWindowClick} />
|
||
|
||
{#snippet cover(a, size)}
|
||
{#if a.art_path}
|
||
{#if coverUrls[a.id]}
|
||
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||
{:else}
|
||
<div class="cover gen-pending"></div>
|
||
{/if}
|
||
{:else}
|
||
{@const g = genFor(a)}
|
||
<div class="cover lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}">
|
||
<span class="lib-gen-glyph"><Icon name={g.icon} size={Math.round(size * 0.3)} /></span>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
|
||
{#snippet releaseCard(a, sectionKey)}
|
||
<div
|
||
class="release"
|
||
class:active={openId === a.id}
|
||
style={accentFor(a)}
|
||
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
|
||
role="button"
|
||
tabindex="0"
|
||
use:loadCover={a}
|
||
title={openId === a.id ? "Close" : `Open ${a.title}`}
|
||
onpointerenter={() => (hoverId = a.id)}
|
||
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
|
||
onclick={() => toggleExpander(a)}
|
||
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
|
||
>
|
||
<div class="cover-wrap">
|
||
{#if chipFor(a)}<span class="ed-badge">{chipFor(a)}</span>{/if}
|
||
{@render cover(a, CARD_W)}
|
||
<button
|
||
class="hoverplay"
|
||
title="Play"
|
||
onclick={(e) => { e.stopPropagation(); playWholeAlbum(a); }}
|
||
><Icon name="play" size={13} /></button>
|
||
</div>
|
||
<div class="title" title={a.title}>{a.title}</div>
|
||
<div class="sub">{subFor(a)}</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet gapCard(r)}
|
||
<!-- A record you do NOT own, at full size and dimmed. It is filed by the
|
||
same classifier as everything else, off the CatalogAlbum payload's own
|
||
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
|
||
is_soundtrack flags Deezer has no record type for) — "other" means the
|
||
classifier had nothing to go on, so it is left unsaid rather than
|
||
printed under every card. -->
|
||
{@const cat = fileRelease({
|
||
title: r.title,
|
||
record_type: r.record_type,
|
||
track_count: r.track_count,
|
||
is_live: r.is_live,
|
||
is_soundtrack: r.is_soundtrack,
|
||
})}
|
||
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}>
|
||
<div class="cover-wrap">
|
||
{#if r.release_date?.slice(0, 4)}
|
||
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
||
{/if}
|
||
{#if r.cover}
|
||
<img class="cover" src={r.cover} alt="" loading="lazy" draggable="false" />
|
||
{:else}
|
||
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
||
{/if}
|
||
</div>
|
||
<div class="title" title={r.title}>{r.title}</div>
|
||
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet expander(items, sectionKey)}
|
||
<div
|
||
class="album-expander"
|
||
style={accentFor(openAlbum)}
|
||
transition:expandSlide={{ duration: 460 }}
|
||
use:revealIntoView
|
||
>
|
||
<!-- In List a row spans the pane, so the notch sits under the row's
|
||
ARTWORK (10px inset + half the 26px thumb − half the notch). -->
|
||
<span class="notch" style="left: {sectionKey === 'list' ? '17px' : notchLeft(items, sectionKey)}"></span>
|
||
<div class="xp-stage">
|
||
{#key openId}
|
||
<div class="xp-panel" use:staggerIn in:panelEnter out:panelLeave>
|
||
<div class="xp-body">
|
||
<div class="xp-rail">
|
||
{#if openAlbum?.art_path && coverUrls[openAlbum.id]}
|
||
<img class="rail-glow" src={coverUrls[openAlbum.id]} alt="" aria-hidden="true" draggable="false" />
|
||
{:else if openAlbum && !openAlbum.art_path}
|
||
{@const g = genFor(openAlbum)}
|
||
<div class="rail-glow lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}" aria-hidden="true"></div>
|
||
{/if}
|
||
<div class="rail-shade"></div>
|
||
<!-- The artwork is a button: it opens the shared
|
||
full-window viewer, the same one the Now Playing
|
||
cover and the artist page's rail open. Disabled
|
||
for a generated cover — there is no file to
|
||
show, so it offers no affordance either. -->
|
||
<button
|
||
class="rail-art-stage"
|
||
title="View artwork"
|
||
aria-label="View artwork full screen"
|
||
disabled={!openAlbum?.art_path}
|
||
onclick={openArtwork}
|
||
>
|
||
{#if openAlbum?.art_path && coverUrls[openAlbum.id]}
|
||
<img class="rail-art" src={coverUrls[openAlbum.id]} alt="" draggable="false" />
|
||
{:else if openAlbum}
|
||
{@const g = genFor(openAlbum)}
|
||
<div class="rail-art lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}">
|
||
<span class="lib-gen-glyph"><Icon name={g.icon} size={62} /></span>
|
||
</div>
|
||
{/if}
|
||
{#if openAlbum?.art_path}
|
||
<span class="rail-art-mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
|
||
{/if}
|
||
</button>
|
||
<!-- The name opens the record's own page, as it does
|
||
on the artist page. An anchored (followed)
|
||
artist's join gives the id up front; otherwise
|
||
the click resolves through openAlbumByName —
|
||
the now-playing links' guarded resolver, which
|
||
verifies artist AND title (never guesses) and
|
||
stays put on a settled miss. Plain text only
|
||
when the row has no artist+title to resolve. -->
|
||
<h3 class="rail-title">
|
||
{#if openCatalogId != null || (openAlbum?.artist && openAlbum?.title)}
|
||
<button
|
||
class="rail-title-link"
|
||
title="Open album"
|
||
onclick={() =>
|
||
openCatalogId != null
|
||
? openCatalogAlbum(openCatalogId)
|
||
: openAlbumByName(openAlbum.artist, openAlbum.title, {
|
||
year: openAlbum.year ?? null,
|
||
})}
|
||
>{openAlbum?.title ?? ""}</button>
|
||
{:else}
|
||
<span class="rail-title-text">{openAlbum?.title ?? ""}</span>
|
||
{/if}
|
||
</h3>
|
||
<p class="rail-meta">
|
||
{openAlbum?.year ?? "no year"}{openTracks.length
|
||
? ` · ${openTracks.length} tracks`
|
||
: openAlbum?.track_count
|
||
? ` · ${openAlbum.track_count} tracks`
|
||
: ""}{openAlbum?.secs ? ` · ${dur(openAlbum.secs)}` : ""}
|
||
</p>
|
||
{#if openAlbum && (openAlbum.bytes || openAlbum.avgKbps)}
|
||
<!-- The artist page's stat pair, carrying what a
|
||
FILE knows instead of what Last.fm knows. -->
|
||
<div class="rail-stats">
|
||
{#if openAlbum.bytes}
|
||
<span class="rail-stat"><b>{fmtBytes(openAlbum.bytes)}</b><i>on disk</i></span>
|
||
{/if}
|
||
{#if openAlbum.bytes && openAlbum.avgKbps}<span class="rail-stat-div"></span>{/if}
|
||
{#if openAlbum.avgKbps}
|
||
<span class="rail-stat"><b>{openAlbum.avgKbps}k</b><i>bitrate</i></span>
|
||
{/if}
|
||
{#if openAlbum.formats[0]}
|
||
<span class="rail-stat-note">{openAlbum.formats[0].toUpperCase()}{openAlbum.depth ? ` ${openAlbum.depth}` : ""}</span>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<div class="rail-cta">
|
||
<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"
|
||
disabled={!openTracks.length}
|
||
onclick={() => { const p = folderOf(openTracks); if (p) api.showInFolder(p); }}
|
||
><Icon name="folder-open" size={13} /></button>
|
||
</div>
|
||
{#if openInfo?.description}
|
||
<!-- The description's own first sentence,
|
||
promoted, then the body under a 4-line clamp
|
||
with a mask fade — the artist page's
|
||
treatment, class for class. Each part
|
||
carries its own `in:partIn` because the
|
||
description resolves after the panel is
|
||
built, so the staggered morph has already
|
||
run by the time it exists. -->
|
||
{#if railDesc.lede}
|
||
<p class="rail-lede" use:partAnim={{ delay: 135 }}>{railDesc.lede}</p>
|
||
{/if}
|
||
{#if railDesc.rest}
|
||
<p class="rail-desc" class:clamped={!descExpanded} use:partAnim={{ delay: 145 }}>{railDesc.rest}</p>
|
||
<button
|
||
class="rail-more"
|
||
class:open={descExpanded}
|
||
use:partAnim={{ delay: 160 }}
|
||
onclick={() => (descExpanded = !descExpanded)}
|
||
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
|
||
{/if}
|
||
{#if openInfo.genre || openInfo.label}
|
||
<div class="rail-pills" use:partAnim={{ delay: 170 }}>
|
||
{#if openInfo.genre}<span class="gpill accent">{openInfo.genre}</span>{/if}
|
||
{#if openInfo.label}<span class="gpill">{openInfo.label}</span>{/if}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
<div class="xp-tracks">
|
||
{#if openLoading}
|
||
{#if loadingVisible}
|
||
<div class="tracks-loading" role="status" aria-busy="true">
|
||
<div class="tl-head">
|
||
<span class="tl-ring" aria-hidden="true"></span> Loading tracks
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{:else if openTracks.length}
|
||
<div class="tl-cols" in:partIn={{ delay: 30 }}>
|
||
<span class="tno">#</span>
|
||
<span>Title</span>
|
||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||
</div>
|
||
{#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)}
|
||
in:partIn={{ delay: Math.min(ti * 16, 180) }}
|
||
role="button"
|
||
tabindex="0"
|
||
title="Play"
|
||
onclick={() => playOpen(ti)}
|
||
onkeydown={(e) => e.key === "Enter" && playOpen(ti)}
|
||
>
|
||
<span class="tno">{isPlayingTrack(t) ? "♫" : (t.track_no ?? "·")}</span>
|
||
<span class="ttitle">
|
||
{t.title}
|
||
{#if t.bitrate_kbps}
|
||
<span class="tk-rate">{(t.path.split(".").pop() || "").toUpperCase()} {t.bitrate_kbps}k{t.bit_depth ? ` · ${t.bit_depth}-bit` : ""}</span>
|
||
{/if}
|
||
</span>
|
||
<span class="tdur">{t.duration_secs ? dur(t.duration_secs) : "—"}</span>
|
||
<button
|
||
class="mini-btn play"
|
||
title="Play"
|
||
onclick={(e) => { e.stopPropagation(); playOpen(ti); }}
|
||
><Icon name="play" size={13} /></button>
|
||
<button
|
||
class="mini-get"
|
||
title="Show in folder"
|
||
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">
|
||
<div class="tl-head">No tracks found for this record</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/key}
|
||
</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet cardGrid(items, sectionKey)}
|
||
<div class="grid" use:measureCols={sectionKey}>
|
||
{#each items as a, i (a.id)}
|
||
{@render releaseCard(a, sectionKey)}
|
||
{#if expanderIndex(items, sectionKey) === i}
|
||
{@render expander(items, sectionKey)}
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet gapSection(name, list)}
|
||
{@const follow = followFor(name)}
|
||
{#if follow}
|
||
{@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[key]}
|
||
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
|
||
{:else if count != null}
|
||
<b>{count}</b> more released
|
||
{:else}
|
||
See what's missing
|
||
{/if}
|
||
<span class="lib-gapline-cta">
|
||
{gapOpen[key] ? "hide" : "show"}
|
||
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
|
||
</span>
|
||
</button>
|
||
{/if}
|
||
{#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">
|
||
<div class="grid">
|
||
{#each missing as r (r.id)}
|
||
{@render gapCard(r)}
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
{/snippet}
|
||
|
||
{#snippet artistPicker()}
|
||
<!-- Only when the name is genuinely ambiguous. Built from the page's own
|
||
.menu, the same surface the Sort control uses, rather than a new
|
||
dialog: this is a short list of choices, which is what a menu is. -->
|
||
<div
|
||
class="lib-picker-veil"
|
||
role="button"
|
||
tabindex="-1"
|
||
aria-label="Cancel"
|
||
onclick={() => (picker = null)}
|
||
onkeydown={(e) => e.key === "Escape" && (picker = null)}
|
||
></div>
|
||
<div class="menu lib-picker" role="menu">
|
||
<div class="mgroup">
|
||
{#if picker.options.length}
|
||
Which <b>{picker.name}</b>?
|
||
{:else if picker.failed}
|
||
Couldn't reach the catalogue
|
||
{:else}
|
||
No catalogue match for <b>{picker.name}</b>
|
||
{/if}
|
||
</div>
|
||
{#each picker.options as o (o.id)}
|
||
<button role="menuitem" class="lib-pick" onclick={() => pickArtist(o)}>
|
||
{#if o.picture}
|
||
<img class="lib-pick-art" src={o.picture} alt="" draggable="false" />
|
||
{:else}
|
||
<span class="lib-pick-art lib-pick-none"><Icon name="users" size={13} /></span>
|
||
{/if}
|
||
<span class="lib-pick-name">{o.name}</span>
|
||
{#if o.fans}<span class="lib-pick-fans">{fmtFans(o.fans)} fans</span>{/if}
|
||
</button>
|
||
{/each}
|
||
{#if !picker.options.length}
|
||
{#if picker.failed}
|
||
<div class="lib-pick-empty">
|
||
The search for <b>{picker.name}</b> didn't come back — the
|
||
catalogue may be rate-limiting. Nothing has been decided
|
||
about this artist.
|
||
</div>
|
||
<button role="menuitem" class="lib-pick" onclick={() => {
|
||
const again = picker.name;
|
||
picker = null;
|
||
openShelfArtist(again);
|
||
}}>
|
||
<span class="lib-pick-art lib-pick-none"><Icon name="history" size={13} /></span>
|
||
<span class="lib-pick-name">Try again</span>
|
||
</button>
|
||
{:else}
|
||
<div class="lib-pick-empty">
|
||
Nothing in the catalogue matches this folder's artist tag. The
|
||
shelf keeps its records; only the catalogue link is unavailable.
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet shelfHead(name, list)}
|
||
{@const follow = followFor(name)}
|
||
{@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
|
||
no longer a dead end — it just costs one search the first time. -->
|
||
{@const actionable = group === "artist"}
|
||
<!-- Only an ARTIST shelf has a portrait to show; every other grouping is a
|
||
Decade or a Format, and its icon is the right mark. The first row with
|
||
art wins — they all carry the same artist_art_path. -->
|
||
{@const portrait = group === "artist"
|
||
? (list.find((a) => a.artist_art_path)?.artist_art_path ?? null)
|
||
: null}
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- role="button" and
|
||
tabindex are set together, by the same `actionable` condition; the
|
||
analyzer cannot see that they travel as a pair. -->
|
||
<div
|
||
class="lib-shelf-head"
|
||
id={anchorId(name)}
|
||
role={actionable ? "button" : undefined}
|
||
tabindex={actionable ? 0 : undefined}
|
||
title={actionable ? `Open ${name}` : undefined}
|
||
aria-busy={resolving[name] ? "true" : undefined}
|
||
onclick={actionable ? () => openShelfArtist(name) : undefined}
|
||
onkeydown={actionable
|
||
? (e) => (e.key === "Enter" || e.key === " ") && openShelfArtist(name)
|
||
: undefined}
|
||
>
|
||
<span class="lib-shelf-dot" use:loadShelfArt={portrait}>
|
||
{#if portrait && artistUrls[portrait]}
|
||
<img src={artistUrls[portrait]} alt="" draggable="false" />
|
||
{:else}
|
||
<Icon name={group === "artist" ? "users" : "layers"} size={17} />
|
||
{/if}
|
||
</span>
|
||
<div>
|
||
<div class="n">
|
||
{name}
|
||
{#if actionable}
|
||
<span class="lib-shelf-go">
|
||
{#if resolving[name]}
|
||
<span class="tl-ring" aria-hidden="true"></span>
|
||
{:else}
|
||
<Icon name="chevron-right" size={15} />
|
||
{/if}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<div class="note">
|
||
{#if total}{list.length} of {total} released{:else}{list.length} {list.length === 1 ? "album" : "albums"}{/if}{#if secs} · {longDur(secs)}{/if}
|
||
</div>
|
||
{#if total}
|
||
<span class="lib-own"><i style="width:{Math.min(100, (list.length / total) * 100).toFixed(1)}%"></i></span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
<div
|
||
class="lib-page"
|
||
bind:this={pageEl}
|
||
data-cols={colsTier}
|
||
style="--tb-h:{tbH}px; --pane-h:{paneH}px"
|
||
>
|
||
{#if picker}{@render artistPicker()}{/if}
|
||
|
||
{#if ambUrl}
|
||
<!-- Keyed so a track change CROSSFADES: background-image is not an
|
||
animatable property, so the outgoing wash has to survive as its own
|
||
element while the incoming one fades up over it. Both are absolute
|
||
and identically placed, so they overlap exactly. Svelte's `fade`
|
||
reads the element's own resting opacity (0.2), so this fades to the
|
||
wash's strength, not to 1. -->
|
||
{#key ambUrl}
|
||
<div class="lib-ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
|
||
{/key}
|
||
<!-- The glow is the ONLY page-wide surface that follows the highlighted
|
||
record. The override lands on this element rather than on .lib-page
|
||
so it cannot inherit into the toolbar, tabs and search ring — that
|
||
would recolour the whole page on hover, which is the global
|
||
retarget this design deliberately does not do. -->
|
||
<div class="lib-ambience-glow" style={highlightAccent}></div>
|
||
{/if}
|
||
|
||
<!-- There is no separate "nothing here" screen. An empty library is the
|
||
library page with nothing in it: same hero, same toolbar, same Import
|
||
button in the place it always lives. The takeover this replaced
|
||
REMOVED the header, so the one moment importing matters most was the
|
||
one moment there was no way to do it. -->
|
||
<header class="lib-hero">
|
||
<div class="lib-hero-top">
|
||
<h1 class="lib-h1">Library</h1>
|
||
<div class="lib-tools">
|
||
<label class="lib-search">
|
||
<Icon name="search" size={15} />
|
||
<input type="text" placeholder="Filter your library" bind:value={q} />
|
||
{#if q}
|
||
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
|
||
{/if}
|
||
</label>
|
||
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
|
||
<Icon name="refresh" size={15} />
|
||
</button>
|
||
<!-- Importing is not a first-run event: another folder or
|
||
someone else's drive can arrive any day, so the way in
|
||
lives here, beside the library it feeds. -->
|
||
<button class="lib-tool"
|
||
title={importResume.active
|
||
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
|
||
: "Import music from a folder or drive"}
|
||
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
|
||
<Icon name="plus" size={15} />
|
||
Import
|
||
{#if importResume.active}
|
||
<span class="lib-import-pct">{importResume.pct}%</span>
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="lib-stats">
|
||
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
|
||
<span class="lib-stat-div"></span>
|
||
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
|
||
<span class="lib-stat-div"></span>
|
||
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
|
||
{#if totals.secs}
|
||
<span class="lib-stat-div"></span>
|
||
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
|
||
{/if}
|
||
{#if totals.bytes}
|
||
<span class="lib-stat-div"></span>
|
||
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
|
||
{/if}
|
||
{#if totals.never}
|
||
<span class="lib-stat-div"></span>
|
||
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
|
||
{/if}
|
||
</div>
|
||
</header>
|
||
|
||
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
|
||
<div class="disco-tabs" role="tablist">
|
||
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
|
||
<button
|
||
class="disco-tab"
|
||
class:on={view === k}
|
||
role="tab"
|
||
aria-selected={view === k}
|
||
onclick={() => setView(k)}
|
||
>{l}</button>
|
||
{/each}
|
||
</div>
|
||
<div class="lib-controls">
|
||
<span class="lib-ctl-lab">Group</span>
|
||
<div class="lib-seg">
|
||
{#each Object.entries(GROUPS) as [k, g] (k)}
|
||
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
|
||
{/each}
|
||
</div>
|
||
<span class="lib-ctl-lab">Sort</span>
|
||
<div class="sortwrap">
|
||
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
|
||
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
|
||
</button>
|
||
{#if menuOpen}
|
||
<div class="menu" role="menu">
|
||
<div class="mgroup">Sort by</div>
|
||
{#each Object.entries(SORTS) as [k, s] (k)}
|
||
<button
|
||
role="menuitem"
|
||
onclick={() => {
|
||
if (sortKey === k) desc = !desc;
|
||
else { sortKey = k; desc = s.desc !== false; }
|
||
menuOpen = false;
|
||
}}
|
||
>
|
||
{s.label}
|
||
{#if sortKey === k}
|
||
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
|
||
<Icon name="chevron-down" size={14} />
|
||
</button>
|
||
</div>
|
||
{#if view === "list"}
|
||
<!-- The column header is the bar's own last row: one box, one
|
||
pin — it cannot drift from the bar or hide behind it at
|
||
any width (handoff §4b.3, Kyle's pick). -->
|
||
<div class="lb-tr lb-th">
|
||
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
|
||
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if railItems.length}
|
||
<div class="year-rail"><div class="yr-col">
|
||
{#each railItems as [l, k] (k)}
|
||
<button onclick={() => jumpTo(k)}>{l}</button>
|
||
{/each}
|
||
</div></div>
|
||
{/if}
|
||
|
||
{#if corpus.length === 0}
|
||
<div class="lib-empty">
|
||
{#if q}
|
||
<span class="icon"><Icon name="search" size={26} /></span>
|
||
<p>Nothing matches <b>{q}</b></p>
|
||
{:else}
|
||
<!-- Nothing in the library at all. Both ways in sit here, but
|
||
the toolbar's Import is still up there too — this is an
|
||
invitation, not the only door. -->
|
||
<span class="icon"><Icon name="library" size={26} /></span>
|
||
<p>Your music will live here</p>
|
||
<small>Bring in a folder you already have, or download an album from
|
||
Search — either way it lands here, tagged with art.</small>
|
||
<div class="lib-empty-acts">
|
||
<button class="lib-tool primary"
|
||
title={importResume.active
|
||
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
|
||
: "Import music from a folder or drive"}
|
||
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
|
||
<Icon name="plus" size={15} />
|
||
{importResume.active ? "Continue importing" : "Import from a folder"}
|
||
{#if importResume.active}
|
||
<span class="lib-import-pct">{importResume.pct}%</span>
|
||
{/if}
|
||
</button>
|
||
<button class="lib-tool" onclick={() => goToView("search")}>
|
||
<Icon name="search" size={15} />
|
||
Find music to download
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if view === "shelves"}
|
||
{#each grouped as [name, list] (name)}
|
||
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
|
||
<section class="lib-shelf">
|
||
{#if name}{@render shelfHead(name, list)}{/if}
|
||
{#if group === "artist"}
|
||
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
|
||
{#if label}
|
||
<div class="lib-subhead">{label}<span>{part.length}</span></div>
|
||
{/if}
|
||
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
|
||
{/each}
|
||
{:else}
|
||
{@render cardGrid(shown, name || "flat")}
|
||
{/if}
|
||
{#if list.length > shown.length}
|
||
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
|
||
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
|
||
</button>
|
||
{/if}
|
||
{#if group === "artist"}
|
||
{@render gapSection(name, list)}
|
||
{/if}
|
||
</section>
|
||
{/each}
|
||
{:else if view === "grid"}
|
||
{#each grouped as [name, list] (name)}
|
||
<section class="lib-sec">
|
||
{#if name}
|
||
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
|
||
{/if}
|
||
{@render cardGrid(list, name || "flat")}
|
||
</section>
|
||
{/each}
|
||
{:else}
|
||
<div class="lib-table">
|
||
{#each grouped as [name, list] (name)}
|
||
<section class="lib-sec">
|
||
{#if name}
|
||
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
|
||
{/if}
|
||
{#each list as a (a.id)}
|
||
<div
|
||
class="lb-tr"
|
||
class:active={openId === a.id}
|
||
style={accentFor(a)}
|
||
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
|
||
role="button"
|
||
tabindex="0"
|
||
use:loadCover={a}
|
||
onpointerenter={() => (hoverId = a.id)}
|
||
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
|
||
onclick={() => toggleExpander(a)}
|
||
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
|
||
>
|
||
<span class="lb-cell lb-thumb">
|
||
{#if a.art_path && coverUrls[a.id]}
|
||
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||
{:else if !a.art_path}
|
||
{@const g = genFor(a)}
|
||
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
||
{:else}
|
||
<Icon name="disc" size={15} />
|
||
{/if}
|
||
</span>
|
||
<span class="lb-cell lb-strong">{a.title}</span>
|
||
<span class="lb-cell">{a.artist}</span>
|
||
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
|
||
<span class="lb-cell num">{a.year ?? "—"}</span>
|
||
<span class="lb-cell num">{a.track_count || "—"}</span>
|
||
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
|
||
<span class="lb-cell">
|
||
{#if a.formats[0]}
|
||
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
|
||
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
|
||
</span>
|
||
{:else}—{/if}
|
||
</span>
|
||
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
|
||
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
|
||
<span class="lb-cell num">{since(a.added_at)}</span>
|
||
</div>
|
||
{#if openId === a.id}
|
||
{@render expander([a], "list")}
|
||
{/if}
|
||
{/each}
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
/* ===================================================== the page frame
|
||
A "start BELOW the bar" page whose AMBIENCE runs to the very top behind
|
||
the frost — the discography's arrangement, so like it this page takes NO
|
||
pane padding-top (see App.svelte BAR_VIEWS) and carries its own inset. */
|
||
.lib-page {
|
||
position: relative;
|
||
/* Same reason .artist-page carries it (CatalogArtist.svelte:5455): the
|
||
ambience layers sit at z-index:-1, and a negative z-index only stays
|
||
inside its parent if that parent is a STACKING CONTEXT. `position:
|
||
relative` alone is not one at `z-index: auto`, so the wash escaped
|
||
past .lib-page and .content — both transparent — and painted behind
|
||
.app's opaque black. Verified by forcing it solid magenta at opacity
|
||
1 with no blur: the banner did not change colour at all. */
|
||
isolation: isolate;
|
||
--pad-top: calc(var(--bar-h, 46px) + 14px);
|
||
/* --xp-reserve is the open expander's scroll reserve (revealIntoView):
|
||
the range the pane needs to park the open row under the bar, spent
|
||
at the very END of the page so it reads as room to scroll instead of
|
||
a hole punched in the middle of a shelf. 0 whenever the page is
|
||
already long enough, which on a library of any size is most of it. */
|
||
padding: var(--pad-top) 0 calc(56px + var(--xp-reserve, 0px));
|
||
min-width: 0;
|
||
/* The toolbar pins 10px under the glass bar — two glass surfaces
|
||
reading as two objects. --tb-h is MEASURED by the page (a typed
|
||
height parked the joined header behind a two-row bar); --tb-clear is
|
||
what everything below the bar scrolls to. */
|
||
--tb-top: calc(var(--bar-h, 46px) - var(--pane-pad, 24px) + 10px);
|
||
--tb-clear: calc(var(--tb-top) + var(--tb-h, 50px) + 8px);
|
||
/* The index rail's gutter is reserved ALWAYS — the rail draws or does
|
||
not draw inside a gutter that never changes width, so no control
|
||
click moves the content column (handoff §4b.1). */
|
||
--rail-gutter: 34px;
|
||
padding-right: var(--rail-gutter);
|
||
}
|
||
|
||
/* ------------------------------------------------------------ ambience
|
||
The blurred most-recently-played cover behind the frost, faded out
|
||
before the shelves — the discography's recipe at the library's colour. */
|
||
.lib-ambience {
|
||
position: absolute;
|
||
left: -28px;
|
||
right: -28px;
|
||
top: calc(-1 * var(--pad-top));
|
||
height: 430px;
|
||
/* 0, not -1 — this is what the approved mockup actually renders
|
||
(library.css declares -1 on line 199 but each layer's own block sets
|
||
0 later, and the later equal-specificity rule wins). At 0 a
|
||
positioned layer paints ABOVE the page's in-flow content, so the
|
||
wash lies over the title and stat row rather than only showing in
|
||
the gaps between them. That is the look on the mockup, and it is why
|
||
.lib-h1 carries a text-shadow. Requires the isolation on .lib-page
|
||
either way: at -1 it escaped the page entirely. */
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
background-size: cover;
|
||
background-position: center 30%;
|
||
filter: blur(90px) saturate(1.5);
|
||
opacity: 0.2;
|
||
transform: translateZ(0);
|
||
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||
mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||
}
|
||
.lib-ambience-glow {
|
||
position: absolute;
|
||
left: -28px;
|
||
right: -28px;
|
||
top: calc(-1 * var(--pad-top));
|
||
height: 300px;
|
||
z-index: 0; /* same as .lib-ambience above — the mockup's real order */
|
||
pointer-events: none;
|
||
background: radial-gradient(640px 260px at 14% 0%,
|
||
color-mix(in srgb, var(--accent) 13%, transparent), transparent 70%);
|
||
/* --accent is a registered @property (<color>), so the scoped override
|
||
this element carries CROSSFADES instead of cutting — which is what
|
||
makes a pointer sweeping a shelf read as one drifting wash rather
|
||
than a strobe. One element, so the per-frame color-mix is cheap. */
|
||
transition: --accent 420ms ease, --accent-hover 420ms ease;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.lib-ambience-glow { transition: none; }
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- hero */
|
||
.lib-hero { padding-top: 4px; }
|
||
.lib-hero-top { display: flex; align-items: center; gap: 16px; }
|
||
.lib-h1 {
|
||
font-size: 31px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
margin: 0;
|
||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.lib-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||
.lib-search {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
height: 32px;
|
||
width: 250px;
|
||
padding: 0 11px;
|
||
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text-muted);
|
||
backdrop-filter: blur(6px);
|
||
transition: border-color 140ms ease, color 140ms ease;
|
||
}
|
||
.lib-search:focus-within { border-color: var(--accent); color: var(--text-secondary); }
|
||
.lib-search input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: none;
|
||
border: 0;
|
||
outline: none;
|
||
color: var(--text-primary);
|
||
font: inherit;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
|
||
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
|
||
.lib-clear:hover { color: var(--text-primary); }
|
||
/* The ongoing-import badge on the Import tool: quiet accent pill, the
|
||
percentage the review has covered. */
|
||
.lib-import-pct {
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
font-variant-numeric: tabular-nums;
|
||
padding: 1px 7px;
|
||
border-radius: 999px;
|
||
background: color-mix(in srgb, var(--accent) 26%, transparent);
|
||
color: var(--accent-hover, var(--accent));
|
||
}
|
||
.lib-tool {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
backdrop-filter: blur(6px);
|
||
transition: color 140ms ease, border-color 140ms ease;
|
||
}
|
||
.lib-tool:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
||
.lib-tool:disabled { opacity: 0.5; cursor: default; }
|
||
|
||
/* The stat row — the album page's value/label idiom for the collection.
|
||
Derived numbers; the filter field moves every one of them. */
|
||
.lib-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px 16px;
|
||
margin: 15px 0 20px;
|
||
}
|
||
.lib-stat { display: inline-flex; align-items: baseline; gap: 7px; white-space: nowrap; }
|
||
.lib-stat b {
|
||
font-size: 15.5px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.01em;
|
||
color: var(--text-primary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.lib-stat i {
|
||
font-style: normal;
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
}
|
||
.lib-stat-div {
|
||
width: 1px;
|
||
height: 17px;
|
||
flex: none;
|
||
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
|
||
}
|
||
|
||
/* ------------------------------------------------------------- toolbar
|
||
A FLOATING ROUNDED CARD (the search hero's own words), pinned 10px
|
||
under the glass bar. It bleeds out by exactly its own padding so its
|
||
CONTENT sits on the page column while the card floats 14px wider. Its
|
||
minimum height is a CONSTANT — never --tb-h, which is measured off this
|
||
bar; feeding a measurement back into the property that decides it was a
|
||
ratchet (handoff trap #9). */
|
||
.lib-toolbar {
|
||
position: sticky;
|
||
top: var(--tb-top);
|
||
z-index: 6;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 14px;
|
||
min-height: 50px;
|
||
margin: 0 -14px 26px;
|
||
padding: 9px 14px;
|
||
border-radius: 14px;
|
||
background: color-mix(in srgb, var(--bg-elevated) 82%, transparent);
|
||
backdrop-filter: blur(16px) saturate(1.2);
|
||
box-shadow:
|
||
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||
0 14px 34px rgba(0, 0, 0, 0.5);
|
||
}
|
||
/* ===== the discography's tab row (CatalogDiscography.svelte) ===== */
|
||
.disco-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.disco-tab {
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
padding: 8px 15px;
|
||
border-radius: 8px;
|
||
border: 1px solid transparent;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
.disco-tab:hover { background: rgba(255, 255, 255, 0.09); color: var(--text-primary); }
|
||
.disco-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.disco-tab.on {
|
||
color: #fff;
|
||
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
|
||
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||
}
|
||
.lib-toolbar .disco-tabs { flex: 0 0 auto; margin-bottom: 0; }
|
||
|
||
.lib-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
min-width: 0;
|
||
}
|
||
/* Two rows when one will not do — MEASURED, not a breakpoint. The controls
|
||
take the full width so a wrapped bar reads as two deliberate rows. */
|
||
.lib-toolbar[data-narrow="1"] { flex-wrap: wrap; }
|
||
.lib-toolbar[data-narrow="1"] .lib-controls {
|
||
margin-left: 0;
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
flex-wrap: wrap;
|
||
row-gap: 9px;
|
||
padding-top: 2px;
|
||
}
|
||
.lib-toolbar[data-narrow="1"] .lib-seg {
|
||
overflow-x: auto;
|
||
scrollbar-width: none;
|
||
max-width: 100%;
|
||
}
|
||
.lib-toolbar[data-narrow="1"] .lib-seg::-webkit-scrollbar { height: 0; }
|
||
.lib-ctl-lab {
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
margin-left: 2px;
|
||
}
|
||
.lib-seg {
|
||
display: flex;
|
||
gap: 3px;
|
||
padding: 3px;
|
||
min-width: 0;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 9px;
|
||
}
|
||
.lib-seg button {
|
||
height: 26px;
|
||
padding: 0 10px;
|
||
border-radius: 6px;
|
||
font-size: 11.5px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: color 120ms ease, background 120ms ease;
|
||
}
|
||
.lib-seg button:hover { color: var(--text-primary); }
|
||
.lib-seg button.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.12); }
|
||
.lib-dir {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 7px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: transform 160ms ease, color 120ms ease;
|
||
}
|
||
.lib-dir:hover { color: var(--text-primary); background: var(--bg-highlight); }
|
||
.lib-dir.up :global(svg) { transform: rotate(180deg); }
|
||
|
||
/* ===== the sort menu (CatalogDiscography ← PlayerBar's popover) ===== */
|
||
.sortwrap { position: relative; flex: none; }
|
||
.sortbtn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
padding: 5px 8px;
|
||
border-radius: 6px;
|
||
}
|
||
.sortbtn:hover { color: var(--text-primary); background: var(--bg-highlight); }
|
||
.menu {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
z-index: 40;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 186px;
|
||
padding: 7px;
|
||
border-radius: 9px;
|
||
background: #0d0d0d;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
|
||
}
|
||
.menu button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-align: left;
|
||
}
|
||
.menu button:hover { background: var(--bg-highlight); color: var(--text-primary); }
|
||
.menu .mgroup {
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.09em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
padding: 6px 9px 3px;
|
||
}
|
||
.menu button .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
|
||
|
||
/* ---- the "which artist?" picker: the page's own .menu, centred --------
|
||
Only ever shown when a name is genuinely ambiguous, so it is a short
|
||
list of choices — which is what a menu is. The veil exists to make the
|
||
next click a dismissal rather than a mis-click on the page behind. */
|
||
.lib-picker-veil {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
background: rgba(8, 8, 10, 0.5);
|
||
backdrop-filter: blur(2px);
|
||
border: 0;
|
||
}
|
||
.lib-picker {
|
||
position: fixed;
|
||
z-index: 41;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
min-width: 288px;
|
||
max-width: 380px;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
.lib-picker .mgroup b { color: var(--text-primary); font-weight: 700; }
|
||
.lib-pick { display: flex; align-items: center; gap: 9px; }
|
||
.lib-pick-art {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
flex: none;
|
||
}
|
||
.lib-pick-none {
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--bg-highlight);
|
||
color: var(--text-muted);
|
||
}
|
||
.lib-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.lib-pick-fans { margin-left: auto; color: var(--text-muted); font-size: 11px; flex: none; }
|
||
.lib-pick-empty {
|
||
padding: 6px 11px 10px;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
.menu button .mcheck.up { transform: rotate(180deg); }
|
||
|
||
/* ===== the index rail (CatalogDiscography.svelte), pointed at whatever
|
||
the page is grouped by ===== */
|
||
.year-rail {
|
||
position: sticky;
|
||
top: calc(var(--pane-h, 760px) / 2 - var(--pane-pad, 24px));
|
||
height: 0;
|
||
z-index: 4;
|
||
}
|
||
.year-rail .yr-col {
|
||
position: absolute;
|
||
right: calc(-1 * (16px + var(--rail-gutter)));
|
||
top: 0;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
align-items: flex-end;
|
||
}
|
||
.year-rail button {
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-muted);
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
line-height: 1.6;
|
||
cursor: pointer;
|
||
}
|
||
.year-rail button:hover { color: var(--text-primary); background: var(--bg-highlight); }
|
||
|
||
/* ============================================================= shelves */
|
||
.lib-shelf { margin-top: 40px; content-visibility: auto; contain-intrinsic-size: auto 360px; }
|
||
.lib-shelf:first-of-type { margin-top: 2px; }
|
||
.lib-shelf-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 13px;
|
||
margin: 0 0 14px;
|
||
scroll-margin-top: var(--tb-clear);
|
||
}
|
||
.lib-shelf-head[role="button"] { cursor: pointer; }
|
||
.lib-shelf-dot {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
flex: none;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--bg-elevated);
|
||
color: var(--text-muted);
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||
overflow: hidden;
|
||
}
|
||
/* The portrait lands after the head has drawn, so it fades rather than
|
||
pops — the same rule the rest of the page follows for late art. */
|
||
.lib-shelf-dot img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
animation: lib-portrait-in 220ms ease both;
|
||
}
|
||
@keyframes lib-portrait-in { from { opacity: 0; } to { opacity: 1; } }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.lib-shelf-dot img { animation: none; }
|
||
}
|
||
.lib-shelf-head .n {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.lib-shelf-go { display: inline-flex; color: var(--text-muted); opacity: 0; transition: opacity 140ms ease, transform 140ms ease; }
|
||
.lib-shelf-head[role="button"]:hover .lib-shelf-go { opacity: 1; transform: translateX(2px); }
|
||
.lib-shelf-head[role="button"]:hover .n { text-decoration: underline; text-underline-offset: 3px; }
|
||
.lib-shelf-head .note { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
|
||
/* The completeness the note states in words, shown: a quiet 3px bar. */
|
||
.lib-own {
|
||
display: block;
|
||
width: 148px;
|
||
height: 3px;
|
||
margin-top: 7px;
|
||
border-radius: 2px;
|
||
background: rgba(255, 255, 255, 0.09);
|
||
overflow: hidden;
|
||
}
|
||
.lib-own i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 2px;
|
||
background: color-mix(in srgb, var(--accent) 75%, #fff 0%);
|
||
}
|
||
.lib-subhead {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
margin: 20px 0 10px;
|
||
padding-bottom: 6px;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.09em;
|
||
text-transform: uppercase;
|
||
color: var(--text-secondary);
|
||
border-bottom: 1px solid transparent;
|
||
border-image: linear-gradient(90deg, var(--border), transparent 72%) 1;
|
||
}
|
||
.lib-subhead span { color: var(--text-muted); font-weight: 700; letter-spacing: 0; }
|
||
.lib-more {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 2px;
|
||
padding: 6px 12px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
cursor: pointer;
|
||
}
|
||
.lib-more:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.09); }
|
||
.lib-group-rule {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 22px 0 8px;
|
||
padding: 0 10px 6px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-secondary);
|
||
scroll-margin-top: var(--tb-clear);
|
||
}
|
||
.lib-group-rule span { color: var(--text-muted); font-weight: 700; letter-spacing: 0; }
|
||
.lib-sec { content-visibility: auto; contain-intrinsic-size: auto 360px; }
|
||
|
||
/* ===== the release grid (CatalogArtist.svelte): fixed 150px tracks,
|
||
margins carry the vertical rhythm so a collapsing expander leaves no
|
||
orphan gap ===== */
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, 150px);
|
||
column-gap: 16px;
|
||
row-gap: 0;
|
||
align-items: start;
|
||
align-content: start;
|
||
overflow-anchor: none;
|
||
}
|
||
|
||
/* ===== the release card (CatalogArtist.svelte), class for class ===== */
|
||
.release {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 16px;
|
||
padding: 9px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.15s ease;
|
||
content-visibility: auto;
|
||
contain-intrinsic-size: 150px 210px;
|
||
scroll-margin-top: var(--tb-clear);
|
||
}
|
||
.release:hover,
|
||
.release:focus-visible { background: var(--bg-elevated); }
|
||
.release.active {
|
||
background: var(--bg-elevated);
|
||
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||
}
|
||
.cover-wrap { position: relative; }
|
||
.cover {
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
object-fit: cover;
|
||
border-radius: 6px;
|
||
}
|
||
.cover.placeholder,
|
||
.cover.gen-pending {
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--bg-elevated);
|
||
color: var(--text-muted);
|
||
}
|
||
.title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
margin-top: 9px;
|
||
line-height: 1.3;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||
.ed-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
z-index: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
line-height: 1;
|
||
padding: 4px 7px;
|
||
border-radius: 6px;
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
font-variant-numeric: tabular-nums;
|
||
color: #fff;
|
||
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
|
||
backdrop-filter: blur(6px) saturate(1.6);
|
||
-webkit-backdrop-filter: blur(6px) saturate(1.6);
|
||
box-shadow:
|
||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
|
||
0 2px 8px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.hoverplay {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
z-index: 2;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #000;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
||
}
|
||
.hoverplay:hover { background: var(--accent-hover); }
|
||
.release:hover .hoverplay,
|
||
.release:focus-visible .hoverplay { opacity: 1; transform: none; }
|
||
|
||
/* ================================================== generated artwork
|
||
Deterministic from the record's own name; texture follows what the
|
||
thing IS (groove rings live, tape stripes bonus material, dot grain
|
||
otherwise). Approved on the prototype, ported whole. */
|
||
.lib-gen {
|
||
position: relative;
|
||
overflow: hidden;
|
||
display: grid;
|
||
place-items: center;
|
||
background:
|
||
radial-gradient(130% 100% at 18% 0%, hsl(var(--h1) 48% 37% / 0.85), transparent 56%),
|
||
radial-gradient(120% 130% at 100% 104%, hsl(var(--h2) 44% 19%), transparent 68%),
|
||
linear-gradient(152deg, hsl(var(--h1) 37% 24%), hsl(var(--h2) 33% 10%));
|
||
}
|
||
.lib-gen::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||
background-size: 3px 3px;
|
||
}
|
||
.lib-gen[data-pat="rings"]::before {
|
||
background: repeating-radial-gradient(circle at 50% 50%,
|
||
rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 5px);
|
||
}
|
||
.lib-gen[data-pat="tape"]::before {
|
||
background: repeating-linear-gradient(-38deg,
|
||
rgba(255, 255, 255, 0.05) 0 2px, transparent 2px 10px);
|
||
}
|
||
.lib-gen::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
box-shadow:
|
||
inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||
inset 0 26px 46px -30px rgba(255, 255, 255, 0.09);
|
||
}
|
||
.lib-gen-glyph { color: rgba(255, 255, 255, 0.3); display: flex; position: relative; z-index: 1; }
|
||
.lib-gen.mini { width: 100%; height: 100%; border-radius: 4px; }
|
||
|
||
/* ===================================================== the gap summary
|
||
Records you do not own: one line at the end of the shelf, opening into
|
||
FULL-SIZE cards dimmed to readable — full strength on hover. */
|
||
.lib-gapline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.lib-gapline b { font-weight: 800; color: var(--text-primary); }
|
||
.lib-gapline::after {
|
||
content: "";
|
||
flex: 1;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, var(--border), transparent 85%);
|
||
}
|
||
.lib-gapline:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.04); }
|
||
.lib-gapline-cta {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
order: 2;
|
||
font-size: 11.5px;
|
||
font-weight: 700;
|
||
color: var(--text-muted);
|
||
}
|
||
.lib-gapline:hover .lib-gapline-cta { color: var(--text-primary); }
|
||
.lib-gaps { margin-top: 12px; }
|
||
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
|
||
.lib-gaps .release.gap:hover { opacity: 1; background: none; }
|
||
|
||
/* ============================================================ the table
|
||
The power-user surface: every column is something the files carry. The
|
||
template lives on the PAGE (data-cols), above both the joined header
|
||
and the rows, so the two can never be laid out differently — a 1fr
|
||
column floors at min-content and RECORD is wider than an ellipsized
|
||
title (handoff §4b.4). Columns in order:
|
||
1 art · 2 Record · 3 Artist · 4 Type · 5 Year · 6 # · 7 Length
|
||
8 Format · 9 Rate · 10 Size · 11 Added */
|
||
.lib-table { margin-top: 2px; }
|
||
.lb-tr {
|
||
display: grid;
|
||
gap: 10px;
|
||
align-items: center;
|
||
height: 36px;
|
||
padding: 0 10px;
|
||
border-radius: 7px;
|
||
font-size: 12.5px;
|
||
cursor: pointer;
|
||
}
|
||
.lib-page[data-cols="all"] .lb-tr {
|
||
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;
|
||
}
|
||
.lib-page[data-cols="b"] .lb-tr {
|
||
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 56px 96px 66px 74px;
|
||
}
|
||
.lib-page[data-cols="c"] .lb-tr {
|
||
grid-template-columns: 34px minmax(150px, 1fr) 150px 46px 56px 96px 74px;
|
||
}
|
||
.lib-page[data-cols="d"] .lb-tr {
|
||
grid-template-columns: 34px minmax(120px, 1fr) 150px 46px 96px;
|
||
}
|
||
.lib-page[data-cols="b"] .lb-tr > :nth-child(6),
|
||
.lib-page[data-cols="b"] .lb-tr > :nth-child(9),
|
||
.lib-page[data-cols="c"] .lb-tr > :nth-child(4),
|
||
.lib-page[data-cols="c"] .lb-tr > :nth-child(6),
|
||
.lib-page[data-cols="c"] .lb-tr > :nth-child(9),
|
||
.lib-page[data-cols="c"] .lb-tr > :nth-child(10),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(4),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(6),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(7),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(9),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(10),
|
||
.lib-page[data-cols="d"] .lb-tr > :nth-child(11) { display: none; }
|
||
.lb-tr:not(.lb-th) {
|
||
content-visibility: auto;
|
||
contain-intrinsic-size: auto 36px;
|
||
scroll-margin-top: var(--tb-clear);
|
||
}
|
||
.lb-tr:not(.lb-th):hover { background: var(--bg-highlight); }
|
||
.lb-tr.active {
|
||
background: var(--bg-elevated);
|
||
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||
}
|
||
/* The joined column header: the bar's own last row, bleeding to the
|
||
card's edges; 24px left padding puts its columns back on the rows' x
|
||
(the card starts at -14, the rows pad 10). */
|
||
.lib-toolbar .lb-th {
|
||
order: 9;
|
||
flex: 0 0 calc(100% + 28px);
|
||
height: auto;
|
||
margin: 0 -14px -9px;
|
||
padding: 8px 24px 9px;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||
color: var(--text-muted);
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
cursor: default;
|
||
}
|
||
.lb-th > span {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.lb-th > :nth-child(5), .lb-th > :nth-child(6), .lb-th > :nth-child(7),
|
||
.lb-th > :nth-child(9), .lb-th > :nth-child(10), .lb-th > :nth-child(11) { text-align: right; }
|
||
.lb-cell {
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: var(--text-secondary);
|
||
}
|
||
.lb-cell.num { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||
.lb-strong { color: var(--text-primary); font-weight: 650; }
|
||
.lb-thumb {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
}
|
||
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||
.lb-fmt {
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 5px;
|
||
padding: 2px 8px;
|
||
border-radius: 5px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text-secondary);
|
||
}
|
||
.lb-fmt i {
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.lb-fmt.hi {
|
||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||
color: var(--accent-hover);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent);
|
||
}
|
||
.lb-fmt.hi i { color: color-mix(in srgb, var(--accent-hover) 70%, var(--text-secondary)); }
|
||
/* A panel that opens under a table row has no grid rhythm around it. */
|
||
.lib-table .album-expander { margin: 6px 0 16px; }
|
||
|
||
/* ====================================== the inline expander
|
||
CatalogArtist.svelte's, class for class — same numbers, same notch,
|
||
same rail recipe. Only the content differs (file facts, not Last.fm). */
|
||
.album-expander {
|
||
grid-column: 1 / -1;
|
||
position: relative;
|
||
margin-bottom: 16px;
|
||
}
|
||
.notch {
|
||
position: absolute;
|
||
top: -6px;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: var(--bg-elevated);
|
||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||
transform: rotate(45deg);
|
||
transition: left 240ms cubic-bezier(0.33, 1, 0.4, 1);
|
||
animation: notch-fade 200ms ease 300ms backwards;
|
||
}
|
||
@keyframes notch-fade {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
.xp-stage { position: relative; }
|
||
.xp-panel {
|
||
position: relative;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
.xp-body {
|
||
display: grid;
|
||
grid-template-columns: 250px 1fr;
|
||
}
|
||
.xp-rail {
|
||
position: relative;
|
||
padding: 20px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 13px;
|
||
overflow: hidden;
|
||
}
|
||
.rail-glow {
|
||
position: absolute;
|
||
inset: -10%;
|
||
z-index: 0;
|
||
width: 120%;
|
||
height: 120%;
|
||
object-fit: cover;
|
||
filter: blur(38px) saturate(1.4);
|
||
opacity: 0.32;
|
||
pointer-events: none;
|
||
}
|
||
.rail-shade {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
background: linear-gradient(
|
||
180deg,
|
||
transparent 0%,
|
||
rgba(24, 24, 24, 0.55) 55%,
|
||
rgba(24, 24, 24, 0.92) 100%
|
||
);
|
||
pointer-events: none;
|
||
}
|
||
.xp-rail > :not(.rail-glow):not(.rail-shade) {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
/* CatalogArtist's .rail-art-stage / .rail-art-mark, rule for rule — the
|
||
shared --art-grow / --art-motion / --art-wash tokens in global.css are
|
||
what make artwork behave the same wherever it opens the viewer. The
|
||
whole element scales rather than the picture inside it, because
|
||
`transform` does not reflow. */
|
||
.rail-art-stage {
|
||
position: relative;
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
padding: 0;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: transform var(--art-motion);
|
||
}
|
||
.rail-art-stage:hover,
|
||
.rail-art-stage:focus-visible {
|
||
transform: scale(var(--art-grow));
|
||
}
|
||
.rail-art-stage::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
border-radius: 8px;
|
||
background: var(--art-wash);
|
||
opacity: 0;
|
||
transition: opacity var(--art-motion);
|
||
pointer-events: none;
|
||
}
|
||
.rail-art-stage:hover::after,
|
||
.rail-art-stage:focus-visible::after {
|
||
opacity: 1;
|
||
}
|
||
.rail-art-mark {
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: auto;
|
||
z-index: 3;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: rgba(12, 12, 14, 0.55);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
color: #fff;
|
||
opacity: 0;
|
||
transform: scale(0.82);
|
||
transition:
|
||
opacity var(--art-motion),
|
||
transform var(--art-motion);
|
||
}
|
||
.rail-art-stage:hover .rail-art-mark,
|
||
.rail-art-stage:focus-visible .rail-art-mark {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
/* A generated cover has no file to open, so it offers nothing: no pointer,
|
||
no lift, no wash. (The mark is not rendered at all in that case.) */
|
||
.rail-art-stage:disabled { cursor: default; }
|
||
.rail-art-stage:disabled:hover { transform: none; }
|
||
.rail-art-stage:disabled:hover::after { opacity: 0; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.rail-art-stage,
|
||
.rail-art-mark {
|
||
transition-duration: 1ms;
|
||
}
|
||
.rail-art-stage:hover,
|
||
.rail-art-stage:focus-visible {
|
||
transform: none;
|
||
}
|
||
}
|
||
.rail-art {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
display: block;
|
||
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
|
||
}
|
||
div.rail-art { display: grid; place-items: center; }
|
||
.rail-title {
|
||
margin: 0;
|
||
font-size: 16.5px;
|
||
font-weight: 800;
|
||
line-height: 1.25;
|
||
}
|
||
/* The name as a link to the record's own page — CatalogArtist's
|
||
.rail-title-link: it inherits the heading's type so only the pointer and
|
||
the underline mark it as one. */
|
||
.rail-title-link {
|
||
font: inherit;
|
||
color: inherit;
|
||
text-align: left;
|
||
padding: 0;
|
||
}
|
||
.rail-title-link:hover { text-decoration: underline; }
|
||
.rail-meta {
|
||
margin: -7px 0 0;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* ---- the record's editorial, CatalogArtist's rules verbatim ---------- */
|
||
.rail-lede {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.5;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.rail-desc {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.55;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.rail-desc.clamped {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 4;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
-webkit-mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
|
||
mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
|
||
}
|
||
.rail-more {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
align-self: flex-start;
|
||
margin-top: -6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
padding: 2px 0;
|
||
}
|
||
.rail-more:hover { color: var(--text-primary); }
|
||
.rail-more :global(svg) { transition: transform 200ms ease; }
|
||
.rail-more.open :global(svg) { transform: rotate(180deg); }
|
||
/* Genre and label only — the date is already in .rail-meta directly above,
|
||
and repeating it costs a second wrapped row in a 210px column. */
|
||
.rail-pills { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.gpill {
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
padding: 3px 9px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.07);
|
||
color: rgba(255, 255, 255, 0.82);
|
||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||
}
|
||
.gpill.accent {
|
||
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
|
||
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
||
color: #fff;
|
||
}
|
||
.rail-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-top: -4px;
|
||
}
|
||
.rail-stat { display: flex; flex-direction: column; line-height: 1.12; }
|
||
.rail-stat b {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: var(--accent-hover);
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.rail-stat i {
|
||
font-style: normal;
|
||
font-size: 10px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
font-weight: 700;
|
||
margin-top: 2px;
|
||
}
|
||
.rail-stat-div { width: 1px; height: 22px; background: rgba(255, 255, 255, 0.12); }
|
||
.rail-stat-note {
|
||
font-size: 9.5px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 1px 5px;
|
||
font-weight: 700;
|
||
}
|
||
.rail-cta { display: flex; gap: 8px; }
|
||
.rail-cta .pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
}
|
||
.pill {
|
||
background: var(--accent);
|
||
color: #000;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 6px 14px;
|
||
border-radius: 14px;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
.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);
|
||
color: var(--text-muted);
|
||
cursor: default;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* ---- the tracklist ---- */
|
||
.xp-tracks { padding: 14px 16px; min-width: 0; }
|
||
.tl-cols {
|
||
display: grid;
|
||
grid-template-columns: 26px minmax(0, 1fr) 48px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 0 10px 7px;
|
||
margin-bottom: 3px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.09em;
|
||
text-transform: uppercase;
|
||
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;
|
||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 7px 10px;
|
||
border-radius: 6px;
|
||
font-size: 13.5px;
|
||
cursor: pointer;
|
||
}
|
||
.trow:hover { background: var(--bg-highlight); }
|
||
.trow.now .tno,
|
||
.trow.now .ttitle { color: var(--accent-hover); }
|
||
.tno {
|
||
text-align: right;
|
||
color: var(--text-muted);
|
||
font-size: 12.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.ttitle {
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
/* What a FILE knows, on the row that names the track — muted, tabular,
|
||
last; the title is still the row. */
|
||
.tk-rate {
|
||
margin-left: 9px;
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
.tdur {
|
||
color: var(--text-muted);
|
||
font-size: 12.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
transition: opacity 0.12s ease;
|
||
}
|
||
.trow:hover .tdur,
|
||
.trow:focus-within .tdur { opacity: 0; }
|
||
.mini-btn,
|
||
.mini-get {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
opacity: 0;
|
||
transition: opacity 0.12s ease;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.mini-btn.play { right: 36px; color: var(--accent-hover); }
|
||
.mini-get { right: 8px; }
|
||
.trow:hover .mini-btn,
|
||
.trow:hover .mini-get,
|
||
.trow:focus-within .mini-btn,
|
||
.trow:focus-within .mini-get { opacity: 1; }
|
||
.mini-btn:hover,
|
||
.mini-get:hover { color: var(--accent-hover); }
|
||
.tracks-loading { display: flex; flex-direction: column; }
|
||
.tl-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
padding: 7px 10px 13px;
|
||
color: var(--text-secondary);
|
||
font-size: 12.5px;
|
||
}
|
||
.tl-ring {
|
||
width: 13px;
|
||
height: 13px;
|
||
border-radius: 50%;
|
||
border: 1.6px solid rgba(255, 255, 255, 0.16);
|
||
border-top-color: rgba(255, 255, 255, 0.62);
|
||
animation: tl-spin 0.72s linear infinite;
|
||
}
|
||
@keyframes tl-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.xp-body { grid-template-columns: 1fr; }
|
||
.xp-rail {
|
||
border-right: 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.notch { animation-duration: 1ms; animation-delay: 0ms; }
|
||
.hoverplay,
|
||
.lib-shelf-go { transition-duration: 1ms; }
|
||
}
|
||
|
||
/* ---------------------------------------------------------- empty states */
|
||
.lib-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 13vh 0;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
}
|
||
.lib-empty .icon {
|
||
width: 52px;
|
||
height: 52px;
|
||
padding: 13px;
|
||
border-radius: 50%;
|
||
background: var(--bg-elevated);
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.lib-empty b { color: var(--text-primary); }
|
||
.lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; }
|
||
.lib-empty-acts {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
/* The one action that starts something. Same object as the toolbar's
|
||
tools, wearing the accent — nothing new invented for an empty page. */
|
||
.lib-tool.primary {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #000;
|
||
}
|
||
.lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; }
|
||
.lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }
|
||
</style>
|