playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -29,6 +29,11 @@
|
||||
|
||||
let step = $state("source");
|
||||
let root = $state(null); // the folder being imported
|
||||
// Where `root` sits relative to the LIBRARY folder. An absolute path is
|
||||
// only true on the machine that wrote it; this is what survives the drive
|
||||
// being carried somewhere else and coming back at another letter.
|
||||
let rootRel = $state(null);
|
||||
let rootMissing = $state(false); // saved source folder could not be found
|
||||
let dest = $state(null); // { dir, freeBytes, layout } from the backend
|
||||
let scan = $state(null); // ScanResult
|
||||
let scanProg = $state(null); // latest per-file tick; running totals ride along
|
||||
@@ -93,7 +98,7 @@
|
||||
return [rel, rest];
|
||||
}));
|
||||
await api.importSessionSet(JSON.stringify({
|
||||
step, root, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
|
||||
step, root, rootRel, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
|
||||
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
|
||||
}));
|
||||
} catch (e) {
|
||||
@@ -108,8 +113,29 @@
|
||||
if (!raw) return;
|
||||
const v = JSON.parse(raw);
|
||||
if (v?.root) {
|
||||
root = v.root;
|
||||
// The saved path first — nothing has moved in the common case.
|
||||
// Then the same folder relative to where the library is NOW,
|
||||
// which is how a review saved at one machine finds its source
|
||||
// again at another. Both `root` and the scan's own copy are
|
||||
// repointed: apply passes `scan.root`, so leaving that stale
|
||||
// would resume onto a folder that is not there.
|
||||
// NOT `found` — that name is the identify-results state, and
|
||||
// shadowing it here throws on the assignment further down.
|
||||
const liveRoot = await api
|
||||
.importResolveRoot(v.root, v.rootRel ?? null)
|
||||
.catch(() => null);
|
||||
root = liveRoot ?? v.root;
|
||||
// A session saved before the portable form existed learns it
|
||||
// now, while its source is still findable — waiting until the
|
||||
// next save would strand any review already in progress.
|
||||
rootRel = v.rootRel
|
||||
?? (liveRoot ? await api.importPortableRoot(liveRoot).catch(() => null) : null);
|
||||
rootMissing = !liveRoot;
|
||||
scan = v.scan ?? null;
|
||||
if (scan && liveRoot) scan.root = liveRoot;
|
||||
// Learned or repointed: write it back now rather than waiting
|
||||
// for a transition that may never come.
|
||||
const learned = rootRel !== (v.rootRel ?? null) || root !== v.root;
|
||||
layoutChoice = v.layout ?? null;
|
||||
choices = v.choices ?? {};
|
||||
found = v.found ?? {};
|
||||
@@ -123,6 +149,7 @@
|
||||
auditions = v.auditions ?? {};
|
||||
keepSeparate = v.keepSeparate ?? [];
|
||||
step = v.step === "scanning" ? "source" : (v.step ?? "source");
|
||||
if (learned) schedulePersist();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("import: session restore failed", e);
|
||||
@@ -161,8 +188,7 @@
|
||||
// The dev seed ({ root } form) still overrides the source on open.
|
||||
$effect(() => {
|
||||
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
|
||||
root = ui.importOpen.root;
|
||||
scan = null;
|
||||
setRoot(ui.importOpen.root);
|
||||
step = "source";
|
||||
}
|
||||
});
|
||||
@@ -206,9 +232,18 @@
|
||||
}
|
||||
|
||||
// ---- source ----------------------------------------------------------
|
||||
/** Every route to a source folder goes through here, so the portable form
|
||||
* is recorded at the same moment as the absolute one and cannot drift. */
|
||||
async function setRoot(p) {
|
||||
root = p;
|
||||
scan = null;
|
||||
rootMissing = false;
|
||||
rootRel = p ? await api.importPortableRoot(p).catch(() => null) : null;
|
||||
}
|
||||
|
||||
async function pickSource() {
|
||||
const p = await api.pickFolder().catch(() => null);
|
||||
if (p) { root = p; scan = null; }
|
||||
if (p) await setRoot(p);
|
||||
}
|
||||
|
||||
async function pickDest() {
|
||||
@@ -473,7 +508,9 @@
|
||||
else
|
||||
k = matchEvidence(
|
||||
f.result.candidate,
|
||||
artistOf.get(x.rel) ?? x.artistFolder,
|
||||
// The artist the answer was found under — the folder,
|
||||
// unless the tags found what the folder could not.
|
||||
f.result.askedArtist ?? artistOf.get(x.rel) ?? x.artistFolder,
|
||||
albumOf.get(x.rel) ?? x.title,
|
||||
x.tracks.length,
|
||||
).verdict;
|
||||
@@ -708,6 +745,125 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask again for the tracks a found copy still owes us.
|
||||
*
|
||||
* Transfers do not survive the app; the files they wrote do. So a session
|
||||
* restored after a crash, a power cut or an ordinary restart brings back
|
||||
* an audition that LOOKS live and is not: `restStarted` is already true,
|
||||
* so nothing ever asks for the tracks that never landed, and every future
|
||||
* apply refuses the record for the same reason — "the found copy is still
|
||||
* arriving" — with nothing arriving. Two records sat at 12 of 13 and 6 of
|
||||
* 11 that way.
|
||||
*
|
||||
* The pen decides how far it got, not the counter: `arrived` is a running
|
||||
* total kept by events, and those events stopped with the process that
|
||||
* was listening for them. A count that was already right costs one stat
|
||||
* per file to confirm; a count that was wrong is corrected before it can
|
||||
* refuse anything again.
|
||||
*
|
||||
* Asked at most once per record per run. A peer that has gone away should
|
||||
* cost one round of requests, not a fresh one every time the record is
|
||||
* looked at — "Try another peer" is the answer to that, and it is a
|
||||
* deliberate act. */
|
||||
const resumeAsked = new Set();
|
||||
async function resumeAudition(rel) {
|
||||
const au = auditions[rel];
|
||||
if (!au || resumeAsked.has(rel)) return;
|
||||
const files = au.candidate?.files ?? [];
|
||||
if (!files.length || ["searching", "none", "failed"].includes(au.status)) return;
|
||||
resumeAsked.add(rel);
|
||||
const peer = au.candidate.username;
|
||||
let missing;
|
||||
try {
|
||||
missing = await api.importAuditionMissing(
|
||||
rel,
|
||||
files.map((f) => ({ path: f.path, size: f.size ?? 0 })),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("import: could not read the pen", e);
|
||||
resumeAsked.delete(rel);
|
||||
return;
|
||||
}
|
||||
// Anything could have happened across the await — a switch to another
|
||||
// peer, a fresh search. Credit this answer only to the copy it is about.
|
||||
const cur = auditions[rel];
|
||||
if (!cur || cur.candidate?.username !== peer) return;
|
||||
cur.expected = files.length;
|
||||
cur.arrived = files.length - missing.length;
|
||||
// WHICH files are owed, not just how many — the strip states a size
|
||||
// only when it knows exactly which files make it up.
|
||||
cur.missingIdx = missing;
|
||||
if (!missing.length) {
|
||||
// Everything is already here. The record was refused by a stale
|
||||
// number, and nothing needs downloading to unstick it.
|
||||
cur.status = "complete";
|
||||
schedulePersist();
|
||||
return;
|
||||
}
|
||||
for (const i of missing) {
|
||||
try {
|
||||
const { ids } = await startAudition(rel, cur.candidate, { from: i, count: 1 });
|
||||
cur.ids = [...(cur.ids ?? []), ...ids];
|
||||
} catch (e) {
|
||||
console.warn("import: could not re-request a missing track", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cur.status = cur.track1Path ? "ready" : "downloading";
|
||||
cur.xfer = { ...(cur.xfer ?? {}), phase: "requested", at: Date.now() };
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
/** Ask again for every refusal that is a found copy still owed tracks.
|
||||
*
|
||||
* Called from BOTH applies. They refuse the same way and have to recover
|
||||
* the same way: wiring only the full one left the picker's partial apply
|
||||
* — the one that runs when you import from the review itself — repeating
|
||||
* "it imports as soon as the rest land" over records nothing was asking
|
||||
* for. No-ops on a refusal that is not about an audition. */
|
||||
function resumeRefused(skipped) {
|
||||
for (const s of skipped ?? []) resumeAudition(s.rel);
|
||||
}
|
||||
|
||||
// Opening a record is when we find out whether its found copy is still
|
||||
// owed anything — and the only moment a resumed transfer has somewhere to
|
||||
// be seen. Deliberately not a sweep at window-open: that would put a
|
||||
// dozen peers' worth of requests on the wire for records nobody is
|
||||
// looking at. The refused set after an apply is handled there.
|
||||
$effect(() => {
|
||||
if (step !== "upgrades" || !open) return;
|
||||
const rel = curPick?.rel;
|
||||
const au = rel ? auditions[rel] : null;
|
||||
const want = au?.candidate?.files?.length ?? 0;
|
||||
if (!want || (au.arrived ?? 0) >= (au.expected ?? want)) return;
|
||||
resumeAudition(rel);
|
||||
});
|
||||
|
||||
/** Throw away what the abandoned peer left in the pen.
|
||||
*
|
||||
* The cancel stops its transfers; this removes what they already wrote.
|
||||
* The pen is keyed by RECORD, not by peer, so without it the dropped
|
||||
* copy's files sit beside the replacement's until the session is cleared
|
||||
* — 189 MB of a stranger's Centipede Hz did exactly that, and apply filed
|
||||
* all 17 tracks. Apply takes the chosen copy's files by name now, so this
|
||||
* is about disk rather than correctness; the ORDER still matters, because
|
||||
* a file written after the clear would be a stray nothing asked for.
|
||||
*
|
||||
* Deliberately unconditional: "another peer" means this copy is not the
|
||||
* one, and a partial copy of a rip you have walked away from is not worth
|
||||
* the gigabytes. The automatic switch only fires before track 1 lands, so
|
||||
* it can never discard more than one unfinished file. */
|
||||
async function clearPen(rel, why) {
|
||||
try {
|
||||
const freed = await api.importAuditionClear(rel);
|
||||
if (freed) {
|
||||
console.info(`import: pen cleared for ${rel} — ${Math.round(freed / 1e6)} MB (${why})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("import: could not clear the pen", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop waiting on this peer and drop to the next ranked copy.
|
||||
*
|
||||
* Cancels the transfers first — without that, a switch left the old peer
|
||||
@@ -721,6 +877,7 @@
|
||||
for (const id of au.ids ?? []) {
|
||||
try { await api.cancelDownload(id); } catch {}
|
||||
}
|
||||
await clearPen(rel, reason ?? "peer switched");
|
||||
const [next, ...rest] = au.alts ?? [];
|
||||
if (!next) {
|
||||
auditions[rel] = { status: "none", dead, exhausted: true, reason };
|
||||
@@ -751,10 +908,13 @@
|
||||
for (const id of prev?.ids ?? []) {
|
||||
try { await api.cancelDownload(id); } catch {}
|
||||
}
|
||||
// A fresh search abandons the current copy just as squarely as a
|
||||
// switch does; whatever answers will be a different peer's rip.
|
||||
if (prev?.candidate) await clearPen(rel, "searched again");
|
||||
auditions[rel] = { status: "searching", dead };
|
||||
try {
|
||||
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
|
||||
const list = await findAlbumCopies({
|
||||
const { copies, silent } = await findAlbumCopies({
|
||||
artist: pickerArtist(x),
|
||||
// The record's own name leads; its folder is the narrower
|
||||
// fallback for when the tags are wrong or absent.
|
||||
@@ -763,15 +923,18 @@
|
||||
candidate: trusted ? found[rel]?.result?.candidate : null,
|
||||
wantTracks: x.tracks.length,
|
||||
});
|
||||
if (!list.length) {
|
||||
auditions[rel] = { status: "none", dead };
|
||||
if (!copies.length) {
|
||||
// `silent` separates "the network never answered" from "peers
|
||||
// answered and none had it" — they read identically on the
|
||||
// card otherwise, and the first one is not about this record.
|
||||
auditions[rel] = { status: "none", dead, silent };
|
||||
schedulePersist();
|
||||
return;
|
||||
}
|
||||
await beginAudition(
|
||||
rel,
|
||||
slimCandidate(list[0]),
|
||||
list.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
|
||||
slimCandidate(copies[0]),
|
||||
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
|
||||
{ dead },
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -812,6 +975,7 @@
|
||||
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
|
||||
if (p.done) {
|
||||
au.arrived = (au.arrived ?? 0) + 1;
|
||||
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
|
||||
if (idx === 0) {
|
||||
au.track1Path = p.localPath;
|
||||
au.status = "ready";
|
||||
@@ -900,6 +1064,105 @@
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- what the upgrade step is still waiting on ---------------------- */
|
||||
|
||||
/** Records whose found copy is not fully in the pen yet, plus ones that
|
||||
* ARE and have not been imported — a finished copy belongs here too,
|
||||
* because it is the thing standing between you and pressing Import.
|
||||
*
|
||||
* Deliberately not "every audition": a record you listened to and then
|
||||
* kept your own copy of is not something you are waiting for. */
|
||||
const arrivals = $derived(
|
||||
Object.entries(auditions)
|
||||
.flatMap(([rel, au]) => {
|
||||
const files = au?.candidate?.files ?? [];
|
||||
const want = au?.expected ?? files.length;
|
||||
if (!files.length || !want) return [];
|
||||
const have = Math.max(0, Math.min(au.arrived ?? 0, want));
|
||||
const owed = want - have;
|
||||
const theirs = choices[rel]?.audio === "theirs";
|
||||
if (choices[rel]?.applied || (!owed && !theirs)) return [];
|
||||
// Bytes only when the pen check told us WHICH files are owed.
|
||||
// Owed-count times an average would be a number that looks
|
||||
// measured and is not.
|
||||
const idx = au.missingIdx;
|
||||
return [{
|
||||
rel, au, want, have, owed, theirs,
|
||||
name: rel.split(/[\\/]/).pop() || rel,
|
||||
peer: au.candidate.username,
|
||||
live: liveOf(au),
|
||||
bytes: idx ? idx.reduce((n, i) => n + (files[i]?.size ?? 0), 0) : null,
|
||||
}];
|
||||
})
|
||||
.sort((a, b) => b.owed - a.owed || a.name.localeCompare(b.name)));
|
||||
|
||||
const arrivalsOwed = $derived(arrivals.filter((a) => a.owed > 0));
|
||||
const arrivalFiles = $derived(arrivalsOwed.reduce((n, a) => n + a.owed, 0));
|
||||
const arrivalStalled = $derived(arrivalsOwed.some((a) => a.live?.stalled));
|
||||
const arrivalBytes = $derived(
|
||||
arrivalsOwed.length && arrivalsOwed.every((a) => a.bytes != null)
|
||||
? arrivalsOwed.reduce((n, a) => n + a.bytes, 0) : null);
|
||||
let trayOpen = $state(false);
|
||||
|
||||
/** What one row says, in the language the picker already uses. Nothing
|
||||
* here invents a state: every branch is something the transfer events
|
||||
* actually said, or the plain absence of them — after a restart there is
|
||||
* no `xfer` at all, and "asked, no answer yet" is the truth then. */
|
||||
function arrivalWords(a) {
|
||||
if (!a.owed) return { cls: "ok", label: `all ${a.want} cached`, detail: "ready to import" };
|
||||
const x = a.live;
|
||||
if (x?.stalled) {
|
||||
const mins = Math.max(1, Math.round((x.quietMs ?? 0) / 60000));
|
||||
return { cls: "stall", label: `nothing for ${mins} min`, detail: `${a.peer} stopped sending` };
|
||||
}
|
||||
if (x?.phase === "transferring") {
|
||||
const mbs = (x.speedBps ?? 0) / 1e6;
|
||||
return { cls: "move", label: "arriving", detail: mbs >= 0.05 ? `${mbs.toFixed(1)} MB/s` : "slowly" };
|
||||
}
|
||||
if (x?.place != null || x?.phase === "queued") {
|
||||
return {
|
||||
cls: "wait", label: `queued at ${a.peer}`,
|
||||
detail: x.place != null ? `${nfmt(x.place)} ahead of you` : "nothing moving yet",
|
||||
};
|
||||
}
|
||||
return { cls: "wait", label: `asked ${a.peer}`, detail: "no answer yet" };
|
||||
}
|
||||
|
||||
/** The container is what the peer sent, the depth and bitrate are what the
|
||||
* probe MEASURED of the file that landed. */
|
||||
function arrivalFormat(a) {
|
||||
const first = a.au.candidate.files?.[0];
|
||||
const ext = String(first?.name ?? first?.path ?? "").split(".").pop().toUpperCase();
|
||||
const p = a.au.probe;
|
||||
if (a.au.candidate.lossless === a.au.candidate.trackCount) {
|
||||
return p?.bitDepth ? `${ext} ${p.bitDepth}-bit` : ext;
|
||||
}
|
||||
const k = p?.bitrate ?? a.au.candidate.avgBitrate;
|
||||
return k ? `${ext} ${nfmt(k)}k` : ext;
|
||||
}
|
||||
|
||||
const arrivalInitials = (name) =>
|
||||
name.replace(/^.*?[-–—]\s*/, "").split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase();
|
||||
|
||||
/** Jump the picker to a record in the tray. The lens can be narrower than
|
||||
* the tray — a record filtered out of the current bucket is still one you
|
||||
* are waiting for — so widen rather than fail silently. */
|
||||
function goToArrival(rel) {
|
||||
if (!seekTo(rel)) {
|
||||
bucket = "all";
|
||||
seekTo(rel);
|
||||
}
|
||||
trayOpen = false;
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
/** Refusals the tray is not already speaking for. A record whose found
|
||||
* copy is still arriving is IN the tray — live, with the peer and the
|
||||
* count — and saying it again in the past tense above is the same fact
|
||||
* twice, the staler of the two winning the eye. */
|
||||
const leftBehind = $derived(
|
||||
(report?.skipped ?? []).filter((s) => !arrivals.some((a) => a.rel === s.rel)));
|
||||
|
||||
/** The picker's resolved snapshot — what apply writes. Stored beside the
|
||||
* choices but NOT a "seen": looking is not deciding. */
|
||||
function snapshot(rel, snap) {
|
||||
@@ -1182,6 +1445,12 @@
|
||||
if (c && !refused.has(rel)) c.applied = true;
|
||||
if (refused.size) {
|
||||
await persist();
|
||||
// A refusal is usually "the found copy is short", and the copy
|
||||
// is short because a transfer died. Ask for the rest NOW, so
|
||||
// "it imports as soon as the rest land" is something that can
|
||||
// actually happen — otherwise the next run refuses the same
|
||||
// records for the same reason, forever.
|
||||
resumeRefused(report.skipped);
|
||||
} else {
|
||||
await api.importSessionClear().catch(() => {});
|
||||
importResume.active = false;
|
||||
@@ -1221,6 +1490,17 @@
|
||||
// copy out of the source folder.
|
||||
audio: r.audio === "theirs" ? "theirs" : null,
|
||||
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : null,
|
||||
// WHICH files the chosen copy is made of. The pen is
|
||||
// keyed by RECORD, not by peer, so a record that
|
||||
// changed peers has both copies sitting in it — the
|
||||
// abandoned peer's transfers were cancelled, but the
|
||||
// files they had already written stayed. Apply took
|
||||
// everything in the folder and filed Centipede Hz as
|
||||
// 17 tracks: deletia's 11 plus the 6 smash2 had
|
||||
// managed before it was dropped.
|
||||
audioFiles: r.audio === "theirs"
|
||||
? (auditions[p.rel]?.candidate?.files ?? []).map((f) => f.path)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1231,7 +1511,17 @@
|
||||
* doesn't redo them. The eventual full apply still covers everything —
|
||||
* idempotent copies make the banked files no-ops. */
|
||||
async function applyPartial() {
|
||||
if (!dest?.dir || applying || !scan) return;
|
||||
// Banking on the way out must never fail in silence. Closing the
|
||||
// picker after deciding 25 records and finding nothing in the library —
|
||||
// with no toast, no error and nothing in the log — is what a bare
|
||||
// `return` here looks like from the outside.
|
||||
if (applying || !scan) return;
|
||||
if (!dest?.dir) {
|
||||
importStatus.phase = "error";
|
||||
importStatus.error = "No library folder is set, so nothing could be imported.";
|
||||
console.warn("import: banking skipped — no destination");
|
||||
return;
|
||||
}
|
||||
const seen = new Set(Object.entries(choices)
|
||||
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
|
||||
if (!seen.size) return;
|
||||
@@ -1255,6 +1545,7 @@
|
||||
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
|
||||
for (const r of seen)
|
||||
if (choices[r] && !refused.has(r)) choices[r].applied = true;
|
||||
resumeRefused(rep.skipped);
|
||||
importStatus.done = rep.albums;
|
||||
importStatus.label = fmtGB(rep.bytes);
|
||||
importStatus.skipped = refused.size;
|
||||
@@ -1297,6 +1588,8 @@
|
||||
}
|
||||
step = "source";
|
||||
root = null;
|
||||
rootRel = null;
|
||||
rootMissing = false;
|
||||
scan = null;
|
||||
scanProg = null;
|
||||
scanEta = null;
|
||||
@@ -1364,14 +1657,14 @@
|
||||
show it on, so without this the only trace of a refusal was
|
||||
a toast reading "Imported 0 records". Not dismissible while
|
||||
it is still true — it clears itself when they import. -->
|
||||
{#if step !== "done" && report?.skipped?.length}
|
||||
{#if step !== "done" && leftBehind.length}
|
||||
<div class="im-left">
|
||||
<b>{nfmt(report.skipped.length)} record{report.skipped.length === 1 ? "" : "s"}
|
||||
{report.skipped.length === 1 ? "was" : "were"} not imported.</b>
|
||||
Nothing was written for {report.skipped.length === 1 ? "it" : "them"} and
|
||||
{report.skipped.length === 1 ? "it is" : "they are"} still on the list —
|
||||
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
|
||||
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
|
||||
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
|
||||
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
|
||||
importing again once the reason below is gone will pick
|
||||
{report.skipped.length === 1 ? "it" : "them"} up.
|
||||
{leftBehind.length === 1 ? "it" : "them"} up.
|
||||
<div class="im-left-rows">
|
||||
<!-- The FOLDER, not the tag album name: this record's
|
||||
tags call it "user48736353001", which names
|
||||
@@ -1397,7 +1690,13 @@
|
||||
<div class="im-drop-t">
|
||||
<div class="im-drop-h">The music — read from</div>
|
||||
<div class="im-path">{root ?? "Choose a folder…"}</div>
|
||||
{#if scan}
|
||||
{#if rootMissing}
|
||||
<!-- A resumed review whose source could not be
|
||||
found: say so HERE, where the folder is
|
||||
named, rather than letting apply fail on
|
||||
a path the review still displays. -->
|
||||
<small class="missing">this folder isn't here — choose where it lives now</small>
|
||||
{:else if scan}
|
||||
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
|
||||
{fmtGB(totals.bytes)} · scanned</small>
|
||||
{:else if root}
|
||||
@@ -1638,7 +1937,83 @@
|
||||
{label}<i>{nfmt(n)}</i>
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Not a lens: the outcome chips choose what the picker
|
||||
WALKS, this one discloses what the found copies still
|
||||
owe. It rides here because it belongs to the same
|
||||
glance, and it costs no space at all when nothing is
|
||||
owed — which is the normal case. -->
|
||||
{#if arrivals.length}
|
||||
<button class="im-bucket ar-chip" class:stalled={arrivalStalled}
|
||||
aria-expanded={trayOpen}
|
||||
onclick={() => (trayOpen = !trayOpen)}
|
||||
title={arrivalFiles
|
||||
? "What the found copies still owe"
|
||||
: "Copies cached and waiting to be imported"}>
|
||||
{#if arrivalFiles}<span class="ar-dot"></span>{/if}
|
||||
{arrivalStalled ? "Stalled" : arrivalFiles ? "Arriving" : "Arrived"}
|
||||
<i>{nfmt(arrivalFiles || arrivals.length)}</i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if trayOpen && arrivals.length}
|
||||
<div class="ar-tray" class:stalled={arrivalStalled} class:done={!arrivalFiles}>
|
||||
<div class="ar-h">
|
||||
{#if !arrivalFiles}
|
||||
<b>Everything arrived.</b>
|
||||
{arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`}
|
||||
cached — importing now takes {arrivals.length === 1 ? "it" : "them"}.
|
||||
{:else if arrivalStalled}
|
||||
<b>Stalled.</b> {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}
|
||||
still owed, and nothing is moving.
|
||||
{:else}
|
||||
<b>Still arriving.</b> {nfmt(arrivalsOwed.length)}
|
||||
record{arrivalsOwed.length === 1 ? "" : "s"} ·
|
||||
{nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} ·
|
||||
{nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}.
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ar-rows">
|
||||
{#each arrivals as a (a.rel)}
|
||||
{@const w = arrivalWords(a)}
|
||||
<div class="ar-row">
|
||||
<div class="ar-art" class:ok={!a.owed}>{arrivalInitials(a.name)}</div>
|
||||
<div class="ar-nm">
|
||||
<b title={a.rel}>{a.name}</b>
|
||||
<div class="ar-meta">
|
||||
<div class="im-bar" class:live={a.owed > 0 && a.live?.phase === "transferring"}>
|
||||
<i style:width="{Math.round((a.have / a.want) * 100)}%"></i>
|
||||
</div>
|
||||
<span>{nfmt(a.have)} of {nfmt(a.want)} cached</span>
|
||||
<span>·</span><span>{arrivalFormat(a)}</span>
|
||||
<b class="ar-state {w.cls}">{w.label}</b>
|
||||
<span>{w.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ar-acts">
|
||||
<button class="pill ghost" onclick={() => goToArrival(a.rel)}>Go to record</button>
|
||||
{#if a.owed}
|
||||
<button class="pill ghost"
|
||||
onclick={() => switchPeer(a.rel, "you asked for another peer")}
|
||||
>Try another peer</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="ar-foot">
|
||||
{#if arrivalFiles}
|
||||
Nothing here needs you — they import themselves on the next run once the
|
||||
files land. "Keep mine" on the record takes your own copy instead.
|
||||
{:else}
|
||||
<!-- The picker's own bank, not the full run:
|
||||
it applies what has been decided and not
|
||||
yet written, which is exactly these. -->
|
||||
<button class="pill" onclick={applyPartial} disabled={applying}>
|
||||
Import {nfmt(arrivals.length)} record{arrivals.length === 1 ? "" : "s"}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if curPick}
|
||||
{#key curPick.rel}
|
||||
<ImportPicker
|
||||
@@ -2117,6 +2492,72 @@
|
||||
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
|
||||
.im-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---- what the found copies still owe ------------------------------
|
||||
The chip rides in the outcome row but is NOT one of them: the others
|
||||
choose what the picker walks, this one discloses a state. Accent fill
|
||||
and a pulse say so, and it is absent entirely when nothing is owed. */
|
||||
.im-bucket.ar-chip {
|
||||
background: color-mix(in srgb, var(--accent) 26%, rgba(10, 10, 12, 0.5));
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
.im-bucket.ar-chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
|
||||
}
|
||||
.im-bucket.ar-chip.stalled {
|
||||
background: rgba(233, 185, 73, 0.2); color: #f0cd77;
|
||||
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.4);
|
||||
}
|
||||
.ar-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
|
||||
animation: ar-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ar-pulse { 50% { opacity: 0.25; } }
|
||||
|
||||
.ar-tray {
|
||||
position: relative; z-index: 1;
|
||||
margin: -6px 0 16px; padding: 11px 13px; border-radius: var(--radius);
|
||||
max-width: 96ch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.ar-h {
|
||||
display: flex; align-items: baseline; gap: 9px;
|
||||
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
|
||||
}
|
||||
.ar-h b { color: color-mix(in srgb, var(--accent) 55%, #fff); font-weight: 800; }
|
||||
.ar-tray.stalled .ar-h b { color: #f0cd77; }
|
||||
.ar-tray.done .ar-h b { color: #7fe0a4; }
|
||||
.ar-rows { margin-top: 9px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.ar-row {
|
||||
display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
|
||||
padding: 8px 9px; border-radius: 6px; background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.ar-art {
|
||||
width: 34px; height: 34px; border-radius: 4px; display: grid; place-items: center;
|
||||
font-size: 10px; font-weight: 800; color: rgba(255, 255, 255, 0.78);
|
||||
background: color-mix(in srgb, var(--accent) 30%, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
.ar-art.ok { background: rgba(127, 224, 164, 0.22); }
|
||||
.ar-nm { min-width: 0; }
|
||||
.ar-nm > b {
|
||||
display: block; font-size: 13px; font-weight: 700;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ar-meta {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 3px; font-size: 11.5px; color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ar-meta .im-bar { width: 120px; flex: none; }
|
||||
.ar-state { font-weight: 700; }
|
||||
.ar-state.wait { color: var(--text-secondary); }
|
||||
.ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); }
|
||||
.ar-state.stall { color: #f0cd77; }
|
||||
.ar-state.ok { color: #7fe0a4; }
|
||||
.ar-acts { display: flex; gap: 6px; align-items: center; }
|
||||
.ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
|
||||
.im-left {
|
||||
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
|
||||
@@ -2137,6 +2578,9 @@
|
||||
}
|
||||
.im-reset b { color: #f0a0a0; }
|
||||
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
|
||||
/* The source folder a resumed review could not find. Amber, not red: it
|
||||
reports something that happened rather than asking to destroy anything. */
|
||||
.im-drop-t small.missing { color: rgb(232, 178, 96); }
|
||||
.pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
|
||||
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
|
||||
|
||||
@@ -2177,5 +2621,6 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.im-bar.live i { animation: none; }
|
||||
.led-pulse { animation: none; }
|
||||
.ar-dot { animation: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user