import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the window, the picker, the audition pen, apply, and the review that stands between them. The fold is the new part. A release filed as a container of parts arrived as N albums, because the scan groups audio by its immediate parent and the container holds none of its own: Analord's eleven volumes were eleven records, and six more releases split the same way. Records now fold into the release above them, and apply is handed an explicit part list — never a recursive walk, which is what let one stray file swallow 288 descendants and duplicate 9 GB. Where releases live is read off the collection rather than assumed, so a genre-nested library moves the answer instead of breaking; below a dominant majority at one depth the pass refuses entirely, because no folding beats wrong folding. Three vetoes independent of depth catch a misread level. Every fold is listed in the review with a way out. research/import/fold-audit.mjs imports the shipping module, so the audited code is the running code: 9 folds, 0 swallows across four library shapes, and none of the 148 artist folders touched. It was that harness that killed the tempting version of this rule — matching a part against its container folded 40 artist folders, Mac Demarco's 8 albums into one, because album folders carry the artist's name. The regression test found a live bug on the way: parts number from 01 each, so flattening two discs collided, and "same name, same size" read the second file as already-imported and dropped it while still counting it. That reuse now also requires that this run has not already written the path. Also here: the picker's accent crossfades between records instead of cutting through the app's blue (ownership moved to the window, which survives the per-record re-key), the fullscreen viewer wears the album's accent instead of the one it left behind in the portal, Skip defers a record without deciding it, and Keep mine is gone — with the cards picked field by field it was Next under another name.
This commit is contained in:
1 parent
4b30c5e573
commit
e7d0971de2
23 files changed
+6498
-258
No files matched your search
@@ -4,6 +4,7 @@
|
||||
import PlayerBar from "./components/PlayerBar.svelte";
|
||||
import NowPlaying from "./components/NowPlaying.svelte";
|
||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||
import ImportWindow from "./components/ImportWindow.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||
import DebugOverlay from "./components/DebugOverlay.svelte";
|
||||
@@ -21,6 +22,7 @@
|
||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||||
import FollowToast from "./components/FollowToast.svelte";
|
||||
import ImportToast from "./components/ImportToast.svelte";
|
||||
import {
|
||||
ui,
|
||||
initEvents,
|
||||
@@ -122,8 +124,10 @@
|
||||
<NowPlaying />
|
||||
<PlayerBar />
|
||||
<FollowToast />
|
||||
<ImportToast />
|
||||
<PreviewOverlay />
|
||||
<ArtworkViewer />
|
||||
<ImportWindow />
|
||||
<LoginModal />
|
||||
<DebugOverlay />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,146 @@
|
||||
<script>
|
||||
// The import's presence while its window is closed — the same dark glass
|
||||
// pill as the follow toast (approved T2), in the same berth above the
|
||||
// player bar, but clickable and persistent: it stays up exactly as long
|
||||
// as there is something to know (a scan or copy running, or a completion
|
||||
// nobody has seen yet) and a click steps back into the import window.
|
||||
//
|
||||
// Visibility is pure derivation — the window OPEN hides it (the window
|
||||
// narrates its own work), and reopening resets terminal phases to idle
|
||||
// (ImportWindow's acknowledgment effect), so the pill never nags about
|
||||
// a state the user has already looked at.
|
||||
import { ui, importStatus } from "../lib/state.svelte.js";
|
||||
|
||||
const nfmt = (n) => n.toLocaleString("en-US");
|
||||
const shown = $derived(!ui.importOpen && importStatus.phase !== "idle");
|
||||
const busy = $derived(
|
||||
importStatus.phase === "scanning" || importStatus.phase === "applying");
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div class="layer">
|
||||
<button
|
||||
class="toast"
|
||||
onclick={() => { ui.importOpen = true; }}
|
||||
title="Back to the import"
|
||||
>
|
||||
{#if busy}
|
||||
<span class="mark pulse">▸</span>
|
||||
{:else if importStatus.phase === "error" || (importStatus.phase === "applied" && importStatus.skipped)}
|
||||
<span class="mark warn">!</span>
|
||||
{:else}
|
||||
<span class="mark ok">✓</span>
|
||||
{/if}
|
||||
{#if importStatus.phase === "scanning"}
|
||||
<span>Reading your music — <b>{nfmt(importStatus.done)} of
|
||||
{importStatus.total ? nfmt(importStatus.total) : "…"}</b> folders</span>
|
||||
{:else if importStatus.phase === "scanned"}
|
||||
<span>Scan finished — <b>{nfmt(importStatus.done)} records</b> to review</span>
|
||||
{:else if importStatus.phase === "applying"}
|
||||
<span>Importing — <b>{nfmt(importStatus.done)} of
|
||||
{nfmt(importStatus.total)}</b> records copied</span>
|
||||
{:else if importStatus.phase === "applied"}
|
||||
<!-- A bare "Imported 0 records · 0.0 GB" is the worst thing
|
||||
this pill can say: something was refused and the reason
|
||||
is a click away, but nothing here admits it. The count
|
||||
that matters leads. -->
|
||||
{#if importStatus.skipped && !importStatus.done}
|
||||
<span><b>{nfmt(importStatus.skipped)} record{importStatus.skipped === 1
|
||||
? "" : "s"}</b> could not be imported — <b>click to see why</b></span>
|
||||
{:else if importStatus.skipped}
|
||||
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
|
||||
? ` · ${importStatus.label}` : ""} · <b>{nfmt(importStatus.skipped)}
|
||||
left out</b></span>
|
||||
{:else}
|
||||
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
|
||||
? ` · ${importStatus.label}` : ""}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span>The import hit a problem — <b>click to see</b></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* FollowToast's berth verbatim — player bar + 8px shell gutter + 26px of
|
||||
air — except the layer lets clicks through and the pill takes them. */
|
||||
.layer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--player-height, 62px) + 8px + 26px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(22, 23, 28, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.toast:hover {
|
||||
background: rgba(34, 35, 42, 0.96);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.toast:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.mark {
|
||||
flex: none;
|
||||
font-style: normal;
|
||||
}
|
||||
.mark.pulse {
|
||||
color: var(--accent);
|
||||
animation: mark-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes mark-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.mark.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mark.warn {
|
||||
color: #f0cd77;
|
||||
}
|
||||
b {
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
.mark.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -223,6 +223,36 @@ export const playerFadePauseAcked = (fadeMs) =>
|
||||
export const playerFadeResume = (fadeMs) =>
|
||||
invoke("player_fade_resume", { fadeMs });
|
||||
export const debugStats = () => invoke("debug_stats");
|
||||
|
||||
// ---- the import tool (see src-tauri/src/import.rs) ------------------------
|
||||
// Scan is read-only; apply COPIES into the managed root and registers — the
|
||||
// source folder is never written to.
|
||||
export const importScan = (root) => invoke("import_scan", { root });
|
||||
export const importSessionGet = () => invoke("import_session_get");
|
||||
export const importSessionSet = (state) => invoke("import_session_set", { state });
|
||||
export const importSessionClear = () => invoke("import_session_clear");
|
||||
export const importGetDest = () => invoke("import_get_dest");
|
||||
export const importSetDest = (dir, layout = null) => invoke("import_set_dest", { dir, layout });
|
||||
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
|
||||
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
||||
// the artwork panel's cover report. Both read-only.
|
||||
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
|
||||
export const importCoverReport = (artist, title, referenceUrl) =>
|
||||
invoke("import_cover_report", { artist, title, referenceUrl });
|
||||
// The record's own artwork, measured (embedded first, else folder images).
|
||||
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
|
||||
// Prune rows whose files are gone, then index anything new — the real rescan.
|
||||
export const rescanLibrary = () => invoke("rescan_library");
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
// comparison. files ordered track-1-first; returns transfer ids.
|
||||
export const importAudition = (username, rel, files) =>
|
||||
invoke("import_audition", { username, rel, files });
|
||||
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
|
||||
// Measured facts about one audio file (the version card's quality chips).
|
||||
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
||||
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
||||
export const onImportScanFolder = (cb) => listen("import:scan-folder", (e) => cb(e.payload));
|
||||
export const onImportApply = (cb) => listen("import:apply", (e) => cb(e.payload));
|
||||
export const previewControl = (action, value = null) =>
|
||||
invoke("preview_control", { action, value });
|
||||
export const previewDispose = () => invoke("preview_dispose");
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
// The import review's match evidence — ONE definition, read by both the
|
||||
// picker's verdict panel and the window's outcome buckets, so the filter
|
||||
// and the panel can never disagree about the same record (the mock's rule,
|
||||
// research/import/render.js evidence()).
|
||||
|
||||
export const normx = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
const EDITION_WORD =
|
||||
/\b(deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|reissue|bonus|explicit|clean|mono|stereo|original|special|limited)\b/gi;
|
||||
|
||||
/** What a release is CALLED beyond the record's own name: bracketed years a
|
||||
* ripper added, edition words a service adds. Strip both and two namings of
|
||||
* one record meet. */
|
||||
export const coreTitle = (s) =>
|
||||
normx(
|
||||
String(s ?? "")
|
||||
.replace(/[\[(<][^\])>]*[\])>]/g, " ")
|
||||
.replace(EDITION_WORD, " ")
|
||||
.replace(/\b(19|20)\d{2}\b/g, " "),
|
||||
) || normx(s);
|
||||
|
||||
/** What is actually known about whether candidate `c` is the same record as
|
||||
* the scanned album (title + artist + your track count). Each row is a
|
||||
* fact the user can check, not a score they have to trust. */
|
||||
export function evidence(c, artist, title, trackCount) {
|
||||
if (!c) return { items: [], verdict: "none", edition: false };
|
||||
const artistOk = normx(c.artist) === normx(artist);
|
||||
const titleOk = normx(c.title) === normx(title);
|
||||
const titleClose =
|
||||
!titleOk &&
|
||||
(normx(c.title).includes(normx(title)) || normx(title).includes(normx(c.title)));
|
||||
// Same record, named for a different edition — the ONE case where a
|
||||
// different track count is expected rather than evidence of the wrong
|
||||
// album.
|
||||
const edition = !titleOk && coreTitle(title) === coreTitle(c.title);
|
||||
const countOk = c.trackCount === trackCount;
|
||||
const items = [
|
||||
{ text: artistOk ? "Artist matches" : `Artist: ${c.artist}`, cls: artistOk ? "ok" : "no" },
|
||||
{
|
||||
text: titleOk
|
||||
? "Title matches"
|
||||
: edition
|
||||
? `Same record, different edition: ${c.title}`
|
||||
: titleClose
|
||||
? `Title: ${c.title}`
|
||||
: `Different title: ${c.title}`,
|
||||
cls: titleOk ? "ok" : edition || titleClose ? "warn" : "no",
|
||||
},
|
||||
{
|
||||
text: countOk
|
||||
? `${c.trackCount} tracks, same`
|
||||
: edition
|
||||
? `${trackCount} tracks vs ${c.trackCount} — expected on another edition`
|
||||
: `${trackCount} tracks vs ${c.trackCount}`,
|
||||
cls: countOk ? "ok" : edition ? "warn" : "no",
|
||||
},
|
||||
];
|
||||
const bad = items.filter((i) => i.cls === "no").length;
|
||||
let verdict = bad >= 2 ? "bad" : bad === 1 ? "warn" : "ok";
|
||||
// An edition difference is never "everything agrees".
|
||||
if (edition && verdict === "ok") verdict = "warn";
|
||||
return { items, verdict, edition };
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Folding a release that was scanned as its parts.
|
||||
//
|
||||
// The scan groups audio by its IMMEDIATE parent folder, so a release filed as
|
||||
// a container of parts —
|
||||
//
|
||||
// Aphex Twin/2005 - AFX - Analord, Volume 1-11/AFX - Analord 01/ 7 tracks
|
||||
// /AFX - Analord 02/ 4 tracks
|
||||
// … 11 volumes
|
||||
//
|
||||
// — arrives as eleven albums, and the container itself as none, because it
|
||||
// holds no audio of its own. Six more of Kyle's records split the same way
|
||||
// (Tago Mago, Drukqs, SAW II, 26 Mixes, Fishmans, Hella): 27 records that are
|
||||
// really 7 releases.
|
||||
//
|
||||
// The rule and every guard in it were settled against the real 515-record scan
|
||||
// BEFORE any of this shipped — `research/import/fold-audit.mjs` imports this
|
||||
// module, so the audited code is the running code. Re-run it after any change
|
||||
// here; it prints every fold and counts swallows under four library shapes.
|
||||
//
|
||||
// What the audit killed, and why the guards look like this:
|
||||
//
|
||||
// · Matching a part's name against its CONTAINER folded 40 of 148 artist
|
||||
// folders — Mac Demarco's 8 albums into one — because album folders
|
||||
// routinely carry the artist's name. Parts are compared to EACH OTHER.
|
||||
// · A bare "depth 3 is a part" rule is only true of libraries shaped like
|
||||
// his. The release level is read off the collection instead, so a
|
||||
// genre-nested library moves the answer instead of breaking it.
|
||||
// · Reading that level from an inconsistently nested collection merged 5
|
||||
// records under the wrong parent. Below a dominant majority the pass
|
||||
// refuses entirely: no folding beats wrong folding (see §0.1, 9 GB).
|
||||
|
||||
const SEP = "\\";
|
||||
export const segs = (rel) => rel.split(SEP);
|
||||
export const leaf = (rel) => segs(rel).at(-1);
|
||||
|
||||
/** Dropped before anything else looks at the list — a zip's `__MACOSX`
|
||||
* resource fork otherwise reads as disc two. Mirrors ImportWindow's JUNK. */
|
||||
const JUNK = /^(__MACOSX|\.DS_Store|@eaDir|System Volume Information)$/i;
|
||||
|
||||
/** `CD1`, `Disc 2`, `Side B`, `Part 3` — a part that says so in its name. */
|
||||
const PART_NAME = /^(?:cd|disc|disk|side|part|vol(?:ume)?)[\s_.-]*\d+[a-z]?$/i;
|
||||
|
||||
const STOP = new Set(["the", "a", "an", "of", "and", "vol", "volume", "cd", "disc",
|
||||
"disk", "part", "ep", "lp", "remastered", "deluxe", "edition", "records", "web",
|
||||
"mp3", "flac", "kbps", "vinyl", "reissue"]);
|
||||
|
||||
const strip = (s) => s
|
||||
.replace(/^[([]\d{4}[)\]]\s*-?\s*/, "").replace(/^\d{4}\s*-\s*/, "")
|
||||
.replace(/[[(].*?[\])]/g, " ");
|
||||
|
||||
const nameTokens = (s) => new Set(
|
||||
strip(s).toLowerCase().match(/[\p{L}\p{N}]+/gu)
|
||||
?.filter((t) => t.length >= 3 && !STOP.has(t) && !/^\d+$/.test(t)) ?? []);
|
||||
|
||||
/** The folder name a folded record wears, and the year in it. The container's
|
||||
* own name is the release; the parts' TAGS name the parts ("Analord 01"), so
|
||||
* a tag majority over a folded record would title the whole set after its
|
||||
* biggest volume. */
|
||||
export const titleOf = (name) => strip(name).replace(/\s{2,}/g, " ").trim() || name;
|
||||
export function yearOf(name) {
|
||||
const m = name.match(/^(19|20)\d{2}(?=\s*[-–—]\s)/)
|
||||
?? name.match(/[([]((?:19|20)\d{2})[)\]]/)
|
||||
?? name.match(/\b((?:19|20)\d{2})\b/);
|
||||
if (!m) return null;
|
||||
const y = Number(m[0].replace(/\D/g, ""));
|
||||
return y >= 1900 && y <= 2100 ? y : null;
|
||||
}
|
||||
|
||||
const yearsIn = (a) => {
|
||||
const out = [];
|
||||
if (a.year) out.push(Number(a.year));
|
||||
for (const t of a.tracks ?? []) if (t.year) out.push(Number(t.year));
|
||||
return out.filter((y) => y >= 1900 && y <= 2100);
|
||||
};
|
||||
|
||||
/** Reasons NOT to fold, each independent of the depth rule so a misread
|
||||
* release level is caught rather than obeyed. Measured against the disaster
|
||||
* case (every artist folder treated as a container): 69 of 73 rejected. */
|
||||
const VETOES = [
|
||||
function tooManyParts({ parts }) {
|
||||
return parts.length > 30 ? `${parts.length} parts — reads as a discography` : null;
|
||||
},
|
||||
function yearSpread({ parts }) {
|
||||
const ys = parts.flatMap(yearsIn);
|
||||
if (ys.length < 2) return null;
|
||||
const lo = Math.min(...ys), hi = Math.max(...ys);
|
||||
return hi - lo > 1 ? `parts span ${hi - lo} years (${lo}–${hi})` : null;
|
||||
},
|
||||
function noCommonThread({ parts }) {
|
||||
if (parts.length < 2) return null;
|
||||
const names = parts.map((p) => leaf(p.rel));
|
||||
if (names.every((n) => PART_NAME.test(strip(n).trim()))) return null;
|
||||
const sets = names.map(nameTokens);
|
||||
const shared = [...sets[0]].filter((t) => sets.every((s) => s.has(t)));
|
||||
return shared.length ? null : "parts share no name between them";
|
||||
},
|
||||
];
|
||||
|
||||
/** Where releases live, read off the collection rather than assumed. */
|
||||
export function releaseLevel(albums) {
|
||||
const tally = new Map();
|
||||
for (const a of albums) {
|
||||
const d = segs(a.rel).length;
|
||||
tally.set(d, (tally.get(d) ?? 0) + 1);
|
||||
}
|
||||
let level = 1, n = -1;
|
||||
for (const [d, c] of [...tally].sort((x, y) => x[0] - y[0])) {
|
||||
if (c > n) { n = c; level = d; }
|
||||
}
|
||||
return { level, share: albums.length ? n / albums.length : 0 };
|
||||
}
|
||||
|
||||
/** Below this the collection has no single shape and nothing folds. */
|
||||
export const CONFIDENCE = 0.7;
|
||||
|
||||
/**
|
||||
* Fold nested parts into their release.
|
||||
*
|
||||
* @param {Array} input raw scan albums
|
||||
* @param {Set<string>} [keepSeparate] container rels the user has un-folded
|
||||
* @returns {{records, folds, vetoed, level, share, refused}}
|
||||
* `records` is the list to use everywhere downstream; a folded one carries
|
||||
* `parts` (sub-paths under its own rel, in order) so apply knows where the
|
||||
* audio actually is without ever walking the tree.
|
||||
*/
|
||||
export function foldReleases(input, keepSeparate = new Set()) {
|
||||
const albums = [], dropped = [];
|
||||
for (const a of input) (JUNK.test(leaf(a.rel)) ? dropped : albums).push(a);
|
||||
const { level, share } = releaseLevel(albums);
|
||||
if (share < CONFIDENCE) {
|
||||
return { records: albums, dropped, folds: [], vetoed: [], level, share, refused: true };
|
||||
}
|
||||
|
||||
const byRel = new Map(albums.map((a) => [a.rel, a]));
|
||||
const groups = new Map();
|
||||
for (const a of albums) {
|
||||
if (segs(a.rel).length <= level) continue;
|
||||
const container = segs(a.rel).slice(0, level).join(SEP);
|
||||
if (!groups.has(container)) groups.set(container, []);
|
||||
groups.get(container).push(a);
|
||||
}
|
||||
|
||||
const folds = [], vetoed = [], absorbed = new Set();
|
||||
for (const [container, deeper] of groups) {
|
||||
// A container holding loose audio beside its parts is a record too;
|
||||
// its own files join the fold, first, as the unnamed part "".
|
||||
const own = byRel.get(container) ?? null;
|
||||
const parts = [...deeper].sort((x, y) => leaf(x.rel).localeCompare(
|
||||
leaf(y.rel), undefined, { numeric: true, sensitivity: "base" }));
|
||||
const all = own ? [own, ...parts] : parts;
|
||||
|
||||
const blocks = VETOES.map((v) => v({ container, parts: all })).filter(Boolean);
|
||||
if (blocks.length) {
|
||||
vetoed.push({ container, parts: all.length, reasons: blocks });
|
||||
continue;
|
||||
}
|
||||
const entry = {
|
||||
container,
|
||||
name: titleOf(leaf(container)),
|
||||
parts: all.length,
|
||||
tracks: all.reduce((n, p) => n + (p.tracks?.length ?? 0), 0),
|
||||
members: all.map((p) => leaf(p.rel)),
|
||||
};
|
||||
if (keepSeparate.has(container)) {
|
||||
entry.off = true;
|
||||
folds.push(entry);
|
||||
continue;
|
||||
}
|
||||
folds.push(entry);
|
||||
for (const p of all) absorbed.add(p.rel);
|
||||
|
||||
// Order is part order, then the scan's order within each part — the
|
||||
// same order apply walks, because `trackTitles` is positional and a
|
||||
// disagreement here would relabel every track past the first part.
|
||||
const merged = {
|
||||
rel: container,
|
||||
artistFolder: segs(container)[0],
|
||||
albumFolder: leaf(container),
|
||||
depth: segs(container).length,
|
||||
year: yearOf(leaf(container)) ?? all.map((p) => p.year).find(Boolean) ?? null,
|
||||
folderArt: [...new Set(all.flatMap((p) => p.folderArt ?? []))],
|
||||
tracks: all.flatMap((p) => p.tracks ?? []),
|
||||
parts: all.map((p) => (p === own ? "" : leaf(p.rel))),
|
||||
foldedFrom: all.length,
|
||||
};
|
||||
byRel.set(container, merged);
|
||||
}
|
||||
|
||||
const records = [];
|
||||
const emitted = new Set();
|
||||
for (const a of albums) {
|
||||
if (absorbed.has(a.rel)) {
|
||||
const container = segs(a.rel).slice(0, level).join(SEP);
|
||||
if (!emitted.has(container)) {
|
||||
emitted.add(container);
|
||||
records.push(byRel.get(container));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
records.push(a);
|
||||
}
|
||||
return { records, dropped, folds, vetoed, level, share, refused: false };
|
||||
}
|
||||
+47
-13
@@ -213,6 +213,27 @@ export function announceFollow(on, artist) {
|
||||
followToast.portrait = artist?.picture ?? null;
|
||||
}
|
||||
|
||||
/** Background-import status, read by ImportToast. The import window keeps
|
||||
* scanning/copying after it is closed (the component stays mounted, only
|
||||
* its template goes), and closed must never mean invisible: while work
|
||||
* runs, the toast narrates it and clicks back into the window. Terminal
|
||||
* phases (scanned/applied/error) are only entered when the window is
|
||||
* CLOSED at that moment — an open window narrates its own completions —
|
||||
* and they stick until the window is reopened, which acknowledges them. */
|
||||
export const importStatus = $state({
|
||||
phase: "idle", // idle | scanning | scanned | applying | applied | error
|
||||
done: 0, // scanning/applying: progress; scanned/applied: final count
|
||||
total: 0,
|
||||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||||
skipped: 0, // applied: records nothing was written for — the toast must
|
||||
// say so, because "Imported 0 records" on its own reads as a dead end
|
||||
});
|
||||
|
||||
/** A paused import's presence on the Library page: the Import button wears
|
||||
* a percentage badge while a saved review session exists. Fed by
|
||||
* ImportWindow (boot restore, every persist, cleared on completion). */
|
||||
export const importResume = $state({ active: false, pct: 0 });
|
||||
|
||||
export const ui = $state({
|
||||
view: "home",
|
||||
albumId: null, // selected album in Library view
|
||||
@@ -237,6 +258,11 @@ export const ui = $state({
|
||||
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||||
// surface can open it, not only the playing track.
|
||||
artwork: null,
|
||||
// The import tool's window. `false` when closed; `true` opens it (resuming
|
||||
// any saved session); `{ root }` opens it with a source folder pre-picked.
|
||||
// Lives on ui rather than the Library page because importing is not a
|
||||
// first-run event — another drive can arrive any day.
|
||||
importOpen: false,
|
||||
// WebView2 keeps document.visibilityState="visible" while the native
|
||||
// window is minimized. This is the authoritative shared activity gate for
|
||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||
@@ -3591,7 +3617,7 @@ export const resolver = $state({
|
||||
error: "",
|
||||
});
|
||||
|
||||
const RESOLVER_AUDIO_QUALITY = {
|
||||
export const RESOLVER_AUDIO_QUALITY = {
|
||||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
||||
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
|
||||
};
|
||||
@@ -3794,7 +3820,7 @@ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||||
function sanitizeSearchQuery(query) {
|
||||
export function sanitizeSearchQuery(query) {
|
||||
let out = ` ${query ?? ""} `;
|
||||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||||
@@ -3822,7 +3848,9 @@ function sanitizeSearchQuery(query) {
|
||||
const SEARCH_MIN_GAP_MS = 1500;
|
||||
let lastSearchStartedAt = 0;
|
||||
|
||||
async function collectSources(query, maxMs, goodCount) {
|
||||
// Exported for the import finder (import-audio.js): the pacing, poisoned-
|
||||
// term hygiene and early-exit logic must exist ONCE, not be re-coded.
|
||||
export async function collectSources(query, maxMs, goodCount) {
|
||||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||
lastSearchStartedAt = performance.now();
|
||||
@@ -4474,16 +4502,22 @@ export function tinyCover(url) {
|
||||
* art, badges, album-folder source preference, and length verification. */
|
||||
export async function streamSong(artist, title, opts = {}) {
|
||||
if (resolver.active) return;
|
||||
const local = await libraryTrackFor(artist, title);
|
||||
if (local) {
|
||||
playAlbum(
|
||||
local.tracks,
|
||||
`${local.album.artist} — ${local.album.title}`,
|
||||
local.index,
|
||||
local.album.art_path ?? null,
|
||||
);
|
||||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||||
return;
|
||||
// opts.skipLocal: the import picker's A/B auditions the CANDIDATE — the
|
||||
// local-library shortcut would find the user's own copy and play them
|
||||
// against themselves (decision 11). The stream cache below still counts:
|
||||
// those bytes came from a peer, so their quality is honestly "theirs".
|
||||
if (!opts.skipLocal) {
|
||||
const local = await libraryTrackFor(artist, title);
|
||||
if (local) {
|
||||
playAlbum(
|
||||
local.tracks,
|
||||
`${local.album.artist} — ${local.album.title}`,
|
||||
local.index,
|
||||
local.album.art_path ?? null,
|
||||
);
|
||||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Streamed before and still byte-complete in the cache? Play those bytes
|
||||
// now — no search, no resolver popup, no network requirement. This is the
|
||||
|
||||
+264
-203
@@ -36,6 +36,8 @@
|
||||
openCatalogAlbum,
|
||||
RECENTS_FLOOR_SECS,
|
||||
recentsStore,
|
||||
importResume,
|
||||
goToView,
|
||||
} from "../lib/state.svelte.js";
|
||||
import {
|
||||
fileRelease,
|
||||
@@ -1378,6 +1380,10 @@
|
||||
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 {
|
||||
@@ -1849,212 +1855,251 @@
|
||||
<div class="lib-ambience-glow" style={highlightAccent}></div>
|
||||
{/if}
|
||||
|
||||
{#if libraryStore.albums.length === 0}
|
||||
<div class="lib-empty-lib">
|
||||
<p class="big">Your music will live here</p>
|
||||
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
</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>
|
||||
<!-- 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}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{#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">
|
||||
<span class="icon"><Icon name="search" size={26} /></span>
|
||||
<p>Nothing matches <b>{q}</b></p>
|
||||
</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"}
|
||||
{: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}
|
||||
{@render cardGrid(list, name || "flat")}
|
||||
{#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}
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2181,6 +2226,17 @@
|
||||
.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;
|
||||
@@ -3327,16 +3383,21 @@
|
||||
place-items: center;
|
||||
}
|
||||
.lib-empty b { color: var(--text-primary); }
|
||||
.lib-empty-lib {
|
||||
padding: 18vh 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
.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;
|
||||
}
|
||||
.lib-empty-lib .big {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
/* 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-empty-lib .sub { margin: 0; font-size: 13px; }
|
||||
.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>
|
||||
Reference in new issue
Block a user