1669 lines
90 KiB
Svelte
1669 lines
90 KiB
Svelte
<script>
|
||
// The upgrades picker: one record at a time, because the decision is per
|
||
// record and cannot be made from a list row. Ported from the approved
|
||
// mock (research/import/render.js screenPicker + import.css) — markup
|
||
// and manners verbatim, data live: identification is on demand when the
|
||
// record is opened (decision 12), artwork is measured (decoded sizes via
|
||
// import_cover_report / import_local_art), and every choice is stored as
|
||
// a departure keyed by rel (decision model proven in the mock).
|
||
//
|
||
// The A/B card renders the app's own waveform shapes from real peaks, and
|
||
// both play buttons play real local files — yours from the source folder,
|
||
// theirs from the audition pen.
|
||
import { fade } from "svelte/transition";
|
||
import * as api from "../lib/api.js";
|
||
import Icon from "./Icon.svelte";
|
||
import { onDestroy } from "svelte";
|
||
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
|
||
import { evidence as matchEvidence, normx } from "../lib/import-evidence.js";
|
||
import { IDENTIFY_ENGINE, majority } from "../lib/import-title.js";
|
||
|
||
let {
|
||
album: a, // derived album from ImportWindow (rel, title, …)
|
||
artist, // the resolved artist (plan logic, not folder-blind)
|
||
albumName, // the resolved album NAME — tags first, folder as
|
||
// fallback. This is what the record IS: the
|
||
// heading, the verdict, what the providers are
|
||
// asked, and what the player announces. `a.title`
|
||
// is the folder on disk and survives as a hover.
|
||
|
||
sourceRoot,
|
||
index, total, // where the queue cursor is
|
||
found, // rel -> { state, result } identify cache ($state, shared)
|
||
localArts, // rel -> LocalArt | null ($state, shared)
|
||
reports, // rel -> CoverReport ($state, shared)
|
||
choice, // this record's { art, tags, tracks, seen }
|
||
onChoose, // (patch) => void — records a departure + seen
|
||
onReset, // () => void — clear this record's choices
|
||
onSnapshot, // (resolved) => void — what apply should write
|
||
audition, // the found-copy state for this record (window-owned)
|
||
live, // in-flight transfer facts + stall verdict, or null
|
||
onFindCopy, // () => void — search + start the audition download
|
||
onTryAnother, // () => void — abandon this peer for the next
|
||
// ranked copy, without a fresh search
|
||
accentStyle, // this record's --accent pair, cast by the window
|
||
} = $props();
|
||
|
||
// ---- what the found row says while it is still arriving ---------------
|
||
// It used to say "arriving…" and nothing else, through connecting, a
|
||
// queue, a live transfer and a dead peer alike. Every one of these facts
|
||
// was already on the wire.
|
||
const MBs = (bps) => `${(bps / 1048576).toFixed(1)} MB/s`;
|
||
const secsLeft = (x) =>
|
||
x.speedBps > 0 && x.total > x.received
|
||
? Math.round((x.total - x.received) / x.speedBps)
|
||
: null;
|
||
/** The one line under the found copy's name. Reads the phase, not a
|
||
* spinner: a queue place that ticks down is proof the wait is moving. */
|
||
function arrivalLine(x, au) {
|
||
if (!x) return "asking the network…";
|
||
if (x.stalled) {
|
||
const s = Math.round(x.quietMs / 1000);
|
||
return `no bytes for ${s}s — the peer went quiet`;
|
||
}
|
||
if (x.phase === "queued")
|
||
return x.place != null
|
||
? `#${x.place} in their queue · nothing to do but wait`
|
||
: "queued at the peer · nothing to do but wait";
|
||
if (x.phase === "connecting" || x.phase === "requested")
|
||
return `connecting to ${au?.candidate?.username ?? "the peer"}…`;
|
||
if (x.total > 0) {
|
||
const pct = Math.min(100, Math.round((x.received / x.total) * 100));
|
||
const eta = secsLeft(x);
|
||
const rate = x.speedBps > 0 ? `${MBs(x.speedBps)} · ` : "";
|
||
return `${rate}${pct}% of track 1${eta != null ? ` · about ${eta}s` : ""}`;
|
||
}
|
||
return "starting the transfer…";
|
||
}
|
||
/** Determinate only when there is a real ratio to show. Derived, not a
|
||
* `{@const}` in the markup: those may only be immediate children of a
|
||
* block tag, and this one sits inside a plain <div>. */
|
||
const arrivPct = $derived(
|
||
live && live.total > 0 && live.phase === "transferring"
|
||
? Math.min(100, (live.received / live.total) * 100)
|
||
: null);
|
||
|
||
const nfmt = (n) => n.toLocaleString("en-US");
|
||
const fmtMB = (n) => Math.round(n / 1024 ** 2) + " MB";
|
||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
||
const norm2 = normx;
|
||
|
||
// ---- on-demand data --------------------------------------------------
|
||
const idn = $derived(found[a.rel]);
|
||
const candidate = $derived(idn?.result?.candidate ?? null);
|
||
const yours = $derived(localArts[a.rel]);
|
||
const report = $derived(reports[a.rel]);
|
||
|
||
$effect(() => {
|
||
const rel = a.rel;
|
||
if (found[rel]) return;
|
||
found[rel] = { state: "loading", result: null };
|
||
(async () => {
|
||
try {
|
||
const ask = albumName ?? a.title;
|
||
// The folder rides along as the second witness: `ask` is the
|
||
// tags' claim, and tags carry typos the folder does not
|
||
// ("Nonagon Infiniry" on all nine files). Asked after it, and
|
||
// deduped away when they agree.
|
||
//
|
||
// The SHAPE goes too — count, track titles, year, and where the
|
||
// files live. A name is only one fact about a record, and it is
|
||
// the one most often wrong; the discography pass compares the
|
||
// rest when no phrase can find it.
|
||
const shape = {
|
||
tracks: a.tracks?.length ?? null,
|
||
trackTitles: (a.tracks ?? []).map((t) => t.title).filter(Boolean),
|
||
year: a.year ?? null,
|
||
// `parts` bounds the artwork hash to the record's OWN audio.
|
||
// Without it the backend walked the folder recursively, and
|
||
// a record sharing a folder with another one hashed the
|
||
// neighbour's cover — see own_audio_files in import.rs.
|
||
sourceRoot, rel, parts: a.parts ?? [],
|
||
};
|
||
let r = await api.importIdentify(artist, ask, a.title, shape);
|
||
let askedArtist = artist;
|
||
|
||
// The FOLDER is asked first, and stays the default: it is one
|
||
// deliberate decision, where track artist tags carry featured
|
||
// guests, compilation contributors and classical performers.
|
||
//
|
||
// But a folder is sometimes not the artist at all — "Band Of
|
||
// Gypsys" holding six files that every one of them tags
|
||
// "Jimi Hendrix" — and then the ask is wrong twice over,
|
||
// because the try-list strips that "artist" off the front of
|
||
// the title and asks the providers for what is left of it.
|
||
//
|
||
// Only when the first ask came back with NOTHING, or with a
|
||
// record the judge calls wrong. A candidate that already
|
||
// stands is not this function's business.
|
||
const judge = (res, who) => (res?.candidate
|
||
? matchEvidence(res.candidate, who, ask, a.tracks.length).verdict
|
||
: "none");
|
||
if (["none", "bad"].includes(judge(r, artist))) {
|
||
const tagged = majority((a.tracks ?? []).map((t) => t.artist));
|
||
if (tagged && normx(tagged) !== normx(artist)) {
|
||
const alt = await api.importIdentify(tagged, ask, a.title, shape);
|
||
// Taken only if it is actually better. A second wrong
|
||
// answer is not an improvement on the first.
|
||
if (["ok", "warn"].includes(judge(alt, tagged))) {
|
||
r = alt;
|
||
askedArtist = tagged;
|
||
}
|
||
}
|
||
}
|
||
// Stamped with the engine that produced it: a cached "nothing
|
||
// found" is only trustworthy while the thing that looked has
|
||
// not learned anything new. See IDENTIFY_ENGINE.
|
||
// `askedArtist` rides along so the judge compares against the
|
||
// name the provider was ASKED about — the §3.2 rule, which
|
||
// would otherwise call this record's own match a wrong one.
|
||
// A lookup that FAILED is not an answer about the record. MB's
|
||
// 1 req/s limiter makes a 503 routine, and filing one as "done"
|
||
// made "Murder of the Universe" a permanent deep cut — the
|
||
// record MB holds, scored 100, and gave up on the next ask.
|
||
// `persist()` only saves `done`, so an error re-asks; nothing
|
||
// else here treats the two alike.
|
||
if (r.incomplete && !r.candidate) {
|
||
found[rel] = {
|
||
state: "error", result: null,
|
||
error: "the provider did not answer — this record has not been looked up yet",
|
||
};
|
||
return;
|
||
}
|
||
found[rel] = { state: "done", result: { ...r, askedArtist }, engine: IDENTIFY_ENGINE };
|
||
if (r.candidate?.cover) {
|
||
const rep = await api.importCoverReport(
|
||
askedArtist, r.used ?? ask, r.candidate.cover);
|
||
reports[rel] = rep;
|
||
}
|
||
} catch (e) {
|
||
found[rel] = { state: "error", result: null, error: String(e) };
|
||
}
|
||
})();
|
||
});
|
||
// A resumed session carries the identify but not the cover report (too
|
||
// heavy to persist) — refetch it for the record on screen.
|
||
let reportPending = false;
|
||
$effect(() => {
|
||
const rel = a.rel;
|
||
const cover = found[rel]?.result?.candidate?.cover;
|
||
if (!cover || reports[rel] || reportPending) return;
|
||
reportPending = true;
|
||
api.importCoverReport(artist, found[rel].result.used ?? a.title, cover)
|
||
.then((rep) => { reports[rel] = rep; }, () => {})
|
||
.finally(() => { reportPending = false; });
|
||
});
|
||
|
||
$effect(() => {
|
||
const rel = a.rel;
|
||
if (localArts[rel] !== undefined) return;
|
||
localArts[rel] = null;
|
||
// `parts` for the same reason apply takes them: the record's audio is
|
||
// its own folder and the parts it named, never a walk of what happens
|
||
// to sit underneath.
|
||
api.importLocalArt(sourceRoot, rel, a.parts ?? []).then(
|
||
(v) => { localArts[rel] = v; },
|
||
() => {});
|
||
});
|
||
|
||
// The MASTER, not the tile-sized default: this card exists to judge one
|
||
// cover against another, and the loupe magnifies it 3x. A resampled copy
|
||
// would hide exactly the difference being judged.
|
||
let yoursSrc = $state(null);
|
||
$effect(() => {
|
||
const p = yours?.path;
|
||
yoursSrc = null;
|
||
if (p) getArtMaster(p).then((u) => { if (yours?.path === p) yoursSrc = u; });
|
||
});
|
||
|
||
// ---- provider covers, as the mock's tiles ---------------------------
|
||
/** Reference + report candidates folded into the mock's covers[] shape:
|
||
* provider label, decoded px, url. Deduped by url. */
|
||
const covers = $derived.by(() => {
|
||
const label = (s) => ({
|
||
deezer: "Deezer", "deezer-max": "Deezer",
|
||
apple: "Apple", "cover-art-archive": "Cover Art Archive",
|
||
current: candidate?.source ?? "provider",
|
||
})[s] ?? s;
|
||
const out = [];
|
||
const seen = new Set();
|
||
// `sized` is what a MEASURED tile needs: a cover whose decoded size
|
||
// came back. The interim tile is exempt — it exists precisely because
|
||
// nothing has been measured yet, and rejecting it (the old `!w` guard
|
||
// did) left covers empty while the report was in flight, which the
|
||
// panel then reported as "no service has this record".
|
||
const push = (src, url, w, h, title, sized = true) => {
|
||
if (!url || seen.has(url) || (sized && !w)) return;
|
||
seen.add(url);
|
||
out.push({ provider: label(src), url, px: w ?? null, realH: h ?? null, title });
|
||
};
|
||
if (report) {
|
||
if (report.reference)
|
||
push(report.reference.source, report.reference.url,
|
||
report.reference.width, report.reference.height, candidate?.title);
|
||
for (const c of report.candidates)
|
||
push(c.source, c.url, c.width, c.height, candidate?.title);
|
||
}
|
||
if (!out.length && candidate?.cover) {
|
||
// Report still loading (or it failed): the identify already proved
|
||
// a service HAS this record and handed us its cover, so show that
|
||
// picture rather than an empty frame claiming nobody has it.
|
||
push(candidate.source.toLowerCase(), candidate.cover, null, null, candidate.title, false);
|
||
}
|
||
// One tile per SERVICE (the mock's model): the Deezer reference and
|
||
// its deezer-max variant are the same picture at two sizes, and a
|
||
// choice stored by provider name would light both frames. Keep each
|
||
// provider's largest measured copy, in first-seen order.
|
||
const byProvider = new Map();
|
||
for (const c of out) {
|
||
const prev = byProvider.get(c.provider);
|
||
if (!prev || (c.px || 0) > (prev.px || 0)) byProvider.set(c.provider, c);
|
||
}
|
||
return [...byProvider.values()];
|
||
});
|
||
|
||
// A lone tile with no decoded size is the interim one, and it is the tell
|
||
// for which of two very different situations the panel is in: the report
|
||
// is still coming, or it came back with nothing. Neither is "no service
|
||
// has this record" — the identify already proved one does.
|
||
/** Candidate cover URLs the webview could not load, by url.
|
||
*
|
||
* A provider can name a cover it does not have: MusicBrainz identifies a
|
||
* release group and the cover URL is built from its MBID, but the Cover
|
||
* Art Archive answers 404 when nothing has been uploaded for it. Only the
|
||
* <img> knows — the report says "size unknown", which is also what a
|
||
* measurement that merely timed out says. */
|
||
let deadArt = $state({});
|
||
|
||
/** Every candidate turned out to be a URL with nothing behind it — which
|
||
* is a different sentence from "the sizes could not be measured": the
|
||
* service was reached and simply has no picture. */
|
||
const allDead = $derived(covers.length > 0 && covers.every((c) => deadArt[c.url]));
|
||
|
||
const unmeasured = $derived(covers.length === 1 && covers[0].px == null);
|
||
const measuring = $derived(unmeasured && !report);
|
||
const measureFailed = $derived(unmeasured && !!report && !allDead);
|
||
/** A match with no cover at all — a real answer, and not the same as
|
||
* nobody having heard of the record. */
|
||
const matchHasNoArt = $derived(
|
||
idn?.state === "done" && !!candidate && !candidate.cover && !covers.length);
|
||
|
||
// ---- evidence (shared with the window's outcome buckets) -------------
|
||
// Judged against the record's own NAME, the same string the provider was
|
||
// asked about — not the folder. Comparing a candidate to the folder made
|
||
// an exact hit look doubtful: "Mirrored" against
|
||
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
|
||
// match wore a "!" on its title row.
|
||
// Against the artist the provider was ASKED about — which is the folder
|
||
// unless the tags found the record where the folder could not.
|
||
const ev = $derived(matchEvidence(
|
||
candidate, idn?.result?.askedArtist ?? artist, albumName ?? a.title, a.tracks.length));
|
||
|
||
const verdictLine = $derived.by(() => {
|
||
const c = candidate;
|
||
const v = {
|
||
none: ["warn", "Nothing on the provider matches this record. It may be a bootleg, a session, or named differently."],
|
||
bad: ["bad", "<b>This does not look like the same record.</b> Applying it would replace your files with a different album."],
|
||
warn: ["warn", "<b>Close, but not identical.</b> Check the tracklist and listen before applying — it may be a different edition."],
|
||
ok: ["ok", "Artist, title and track count all agree. Listen to be certain."],
|
||
}[c ? ev.verdict : "none"];
|
||
if (c && ev.edition) {
|
||
const counts = c.trackCount !== a.tracks.length
|
||
? ` with ${c.trackCount} tracks to your ${a.tracks.length}` : "";
|
||
return [v[0], `<b>The same record, a different edition.</b> Theirs is “${c.title}”${counts}. Take it only if you want that pressing.`];
|
||
}
|
||
return v;
|
||
});
|
||
|
||
// ---- decisions (mock's proposal/resolved*, verbatim) -----------------
|
||
const tagPairs = $derived.by(() => {
|
||
const c = candidate;
|
||
const first = a.tracks[0] ?? {};
|
||
// Album-level fields; "yours" comes from the scan's tags (majority
|
||
// fields land in the plan; per-file artist/album/year ride the scan).
|
||
const maj = (vals) => {
|
||
const tally = new Map();
|
||
for (const v of vals) if (v != null && v !== "") tally.set(v, (tally.get(v) ?? 0) + 1);
|
||
let best = null, n = 0;
|
||
for (const [v, k] of tally) if (k > n) { best = v; n = k; }
|
||
return best;
|
||
};
|
||
return [
|
||
["Artist", maj(a.tracks.map((t) => t.artist)), c?.artist],
|
||
["Album", maj(a.tracks.map((t) => t.album)), c?.title],
|
||
["Year", a.year ?? maj(a.tracks.map((t) => t.year)), c?.releaseDate?.slice(0, 4)],
|
||
["Label", null, c?.label],
|
||
["Genre", null, c?.genre],
|
||
].map(([k, m, t]) => [k, m == null ? null : String(m), t == null ? null : String(t)]);
|
||
});
|
||
|
||
const trusted = $derived(candidate && (ev.verdict === "ok" || ev.verdict === "warn"));
|
||
|
||
const proposal = $derived.by(() => {
|
||
let art = "yours";
|
||
if (covers.length && trusted) {
|
||
const best = covers.reduce((b, c) => ((c.px || 0) > (b.px || 0) ? c : b), covers[0]);
|
||
if (!yours || (best.px || 0) > yours.w) art = best.provider;
|
||
}
|
||
const tags = {};
|
||
if (trusted) {
|
||
for (const [k, mine, theirs] of tagPairs) if (!mine && theirs) tags[k] = "theirs";
|
||
}
|
||
return { art, tags };
|
||
});
|
||
|
||
const resolvedArt = $derived(choice?.art ?? proposal.art);
|
||
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
|
||
const resolvedTrack = (n) => choice?.tracks?.[n] ?? "yours";
|
||
|
||
/** Row order is the RECORD's order; `n` stays the SCAN's.
|
||
*
|
||
* The scan lists a folder with a plain byte sort (import.rs `paths.sort()`),
|
||
* where '0' (0x30) beats '_' (0x5F) — so "10_LONELY WANDERER" precedes
|
||
* "1_SEQUENTIAL CIRCUITS" and a record numbered without zero-padding reads
|
||
* 10, 11, 12, 13, 1, 2 … 9. Pairing that against a provider tracklist by
|
||
* position compares track 10 with track 1 the whole way down, and the
|
||
* length differences it reports are the arithmetic of that mispairing.
|
||
*
|
||
* Ordering is therefore for DISPLAY ONLY. `n` remains each track's index
|
||
* in `a.tracks`, because that is the index apply writes by: `track_titles`
|
||
* is positional against apply's own `f.sort()` of the same folder
|
||
* (import.rs:1238). Reordering `a.tracks` itself would move the review
|
||
* without moving apply, and land every chosen title on the wrong file.
|
||
*
|
||
* A folded release is left in the order the fold built: its tracks are in
|
||
* PART order and every part numbers its own tracks from 1, so ordering the
|
||
* flattened list by track number would interleave the discs — the trap
|
||
* import.rs:1235 and import-fold.js:172 both already guard. */
|
||
const trackRows = $derived.by(() => {
|
||
const rows = a.tracks.map((t, n) => ({ t, n }));
|
||
if ((a.parts?.length ?? 0) > 1) return rows;
|
||
// The tags decide when they ALL carry a distinct number: a rarities
|
||
// folder where every file claims track 1 orders by nothing, and a
|
||
// partial set would sort the numbered ahead of the unnumbered.
|
||
const nos = rows.map((r) => r.t.trackNo);
|
||
if (nos.every((x) => typeof x === "number" && x > 0)
|
||
&& new Set(nos).size === nos.length) {
|
||
return [...rows].sort((x, y) => x.t.trackNo - y.t.trackNo);
|
||
}
|
||
// Otherwise the filename, compared with its digit runs as NUMBERS —
|
||
// which is the same comparison the fold already uses to order parts.
|
||
return [...rows].sort((x, y) => x.t.name.localeCompare(
|
||
y.t.name, undefined, { numeric: true, sensitivity: "base" }));
|
||
});
|
||
|
||
/** What the file calls the track. The tag first — it is the thing apply
|
||
* writes and compares — falling back to the filename for a file carrying
|
||
* no title at all. */
|
||
const titleOfTrack = (t) => (t.title ?? "").trim()
|
||
|| t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
|
||
|
||
const trackPairs = $derived.by(() => {
|
||
const theirs = candidate?.tracklist ?? [];
|
||
return trackRows.map(({ t, n }, row) => {
|
||
const mine = titleOfTrack(t);
|
||
const their = theirs[row];
|
||
const dY = t.seconds ?? null;
|
||
const dT = their?.secs ?? null;
|
||
const drift = dY && dT ? Math.abs(dY - dT) : null;
|
||
return { n, row, mine, their: their?.title ?? null, dY, dT, drift,
|
||
aligned: drift !== null && drift <= 2 };
|
||
});
|
||
});
|
||
const trackChanges = $derived(
|
||
trackPairs.filter((p) => p.their && resolvedTrack(p.n) === "theirs").length);
|
||
const changeCount = $derived.by(() => {
|
||
let n = resolvedArt !== "yours" ? 1 : 0;
|
||
if (resolvedAudio !== "yours") n++;
|
||
for (const [k] of tagPairs) if (resolvedTag(k) === "theirs") n++;
|
||
return n + trackChanges;
|
||
});
|
||
|
||
const bucket = $derived(
|
||
!idn || idn.state !== "done" ? "Looking…"
|
||
: candidate
|
||
? { ok: "Clean match", warn: "Close", bad: "Wrong record" }[ev.verdict]
|
||
: covers.length ? "Art only" : "Deep cut");
|
||
|
||
const gainLabel = $derived.by(() => {
|
||
if (!idn || idn.state !== "done") return "";
|
||
// Same reason as yoursWins: with nothing measured the best is 0px, and
|
||
// the hero would announce "nothing to gain" about covers it has not
|
||
// looked at yet.
|
||
if (measuring) return "";
|
||
const conf = { ok: 1, warn: 0.6, bad: 0, none: 0 }[candidate ? ev.verdict : "none"];
|
||
const bestPx = Math.max(0, ...covers.map((c) => c.px || 0));
|
||
const artPx = bestPx > (yours?.w ?? 0) ? bestPx - (yours?.w ?? 0) : 0;
|
||
const bits = [];
|
||
if (artPx && conf) bits.push(`art +${nfmt(artPx)}px`);
|
||
if (a.avgBitrate && a.avgBitrate < 320 && conf) bits.push(`${a.avgBitrate}k audio`);
|
||
return bits.join(" · ") || (covers.length || candidate ? "nothing to gain" : "nothing found");
|
||
});
|
||
|
||
// The record's accent is cast by ImportWindow, not here: this component is
|
||
// re-keyed per record, so a tint it owned was removed on every move and the
|
||
// window snapped back to the app's blue between covers. See `accent` there.
|
||
|
||
const ext = $derived((a.tracks[0]?.ext ?? "").slice(1).toUpperCase());
|
||
|
||
const waveFloor = (n = 56) =>
|
||
Array.from({ length: n }, (_, i) => ({ h: "2px", fill: 0, i }));
|
||
|
||
// ---- fullscreen compare (mock's fullscreen(), ArtworkViewer's manners)
|
||
// Two shapes: with a cover on both sides it is the wipe/side/flip
|
||
// comparison; with only ONE image it is a plain viewer — looking at what
|
||
// you already have is the whole point, refusing to open was the bug.
|
||
let fs = $state(false);
|
||
let fsMode = $state("wipe"); // wipe | side | toggle
|
||
let fsPick = $state(1); // which service's cover is on the right
|
||
let fsSplit = $state(50); // the wipe handle, %
|
||
let fsShow = $state("a"); // flip: which one is showing
|
||
const fsRight = $derived(covers[Math.max(0, fsPick - 1)] ?? covers[0] ?? null);
|
||
function openFs(n) {
|
||
if (!yoursSrc && !covers.length) return;
|
||
fsPick = Math.max(1, n);
|
||
fsShow = "a";
|
||
fs = true;
|
||
}
|
||
// Escape closes the viewer BEFORE the window's own Escape closes the
|
||
// import (capture phase, stopped); space flips in toggle mode.
|
||
$effect(() => {
|
||
if (!fs) return;
|
||
const h = (e) => {
|
||
if (e.key === "Escape") {
|
||
e.stopPropagation();
|
||
fs = false;
|
||
} else if (e.key === " " && fsMode === "toggle") {
|
||
e.preventDefault();
|
||
fsShow = fsShow === "a" ? "b" : "a";
|
||
}
|
||
};
|
||
window.addEventListener("keydown", h, true);
|
||
return () => window.removeEventListener("keydown", h, true);
|
||
});
|
||
/** The viewer must escape .im-window: its transform turns `fixed` into
|
||
* "absolute in the window box", the scrolling body clips the bottom
|
||
* chrome, and the rail paints over the title. ArtworkViewer solves
|
||
* this by mounting at the app root — same idea, done by moving the
|
||
* node. Svelte's scoped classes travel with it. */
|
||
function portal(node) {
|
||
document.body.appendChild(node);
|
||
return { destroy: () => node.remove() };
|
||
}
|
||
|
||
// The loupe: both sides magnified 3× at the same spot, split down the
|
||
// middle, pixelated on purpose — the question it answers is whether a
|
||
// bigger number is real detail or upscale mush, and only true pixels
|
||
// can say. Follows the cursor; a toggle so the wipe stays undisturbed.
|
||
const LOUPE = 210;
|
||
const LOUPE_Z = 3;
|
||
let loupe = $state(false);
|
||
let loupePos = $state(null);
|
||
function loupeMove(e) {
|
||
if (!loupe) return;
|
||
let box = e.currentTarget.getBoundingClientRect();
|
||
if (fsMode === "side") {
|
||
const img = e.target.closest("img");
|
||
if (img) box = img.getBoundingClientRect();
|
||
}
|
||
// The wipe handle keeps its space: near the split line the loupe
|
||
// steps aside so the slider can be grabbed unobstructed.
|
||
if (fsMode === "wipe") {
|
||
const splitX = box.left + (box.width * fsSplit) / 100;
|
||
if (Math.abs(e.clientX - splitX) < 28) {
|
||
loupePos = null;
|
||
return;
|
||
}
|
||
}
|
||
const fx = (e.clientX - box.left) / box.width;
|
||
const fy = (e.clientY - box.top) / box.height;
|
||
if (fx < 0 || fx > 1 || fy < 0 || fy > 1) {
|
||
loupePos = null;
|
||
return;
|
||
}
|
||
loupePos = {
|
||
x: e.clientX, y: e.clientY, fx, fy,
|
||
w: box.width, h: box.height,
|
||
below: e.clientY < LOUPE + 60,
|
||
};
|
||
}
|
||
const loupeBg = (f, size, pane) => -(f * size * LOUPE_Z) + pane / 2;
|
||
|
||
function fsDrag(e) {
|
||
if (fsMode !== "wipe") return;
|
||
const stage = e.currentTarget;
|
||
const move = (ev) => {
|
||
const r = stage.getBoundingClientRect();
|
||
fsSplit = Math.min(98, Math.max(2, ((ev.clientX - r.left) / r.width) * 100));
|
||
};
|
||
move(e);
|
||
const up = () => {
|
||
window.removeEventListener("pointermove", move);
|
||
window.removeEventListener("pointerup", up);
|
||
};
|
||
window.addEventListener("pointermove", move);
|
||
window.addEventListener("pointerup", up);
|
||
}
|
||
|
||
// ---- the audio version comparison ------------------------------------
|
||
// Both play buttons play REAL local files: yours from the source folder,
|
||
// theirs from the cached audition copy — measured against measured.
|
||
const yoursPath = $derived(
|
||
a.tracks[0] ? `${sourceRoot}\\${a.rel}\\${a.tracks[0].name}` : null);
|
||
const resolvedAudio = $derived(choice?.audio ?? "yours");
|
||
|
||
// ---- preview transport ----------------------------------------------
|
||
// A preview plays through the MAIN player (playLocalFile replaces the
|
||
// queue), so "is this one playing?" is a question about the player's
|
||
// current local path — not about anything this card owns.
|
||
const nowPath = $derived(player.track?.source === "local" ? player.track.path : null);
|
||
const isLive = (p) => !!p && nowPath === p;
|
||
/** Sounding right now (buffering counts — the button must offer the stop
|
||
* before the audio arrives, or a slow start is unstoppable). */
|
||
const isSounding = (p) => isLive(p) && (player.status === "playing" || player.status === "buffering");
|
||
|
||
/** One button, both jobs: start this preview, or pause/resume it if it is
|
||
* already the thing playing. Previously every press re-issued
|
||
* playLocalFile, which restarted the track — there was no way to stop it. */
|
||
function togglePreview(path, start) {
|
||
if (!path) return;
|
||
if (isLive(path)) togglePlayPause();
|
||
else start();
|
||
}
|
||
function playYours() {
|
||
playLocalFile(
|
||
yoursPath,
|
||
a.tracks[0].name.replace(/\.[a-z0-9]+$/i, ""),
|
||
`${artist} — ${albumName ?? a.title} · yours`,
|
||
yours?.path ?? null,
|
||
);
|
||
}
|
||
function playTheirs() {
|
||
playLocalFile(
|
||
audition.track1Path,
|
||
(audition.probe?.title ?? "Track 1"),
|
||
`${artist} — ${albumName ?? a.title} · found copy`,
|
||
yours?.path ?? null,
|
||
);
|
||
}
|
||
|
||
// Leaving the record silences ITS preview. The card is re-keyed per record,
|
||
// so this fires on every move as well as on closing the window.
|
||
//
|
||
// Deliberately narrow: only when what is sounding is one of THIS record's
|
||
// two files. Music the user started before opening the importer is theirs,
|
||
// and walking through records must not silence it.
|
||
onDestroy(() => {
|
||
const mine = [yoursPath, audition?.track1Path].filter(Boolean);
|
||
if (mine.includes(nowPath) && player.status === "playing") togglePlayPause();
|
||
});
|
||
|
||
// Real peaks for both lanes, drawn against the JOINT maximum so a
|
||
// quieter master honestly looks smaller — the shapes are comparable
|
||
// because the scale is shared (his ask: match the waveforms).
|
||
let peaksYours = $state(null);
|
||
let peaksTheirs = $state(null);
|
||
$effect(() => {
|
||
const p = yoursPath;
|
||
peaksYours = null;
|
||
if (p) api.waveform(p, 56).then((v) => (peaksYours = v), () => {});
|
||
});
|
||
$effect(() => {
|
||
const p = audition?.track1Path;
|
||
peaksTheirs = null;
|
||
if (p) api.waveform(p, 56).then((v) => (peaksTheirs = v), () => {});
|
||
});
|
||
const waveScale = $derived.by(() => {
|
||
const all = [...(peaksYours ?? []), ...(peaksTheirs ?? [])];
|
||
const max = Math.max(0.001, ...all);
|
||
return (v) => `${Math.max(4, Math.round((v / max) * 92))}%`;
|
||
});
|
||
// Not while anything is unmeasured: an unmeasured cover counts as 0px,
|
||
// which would crown your copy "already the best" a second before a 1500px
|
||
// scan lands and takes the title off it.
|
||
const yoursWins = $derived(
|
||
yours && !unmeasured && yours.w >= Math.max(0, ...covers.map((c) => c.px || 0)));
|
||
const slipped = $derived(trackPairs.filter((p) => p.their && !p.aligned).length);
|
||
|
||
// What apply should WRITE for this record — resolved values, not sides,
|
||
// snapshotted into the session whenever they settle or change. A record
|
||
// with no identify yet reports nothing; untouched trusted proposals ride
|
||
// along, which is exactly decision 10's conservative default.
|
||
$effect(() => {
|
||
if (!onSnapshot) return;
|
||
// Audio: "theirs" only counts once the cached copy can actually be
|
||
// kept (at least track 1 on disk proves the transfer works). The
|
||
// expected count rides along so apply can refuse a pen that never
|
||
// filled — importing 4 of 12 tracks looks like a complete album.
|
||
const audio = resolvedAudio === "theirs" && audition?.track1Path ? "theirs" : "yours";
|
||
const audioTracks = audio === "theirs" ? (audition?.ids?.length ?? null) : null;
|
||
// Identification is a SEPARATE axis from acquisition (decision 3): a
|
||
// deep cut no provider knows still gets its version choice, so the
|
||
// snapshot cannot wait on an identify that will never land.
|
||
if (!idn || idn.state !== "done") {
|
||
// null, not an all-empty snapshot: a record with nothing resolved
|
||
// must import exactly as phase 1 did, and clearing it is how a
|
||
// flip back to "yours" undoes a keep that was never applied.
|
||
onSnapshot(audio === "theirs"
|
||
? { artUrl: null, tags: {}, trackTitles: [], audio, audioTracks }
|
||
: null);
|
||
return;
|
||
}
|
||
const artUrl = resolvedArt === "yours"
|
||
? null
|
||
: covers.find((c) => c.provider === resolvedArt)?.url ?? null;
|
||
const tags = {};
|
||
for (const [k, , theirs] of tagPairs)
|
||
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
|
||
// Built by SCAN index, never by row: apply matches `trackTitles`
|
||
// position-for-position against its own sorted file list
|
||
// (import.rs:1238), and the rows above are ordered for reading. Writing
|
||
// this in row order would land every chosen title on the wrong file —
|
||
// silently, and on the user's actual music.
|
||
const trackTitles = a.tracks.map(() => null);
|
||
for (const p of trackPairs) {
|
||
if (p.their && resolvedTrack(p.n) === "theirs") trackTitles[p.n] = p.their;
|
||
}
|
||
onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks });
|
||
});
|
||
</script>
|
||
|
||
<div class="im-hero">
|
||
<div class="im-hero-art">
|
||
{#if yoursSrc}<img src={yoursSrc} alt="" />{/if}
|
||
</div>
|
||
<div class="im-hero-t">
|
||
<div class="im-hero-kick">{index + 1} of {total}
|
||
<span class="im-out" data-out={candidate ? ev.verdict : covers.length ? "none" : "none"}>{bucket}</span>
|
||
<!-- Coming back to one you passed. Deliberately quiet rather than
|
||
amber: nothing is wrong with this record, you just haven't
|
||
said what to do with it yet. -->
|
||
{#if choice?.deferred && !choice?.seen}
|
||
<span class="im-out" data-out="later"
|
||
title="You skipped this one — still in the run, still undecided">skipped for now</span>
|
||
{/if}</div>
|
||
<!-- The RECORD, not the folder it happens to sit in. The folder is the
|
||
ripper's naming ("[WARP CD 156] Battles - Mirrored - 2007"); the
|
||
heading says what the record is, and the folder stays one hover
|
||
away for when you need to know which directory this is. -->
|
||
<h3 title={a.title === (albumName ?? a.title) ? undefined : `folder: ${a.title}`}>{albumName ?? a.title}</h3>
|
||
<small>{artist} · {a.tracks.length} tracks · {fmtMB(a.bytes)} · {ext} {a.avgBitrate ?? "—"}k</small>
|
||
</div>
|
||
<div class="im-hero-side">
|
||
<div class="im-hero-gain" class:nil={!gainLabel || gainLabel.startsWith("nothing")}>{gainLabel}</div>
|
||
<div class="im-hero-bar"><i style:width="{Math.round(((index + 1) / Math.max(1, total)) * 100)}%"></i></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="im-card">
|
||
<div class="im-card-h">Artwork
|
||
<!-- Each of these is a DIFFERENT answer, and the panel used to give
|
||
the last one for all of them: while the cover report was in
|
||
flight it said "no service has this record" over an empty frame,
|
||
then filled in with four covers a second later. -->
|
||
<em>{#if !idn || idn.state === "loading"}looking the record up…
|
||
{:else if idn.state === "error"}the lookup failed
|
||
{:else if measuring}found it — measuring what each service has…
|
||
{:else if allDead}found it, but no service has a cover for it
|
||
{:else if measureFailed}found it, but the sizes could not be measured
|
||
{:else if matchHasNoArt}the matching release carries no artwork
|
||
{:else if !covers.length}no service has this record
|
||
{:else}{@const best = Math.max(...covers.map((x) => x.px || 0))}
|
||
{covers.length} service{covers.length === 1 ? "" : "s"} answered{#if !yours} — you have none{:else if (yours?.w ?? 0) >= best}, and none beats your {yours.w}px — nothing to gain here{:else} — yours is {yours.w}px, the best of these is {best}px{/if}
|
||
{/if}</em></div>
|
||
<div class="im-art-row">
|
||
<div class="im-art {yours ? (yoursWins ? 'better' : 'worse') : ''}"
|
||
class:picked={resolvedArt === "yours"}
|
||
role="button" tabindex="0" title="Use your own artwork"
|
||
onclick={() => onChoose({ art: "yours" })}
|
||
onkeydown={(e) => e.key === "Enter" && onChoose({ art: "yours" })}>
|
||
<div class="im-art-box" class:zoom={yoursSrc || covers.length}
|
||
onclick={(e) => { e.stopPropagation(); openFs(fsPick); }}
|
||
role="presentation">
|
||
{#if yoursSrc}<img src={yoursSrc} alt="" />
|
||
{:else}<div class="im-art-none">no artwork in the folder,<br />and nothing embedded</div>{/if}
|
||
</div>
|
||
<div class="im-art-lab"><span class="im-art-res">{yours ? `${yours.w} × ${yours.h}` : "none"}</span>
|
||
<i>yours{yoursWins ? " · already the best" : ""}</i></div>
|
||
{#if yours}<div class="im-art-lab"><i>{yours.kind === "embedded" ? `embedded in ${yours.from}` : yours.from}</i></div>{/if}
|
||
{#if yours?.kind === "derived"}
|
||
<div class="im-art-lab"><i class="im-amber">the only picture in the folder — nothing is
|
||
embedded, so this thumbnail is all you have</i></div>
|
||
{:else if yours?.derivedIgnored}
|
||
<div class="im-art-lab"><i class="im-dim">{yours.derivedIgnored} Media Player thumbnail{yours.derivedIgnored === 1 ? "" : "s"} in the folder, ignored</i></div>
|
||
{/if}
|
||
<div class="im-art-pick">{resolvedArt === "yours" ? "✓ using this" : "use this"}</div>
|
||
</div>
|
||
{#each covers as c, n (c.url)}
|
||
{@const dead = !!deadArt[c.url]}
|
||
{@const smaller = yours && c.px && c.px < yours.w}
|
||
{@const wrongTitle = c.title && norm2(c.title) !== norm2(a.title)}
|
||
<!-- A candidate whose picture will not load is shown as the empty
|
||
answer it is, and cannot be chosen. The Cover Art Archive
|
||
answers 404 for a release group with no art on file, and the
|
||
URL is built from the MBID without knowing that — so the tile
|
||
used to be a broken-image icon inside a frame that still said
|
||
"use this", and using it silently fell back at apply time. -->
|
||
<div class="im-art {smaller ? 'worse' : 'better'}"
|
||
class:picked={resolvedArt === c.provider}
|
||
class:dead
|
||
role={dead ? "presentation" : "button"} tabindex={dead ? -1 : 0}
|
||
title={dead
|
||
? `${c.provider} has no cover on file for this release`
|
||
: `Use ${c.provider}'s artwork`}
|
||
onclick={() => !dead && onChoose({ art: c.provider })}
|
||
onkeydown={(e) => !dead && e.key === "Enter" && onChoose({ art: c.provider })}>
|
||
<div class="im-art-box" class:zoom={!dead}
|
||
onclick={(e) => { if (!dead) { e.stopPropagation(); openFs(n + 1); } }}
|
||
role="presentation">
|
||
{#if dead}
|
||
<div class="im-art-none">nothing on file here —<br />{c.provider} has no cover for this release</div>
|
||
{:else}
|
||
<img src={c.url} alt="" onerror={() => (deadArt[c.url] = true)} />
|
||
{/if}</div>
|
||
<div class="im-art-lab"><span class="im-art-res" class:im-dim={!c.px || dead}>{dead
|
||
? "no cover"
|
||
: c.px
|
||
? `${c.px} × ${c.realH ?? c.px}`
|
||
: measuring ? "measuring…" : "size unknown"}</span>
|
||
<i>{c.provider}{smaller && !dead ? " · smaller than yours" : ""}</i></div>
|
||
{#if wrongTitle}<div class="im-art-lab"><i class="im-amber">“{c.title}”</i></div>{/if}
|
||
<div class="im-art-pick">{dead
|
||
? "nothing to use"
|
||
: resolvedArt === c.provider ? "✓ using this" : "use this"}</div>
|
||
</div>
|
||
{/each}
|
||
<!-- Only once the lookup has actually SETTLED with nothing. While it
|
||
is still running the row carries the found cover instead. -->
|
||
{#if idn?.state === "done" && !covers.length}
|
||
<div class="im-art">
|
||
<div class="im-art-box"><div class="im-art-none">{matchHasNoArt
|
||
? "the matching release has no artwork on file"
|
||
: "no service has this record,"}{#if !matchHasNoArt}<br />so there is nothing to compare{/if}</div></div>
|
||
<div class="im-art-lab"><i>Deezer · Apple · Cover Art Archive</i></div>
|
||
</div>
|
||
{:else if !idn || idn.state === "loading"}
|
||
<div class="im-art">
|
||
<div class="im-art-box"><div class="im-art-none">looking this record up…</div></div>
|
||
<div class="im-art-lab"><i>Deezer · Apple · Cover Art Archive</i></div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="im-card">
|
||
<div class="im-card-h">Is this the same record?</div>
|
||
{#if !idn || idn.state === "loading"}
|
||
<div class="im-blank">Asking Deezer, then MusicBrainz when it has no answer…</div>
|
||
{:else if idn.state === "error"}
|
||
<div class="im-verdict warn"><div>The lookup failed — {idn.error}</div></div>
|
||
{:else}
|
||
<div class="im-ev">
|
||
{#each ev.items as it}
|
||
<span class="im-evi {it.cls}">{it.cls === "ok" ? "✓" : it.cls === "no" ? "✕" : "!"} {it.text}</span>
|
||
{/each}
|
||
{#if candidate}<span class="im-evi">{candidate.source} {String(candidate.id).replace(/^mb:/, "#")}</span>{/if}
|
||
</div>
|
||
<div class="im-verdict {verdictLine[0]}"><div>{@html verdictLine[1]}</div></div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="im-card">
|
||
<div class="im-card-h">Audio — the version you keep
|
||
<em>{#if !audition}whole album · one choice, like the artwork
|
||
{:else if audition.status === "searching"}searching the network…
|
||
{:else if audition.status === "none"}nothing on the network answered
|
||
{:else if audition.status === "failed"}the transfer failed{audition.error ? ` — ${audition.error}` : ""}
|
||
{:else if audition.status === "downloading"}{live?.stalled
|
||
? "the transfer went quiet"
|
||
: "found a copy — fetching track 1 first, so the comparison can start"}
|
||
{:else if audition.status === "complete"}the whole copy is cached — keeping it is instant
|
||
{:else}{audition.arrived ?? 1} of {audition.expected ?? audition.ids?.length ?? "…"} tracks cached · the rest keep arriving
|
||
{/if}</em>
|
||
<span class="im-card-hint">the lit version is what gets imported</span></div>
|
||
|
||
{#if !audition || audition.status === "none" || audition.status === "failed"}
|
||
<div class="im-ver" class:picked={true} role="button" tabindex="0"
|
||
onclick={() => onChoose({ audio: "yours" })}
|
||
onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}>
|
||
<button class="im-play" disabled={!yoursPath}
|
||
title={isSounding(yoursPath) ? "Pause" : "Play your copy"}
|
||
onclick={(e) => { e.stopPropagation(); togglePreview(yoursPath, playYours); }}>
|
||
<Icon name={isSounding(yoursPath) ? "pause" : "play"} size={13} />
|
||
</button>
|
||
<div class="im-ablab"><b>Yours</b><i>{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}</i></div>
|
||
<div class="im-wave">{#each (peaksYours ?? waveFloor().map((b) => 0)) as v, i (i)}<i style:height={peaksYours ? waveScale(v) : "2px"} style:--fill="0" style:--i={i}></i>{/each}</div>
|
||
<div class="im-abtime">{a.tracks[0]?.seconds ? mmss(a.tracks[0].seconds) : "—"}</div>
|
||
</div>
|
||
<div class="im-ab-find">
|
||
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
|
||
<small>{audition?.status === "none"
|
||
? audition.silent
|
||
? "Soulseek answered nothing at all — the server refuses some queries outright, and quiet spells happen. Worth asking again."
|
||
: "No peer offered this album just now — worth asking again another day."
|
||
: audition?.status === "failed"
|
||
? "The peer stopped answering — try the search again."
|
||
: "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."}</small>
|
||
</div>
|
||
{:else if audition.status === "searching"}
|
||
<div class="im-abrow resolving">
|
||
<button class="im-play" disabled>
|
||
<Icon name="play" size={13} />
|
||
</button>
|
||
<div class="im-ablab"><b>Searching…</b><i>asking the network for this album</i></div>
|
||
<div class="im-wave pending">{#each waveFloor() as b (b.i)}<i style:height={b.h} style:--fill="0" style:--i={b.i}></i>{/each}</div>
|
||
<div class="im-abtime">—</div>
|
||
</div>
|
||
{:else}
|
||
{@const au = audition}
|
||
{@const theirsReady = !!au.track1Path}
|
||
<div class="im-vers">
|
||
<div class="im-ver" class:picked={resolvedAudio === "yours"} role="button" tabindex="0"
|
||
title="Keep your own files"
|
||
onclick={() => onChoose({ audio: "yours" })}
|
||
onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}>
|
||
<button class="im-play" disabled={!yoursPath}
|
||
title={isSounding(yoursPath) ? "Pause" : "Play your copy"}
|
||
onclick={(e) => { e.stopPropagation(); togglePreview(yoursPath, playYours); }}>
|
||
<Icon name={isSounding(yoursPath) ? "pause" : "play"} size={13} />
|
||
</button>
|
||
<div class="im-ablab"><b>Yours</b><i>{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}</i></div>
|
||
<div class="im-wave">{#each (peaksYours ?? []) as v, i (i)}<i style:height={waveScale(v)} style:--fill="0" style:--i={i}></i>{/each}</div>
|
||
<div class="im-abtime">{a.tracks[0]?.seconds ? mmss(a.tracks[0].seconds) : "—"}</div>
|
||
<div class="im-ver-pick">{resolvedAudio === "yours" ? "✓ keeping this" : "keep this"}</div>
|
||
</div>
|
||
<!-- Not ready: a flat waveform of a file that does not exist yet
|
||
is a lie with a shape. The lane carries the transfer's own
|
||
words instead, and the real peaks take it over the moment
|
||
there are real peaks to draw. -->
|
||
<div class="im-ver" class:picked={resolvedAudio === "theirs"} role="button" tabindex="0"
|
||
class:pending={!theirsReady}
|
||
class:stalled={!theirsReady && live?.stalled}
|
||
title={theirsReady ? "Replace the album with this copy" : "Track 1 is still arriving"}
|
||
onclick={() => theirsReady && onChoose({ audio: "theirs" })}
|
||
onkeydown={(e) => e.key === "Enter" && theirsReady && onChoose({ audio: "theirs" })}>
|
||
<button class="im-play" disabled={!theirsReady}
|
||
title={!theirsReady
|
||
? "Still arriving"
|
||
: isSounding(au.track1Path) ? "Pause" : "Play the found copy"}
|
||
onclick={(e) => { e.stopPropagation(); togglePreview(au.track1Path, playTheirs); }}>
|
||
<Icon name={isSounding(au.track1Path) ? "pause" : "play"} size={13} />
|
||
</button>
|
||
<!-- "Found copy", to match "Yours" on the lane above. This
|
||
used to lead with the PEER's folder name ("[WARP 156CD]
|
||
Battles …"), which is the same rip-descriptor noise the
|
||
heading carried — and in a 150px label it truncated to
|
||
nothing useful anyway. Which peer and which folder is what
|
||
the sub-line and the hover are for. -->
|
||
<div class="im-ablab" title={`${au.candidate.folderLeaf} · ${au.candidate.username}`}>
|
||
<b>Found copy</b>
|
||
<i>{au.probe
|
||
? `${(au.candidate.files[0]?.name ?? "").split(".").pop().toUpperCase()} ${au.probe.bitrate ?? "—"}k${au.probe.bitDepth ? ` · ${au.probe.bitDepth}-bit` : ""} · measured`
|
||
: theirsReady
|
||
? `${au.candidate.avgBitrate ? au.candidate.avgBitrate + "k · " : ""}${au.candidate.trackCount} tracks · ${fmtMB(au.candidate.bytes)} · ${au.candidate.username}`
|
||
: au.candidate.username}</i>
|
||
</div>
|
||
{#if theirsReady && peaksTheirs}
|
||
<div class="im-wave">{#each peaksTheirs as v, i (i)}<i style:height={waveScale(v)} style:--fill="0" style:--i={i}></i>{/each}</div>
|
||
{:else if theirsReady}
|
||
<div class="im-wave pending">{#each waveFloor() as b (b.i)}<i style:height={b.h} style:--fill="0" style:--i={b.i}></i>{/each}</div>
|
||
{:else}
|
||
<div class="im-arriv">
|
||
<span class="im-arriv-t">{arrivalLine(live, au)}</span>
|
||
<span class="im-rail" class:indet={arrivPct == null} class:dead={live?.stalled}>
|
||
<i style:width={arrivPct != null ? `${arrivPct}%` : null}></i>
|
||
</span>
|
||
</div>
|
||
{/if}
|
||
<div class="im-abtime">{au.probe?.seconds ? mmss(au.probe.seconds) : "—"}</div>
|
||
<div class="im-ver-pick">{resolvedAudio === "theirs"
|
||
? "✓ keeping this"
|
||
: theirsReady ? "keep this" : live?.stalled ? "stalled" : "arriving…"}</div>
|
||
</div>
|
||
</div>
|
||
<div class="im-abnote">
|
||
<!-- Track 1 landing is the moment the card becomes usable, and
|
||
nothing used to say so — the pill changed and that was all. -->
|
||
{#if !theirsReady && live?.stalled}
|
||
<b class="im-amber">Nothing has arrived for {Math.round(live.quietMs / 1000)}s.</b>
|
||
The peer may have gone offline or dropped the queue. You can wait, try
|
||
another peer, or keep your own copy — nothing is lost either way.
|
||
{:else if !theirsReady}
|
||
Track 1 is asked for on its own, so it is not queued behind the other
|
||
{au.candidate.trackCount - 1} files of its own album.{#if au.switchedFrom}
|
||
· Moved on from <b>{au.switchedFrom}</b>{au.reason ? ` — ${au.reason}` : ""}.
|
||
{/if}
|
||
{:else if au.probe?.seconds && a.tracks[0]?.seconds}
|
||
{@const d = Math.abs(a.tracks[0].seconds - au.probe.seconds)}
|
||
{#if d <= 2}
|
||
<b class="im-ready">Ready to compare.</b>
|
||
Track 1 is the same length in both ({mmss(au.probe.seconds)}) — the strongest signal it is the same recording. The waveforms share one scale, so shape and loudness compare honestly.{#if au.status !== "complete"} The other {Math.max(0, (au.expected ?? au.ids?.length ?? 0) - (au.arrived ?? 0))} tracks are still coming; keeping this version waits for them.{/if}
|
||
{:else}
|
||
<b class="im-amber">Track 1 differs by {d}s</b> — different recording, different edit, or a different record. Listen before keeping it.
|
||
{/if}
|
||
{:else if au.candidate.countMatch === false}
|
||
<b class="im-amber">{au.candidate.trackCount} tracks against your {a.tracks.length}</b> — this copy may be a different edition or incomplete.
|
||
{:else}
|
||
The whole album downloads into the cache — if you keep it, nothing is fetched twice; if you keep yours, the sweep reclaims it in a week.
|
||
{/if}
|
||
</div>
|
||
<!-- Reachable WHILE a peer is loading. There was no way out of a bad
|
||
peer once one was chosen: the only "Find a better copy" button
|
||
lives in the no-audition state, so a queue of 277 could only be
|
||
waited out. Alternates are already ranked and held, so switching
|
||
costs no new search. -->
|
||
{#if !theirsReady}
|
||
{@const alts = au.alts?.length ?? 0}
|
||
<div class="im-ab-find">
|
||
<button class="pill ghost" onclick={alts ? onTryAnother : onFindCopy}>
|
||
{alts ? "Try another peer" : "Search again"}
|
||
</button>
|
||
<small>{alts
|
||
? `${alts} more ${alts === 1 ? "copy" : "copies"} ranked behind this one${au.candidate.queue ? ` — this peer has ${au.candidate.queue} ahead of you` : ""}. Switching cancels this download; nothing already cached is lost.`
|
||
: "No ranked alternates left for this copy — this asks the network again."}</small>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
{#if candidate}
|
||
<div class="im-card">
|
||
<div class="im-card-h">Track titles <em>{a.tracks.length} yours · {candidate.tracklist.length} theirs</em>
|
||
<span class="im-card-hint">pick a side per track</span></div>
|
||
{#if a.tracks.length !== candidate.tracklist.length || slipped}
|
||
<div class="im-tl-warn">
|
||
{#if a.tracks.length !== candidate.tracklist.length}<b>Different track counts</b> — {a.tracks.length} against {candidate.tracklist.length}.{/if}
|
||
{#if slipped}<b>{slipped} track{slipped === 1 ? "" : "s"}</b> {slipped === 1 ? "does" : "do"} not match on length.{/if}
|
||
These are paired by position because the provider sends no track
|
||
numbers, so the rows below may be lined up against the wrong songs.
|
||
</div>
|
||
{:else}
|
||
<div class="im-tl-ok">Every track pairs with one of the same length, so the rows below line up.</div>
|
||
{/if}
|
||
<div class="im-tl">
|
||
<div class="im-tl-h"></div>
|
||
<div class="im-tl-h">yours</div>
|
||
<div class="im-tl-h">{candidate.source}</div>
|
||
<div class="im-tl-h"></div>
|
||
{#each trackPairs as p (p.n)}
|
||
<div class="im-tl-n">{p.row + 1}</div>
|
||
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "yours"}
|
||
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
|
||
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}
|
||
class:empty={!p.their} disabled={!p.their}
|
||
onclick={() => onChoose({ tracks: { [p.n]: "theirs" } })}
|
||
>{p.their ?? "— nothing at this position"}</button>
|
||
<div class="im-tl-d" class:off={p.their && !p.aligned}
|
||
title={p.their ? `yours ${mmss(p.dY ?? 0)} · theirs ${mmss(p.dT ?? 0)}` : ""}
|
||
>{p.their ? (p.aligned ? mmss(p.dT) : `${p.drift ?? "?"}s off`) : ""}</div>
|
||
{/each}
|
||
</div>
|
||
<div class="im-tl-bulk">
|
||
<button class="pill ghost" onclick={() =>
|
||
onChoose({ tracks: Object.fromEntries(trackPairs.map((p) => [p.n, "yours"])) })}>Keep all mine</button>
|
||
<button class="pill ghost" onclick={() =>
|
||
onChoose({ tracks: Object.fromEntries(trackPairs.filter((p) => p.their).map((p) => [p.n, "theirs"])) })}>Take all theirs</button>
|
||
{#if trackChanges}<span class="im-tl-note">{trackChanges} title{trackChanges === 1 ? "" : "s"} will be replaced.</span>
|
||
{:else}<span class="im-tl-note">Keeping all of your titles.</span>{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="im-card">
|
||
<div class="im-card-h">Tags <em>pick a side per field</em>
|
||
<span class="im-card-hint">the lit one is what gets written</span></div>
|
||
<div class="im-tags">
|
||
<div class="im-tags-h"></div><div class="im-tags-h">yours</div>
|
||
<div class="im-tags-h">{candidate.source}</div>
|
||
{#each tagPairs as [k, mine, theirs] (k)}
|
||
{@const same = mine && theirs && norm2(mine) === norm2(theirs)}
|
||
<div class="im-tag-k">{k}</div>
|
||
<button class="im-tag-side" class:on={resolvedTag(k) === "yours"} class:empty={!mine}
|
||
disabled={!mine}
|
||
title={mine ? "Keep what your file says" : "Your file has no value for this"}
|
||
onclick={() => onChoose({ tags: { [k]: "yours" } })}>{mine ?? "— empty"}</button>
|
||
<button class="im-tag-side" class:on={resolvedTag(k) === "theirs"} class:empty={!theirs}
|
||
disabled={!theirs}
|
||
title={theirs ? "Take the provider's value" : "The provider did not supply this"}
|
||
onclick={() => onChoose({ tags: { [k]: "theirs" } })}>{theirs ?? "— not supplied"}{#if same} <i>same</i>{/if}</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{:else if idn?.state === "done"}
|
||
<div class="im-card">
|
||
<div class="im-card-h">Tags</div>
|
||
<div class="im-blank">This record has not been matched, so there is nothing to choose
|
||
between. What your files say is in the summary below.</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="im-card im-composed">
|
||
<div class="im-card-h">What gets imported
|
||
<em>{changeCount ? `${changeCount} change${changeCount === 1 ? "" : "s"} from what you have` : "everything of yours, unchanged"}</em>
|
||
{#if choice?.seen}<button class="pill ghost im-card-act" onclick={onReset}>Reset to proposed</button>{/if}</div>
|
||
<div class="im-comp">
|
||
<div class="im-comp-k">Artwork</div>
|
||
<div class="im-comp-v" class:changed={resolvedArt !== "yours"}>
|
||
{resolvedArt === "yours"
|
||
? (yours ? `yours · ${yours.w} × ${yours.h}` : "none — a cover will be generated")
|
||
: `${resolvedArt} · ${covers.find((c) => c.provider === resolvedArt)?.px ?? "?"}px`}</div>
|
||
{#each tagPairs as [k, mine, theirs] (k)}
|
||
{@const side = resolvedTag(k)}
|
||
{@const v = side === "theirs" ? theirs : mine}
|
||
{#if v}
|
||
<div class="im-comp-k">{k}</div>
|
||
<div class="im-comp-v" class:changed={side === "theirs"}>{v}</div>
|
||
{/if}
|
||
{/each}
|
||
<div class="im-comp-k">Track titles</div>
|
||
<div class="im-comp-v" class:changed={trackChanges > 0}>
|
||
{trackChanges ? `${trackChanges} of ${a.tracks.length} replaced` : `all ${a.tracks.length} kept as yours`}</div>
|
||
<div class="im-comp-k">Audio</div>
|
||
<div class="im-comp-v" class:changed={resolvedAudio === "theirs"}>
|
||
{resolvedAudio === "theirs" && audition?.candidate
|
||
? `the found copy — ${audition.candidate.folderLeaf} (${audition.candidate.avgBitrate ? audition.candidate.avgBitrate + "k, " : ""}${audition.candidate.trackCount} tracks)`
|
||
: `${ext} ${a.avgBitrate ?? "—"}k · unchanged`}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{#if fs}
|
||
{@const right = fsRight}
|
||
<!-- The viewer is portalled to <body>, so it left the window's accent
|
||
scope behind and wore the app's blue over someone else's artwork.
|
||
The pair rides across explicitly; custom properties inherit through
|
||
`display: contents`, so everything inside picks it up. -->
|
||
<div class="im-fs-portal" use:portal style={accentStyle}>
|
||
{#if !right || !yoursSrc}
|
||
<!-- one image: the plain viewer — your own artwork full size, or the
|
||
provider's, with no controls that would compare it to nothing -->
|
||
<div class="im-fs" data-solo="1">
|
||
<img class="im-fs-bg" src={yoursSrc ?? right?.url} alt="" />
|
||
<div class="im-fs-shade"></div>
|
||
<div class="im-fs-top">
|
||
<div class="im-fs-title">{a.title}<small>{artist}</small></div>
|
||
</div>
|
||
<div class="im-fs-close" role="button" tabindex="0" aria-label="Close"
|
||
onclick={() => (fs = false)}
|
||
onkeydown={(e) => e.key === "Enter" && (fs = false)}>⤡</div>
|
||
<div class="im-fs-stage">
|
||
<img class="im-fs-a" src={yoursSrc ?? right?.url} alt="" />
|
||
</div>
|
||
<div class="im-fs-meta">
|
||
{yoursSrc
|
||
? `yours · ${yours?.w} × ${yours?.h}${yours?.kind === "embedded" ? " · embedded" : ""}`
|
||
: `${right?.provider} · ${right?.px ? `${right.px} × ${right.realH ?? right.px}` : "size not measured yet"}`}
|
||
{#if yoursSrc && !covers.length} · <span class="im-dim">no service has this record — nothing to compare against</span>
|
||
{:else if !yoursSrc} · <span class="im-dim">you have no artwork for this record</span>{/if}
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="im-fs" data-mode={fsMode} data-show={fsShow} style:--split="{fsSplit}%">
|
||
<img class="im-fs-bg" src={right.url} alt="" />
|
||
<div class="im-fs-shade"></div>
|
||
<div class="im-fs-top">
|
||
<div class="im-fs-title">{a.title}<small>{artist}</small></div>
|
||
<div class="im-fs-modes">
|
||
<button aria-selected={fsMode === "wipe"} onclick={() => (fsMode = "wipe")}>Slider</button>
|
||
<button aria-selected={fsMode === "side"} onclick={() => (fsMode = "side")}>Side by side</button>
|
||
<button aria-selected={fsMode === "toggle"} onclick={() => (fsMode = "toggle")}>Flip</button>
|
||
<button aria-selected={loupe}
|
||
title="Hover the artwork to inspect the same spot on both sides at 3×, true pixels"
|
||
onclick={() => { loupe = !loupe; if (!loupe) loupePos = null; }}>Magnify</button>
|
||
</div>
|
||
</div>
|
||
<div class="im-fs-close" role="button" tabindex="0" aria-label="Close"
|
||
onclick={() => (fs = false)}
|
||
onkeydown={(e) => e.key === "Enter" && (fs = false)}>⤡</div>
|
||
<div class="im-fs-stage" role="presentation" class:magnify={loupe}
|
||
onpointerdown={fsDrag}
|
||
onmousemove={loupeMove}
|
||
onmouseleave={() => (loupePos = null)}
|
||
onclick={() => { if (fsMode === "toggle") fsShow = fsShow === "a" ? "b" : "a"; }}>
|
||
<img class="im-fs-a" src={yoursSrc} alt="" />
|
||
<img class="im-fs-b" src={right.url} alt="" />
|
||
<div class="im-fs-handle"></div>
|
||
</div>
|
||
{#if loupe && loupePos}
|
||
{@const bs = `${loupePos.w * LOUPE_Z}px ${loupePos.h * LOUPE_Z}px`}
|
||
{@const bx = loupeBg(loupePos.fx, loupePos.w, LOUPE / 2)}
|
||
{@const by = loupeBg(loupePos.fy, loupePos.h, LOUPE)}
|
||
<div class="im-loupe" class:below={loupePos.below}
|
||
style:left="{loupePos.x}px" style:top="{loupePos.y}px"
|
||
style:width="{LOUPE}px" style:height="{LOUPE}px"
|
||
transition:fade={{ duration: 130 }}>
|
||
<div class="im-loupe-box">
|
||
<div class="im-loupe-half"
|
||
style:background-image="url({yoursSrc})"
|
||
style:background-size={bs}
|
||
style:background-position="{bx}px {by}px"></div>
|
||
<div class="im-loupe-half"
|
||
style:background-image="url({right.url})"
|
||
style:background-size={bs}
|
||
style:background-position="{bx}px {by}px"></div>
|
||
<span class="im-loupe-lab l">yours</span>
|
||
<span class="im-loupe-lab r">{right.provider}</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if fsMode === "side"}
|
||
<div class="im-fs-side-lab l">yours · {yours?.w}px</div>
|
||
<div class="im-fs-side-lab r">{right.provider} · {right.px}px</div>
|
||
{/if}
|
||
<div class="im-fs-meta">
|
||
yours {yours?.w} × {yours?.h}{yours?.kind === "embedded" ? " · embedded" : ""}
|
||
vs {right.provider} {right.px} × {right.realH ?? right.px}
|
||
</div>
|
||
<div class="im-fs-bot">
|
||
{#each covers.filter((c) => !deadArt[c.url]) as c, n (c.url)}
|
||
<div class="im-fs-pick" role="button" tabindex="0"
|
||
aria-selected={fsPick === n + 1}
|
||
onclick={() => (fsPick = n + 1)}
|
||
onkeydown={(e) => e.key === "Enter" && (fsPick = n + 1)}>
|
||
<img src={c.url} alt="" />
|
||
<div><b>{c.provider}</b><i>{c.px} × {c.realH ?? c.px}</i></div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
/* import.css's picker section, verbatim where the app's tokens allow. */
|
||
.im-out {
|
||
flex: none; align-self: center;
|
||
font-size: 10px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase;
|
||
padding: 4px 9px; border-radius: 999px; white-space: nowrap;
|
||
background: rgba(255, 255, 255, 0.06); color: var(--text-secondary);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
||
}
|
||
.im-out[data-out="ok"] { background: rgba(72, 199, 116, 0.16); color: #7fe0a4; box-shadow: inset 0 0 0 1px rgba(72, 199, 116, 0.35); }
|
||
.im-out[data-out="warn"] { background: rgba(233, 185, 73, 0.16); color: #f0cd77; box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.35); }
|
||
.im-out[data-out="bad"] { background: rgba(232, 92, 92, 0.16); color: #f19292; box-shadow: inset 0 0 0 1px rgba(232, 92, 92, 0.35); }
|
||
.im-out[data-out="later"] {
|
||
background: rgba(255, 255, 255, 0.05); color: var(--text-muted);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
|
||
}
|
||
|
||
.im-hero { position: relative; z-index: 1; display: flex; align-items: center; gap: 18px; margin-bottom: 18px; }
|
||
.im-hero-art {
|
||
width: 84px; height: 84px; flex: none;
|
||
border-radius: var(--radius); overflow: hidden;
|
||
box-shadow: 0 18px 38px -14px rgba(0, 0, 0, 0.7), inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||
background: var(--bg-highlight);
|
||
}
|
||
.im-hero-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.im-hero-t { flex: 1; min-width: 0; }
|
||
.im-hero-kick {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 11px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase;
|
||
color: var(--text-muted); margin-bottom: 4px;
|
||
}
|
||
.im-hero-t h3 {
|
||
margin: 0; font-size: 26px; font-weight: 800; letter-spacing: -0.02em;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.im-hero-t small { display: block; font-size: 13px; color: var(--text-secondary); margin-top: 3px; }
|
||
.im-hero-side { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 7px; }
|
||
.im-hero-gain {
|
||
font-size: 11.5px; font-weight: 700; color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||
}
|
||
.im-hero-gain.nil { color: var(--text-muted); font-weight: 500; font-style: italic; }
|
||
.im-hero-bar { width: 190px; height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.10); overflow: hidden; }
|
||
.im-hero-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
|
||
|
||
.im-card {
|
||
border-radius: var(--radius);
|
||
background: rgba(255, 255, 255, 0.03);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||
padding: 16px; margin-bottom: 12px;
|
||
position: relative; z-index: 1;
|
||
}
|
||
.im-card-h {
|
||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; row-gap: 4px;
|
||
font-size: 11px; font-weight: 800; letter-spacing: 0.1em;
|
||
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px;
|
||
}
|
||
.im-card-h::after { content: ""; order: 1; flex: 1; height: 1px; background: rgba(255, 255, 255, 0.10); margin-left: 4px; }
|
||
.im-card-h em { font-style: normal; color: var(--text-secondary); font-weight: 700; letter-spacing: 0; text-transform: none; font-size: 12px; }
|
||
.im-card-hint { order: 2; flex: none; margin-left: 8px; font-size: 11px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
|
||
.im-card-act { order: 2; flex: none; margin-left: 8px; font-size: 11px; padding: 4px 10px; }
|
||
|
||
.im-art-row { display: flex; align-items: stretch; gap: 16px; flex-wrap: wrap; }
|
||
.im-art {
|
||
flex: none; width: 276px; cursor: pointer;
|
||
display: flex; flex-direction: column;
|
||
padding: 8px; border-radius: 12px;
|
||
box-shadow: inset 0 0 0 1px transparent;
|
||
transition: background 120ms ease, box-shadow 120ms ease;
|
||
}
|
||
.im-art:hover { background: rgba(255, 255, 255, 0.04); }
|
||
.im-art:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.im-art.picked {
|
||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||
}
|
||
.im-art-pick {
|
||
margin-top: auto; font-size: 10px; font-weight: 800;
|
||
letter-spacing: 0.06em; text-transform: uppercase;
|
||
color: var(--text-muted); text-align: center;
|
||
padding: 6px 0; border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
.im-art.picked .im-art-pick { background: var(--accent); color: #000; }
|
||
.im-art-box {
|
||
width: 260px; height: 260px; border-radius: 8px; overflow: hidden;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
|
||
position: relative;
|
||
}
|
||
.im-art-box img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.im-art-lab { display: flex; align-items: baseline; gap: 6px; margin-top: 7px; font-size: 12px; font-weight: 700; }
|
||
.im-art-lab i { font-style: normal; color: var(--text-muted); font-weight: 400; font-size: 11.5px; }
|
||
.im-art-res { font-variant-numeric: tabular-nums; }
|
||
.im-art.better .im-art-res { color: var(--accent-hover); }
|
||
.im-art.worse .im-art-res { color: #e9b949; }
|
||
/* Not an option, so it does not look like one: no pointer, no hover lift,
|
||
and the size line stays muted rather than reading as a measurement. */
|
||
.im-art.dead { cursor: default; }
|
||
.im-art.dead:hover { background: none; }
|
||
.im-art.dead .im-art-res,
|
||
.im-art.dead .im-art-pick { color: var(--text-muted); }
|
||
.im-art-none { display: grid; place-items: center; height: 100%; color: var(--text-muted); font-size: 12px; text-align: center; padding: 12px; }
|
||
|
||
.im-ev { display: flex; flex-wrap: wrap; gap: 7px; }
|
||
.im-evi {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 4px 9px; border-radius: 999px;
|
||
font-size: 11.5px; font-weight: 700;
|
||
background: rgba(255, 255, 255, 0.05); color: var(--text-secondary);
|
||
}
|
||
.im-evi.ok { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent-hover); }
|
||
.im-evi.no { background: rgba(233, 90, 90, 0.16); color: #e95a5a; }
|
||
.im-evi.warn { background: rgba(233, 185, 73, 0.16); color: #e9b949; }
|
||
.im-verdict {
|
||
margin-top: 12px; padding: 11px 13px; border-radius: var(--radius);
|
||
font-size: 12.5px; line-height: 1.45;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.im-verdict.bad { background: rgba(233, 90, 90, 0.1); color: #f0a0a0; box-shadow: inset 0 0 0 1px rgba(233, 90, 90, 0.25); }
|
||
.im-verdict.warn { background: rgba(233, 185, 73, 0.1); color: #edd08a; box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.22); }
|
||
.im-verdict.ok { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text-secondary); }
|
||
.im-verdict :global(b) { color: #fff; }
|
||
|
||
.im-tags { display: grid; grid-template-columns: 96px 1fr 1fr; gap: 6px 10px; align-items: center; }
|
||
.im-tags-h, .im-tl-h {
|
||
font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
}
|
||
.im-tag-k { font-size: 12px; color: var(--text-muted); font-weight: 700; }
|
||
.im-tag-side {
|
||
font-size: 12.5px; color: var(--text-secondary); text-align: left;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 6px; padding: 6px 9px; cursor: pointer;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
font-family: inherit;
|
||
}
|
||
.im-tag-side:hover:not([disabled]) { background: rgba(255, 255, 255, 0.08); }
|
||
.im-tag-side.on {
|
||
color: var(--text-primary);
|
||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||
}
|
||
.im-tag-side i { font-style: normal; color: var(--text-muted); font-size: 11px; }
|
||
.im-tag-side.empty, .im-tag-side[disabled] {
|
||
color: var(--text-muted); background: none; border-style: dashed;
|
||
border-color: rgba(255, 255, 255, 0.10); cursor: default;
|
||
}
|
||
|
||
.im-comp { display: grid; grid-template-columns: 96px 1fr; gap: 6px 12px; align-items: center; }
|
||
.im-comp-k { font-size: 12px; color: var(--text-muted); font-weight: 700; }
|
||
.im-comp-v { font-size: 12.5px; color: var(--text-primary); }
|
||
.im-comp-v.changed { color: var(--accent-hover); font-weight: 600; }
|
||
.im-composed { background: rgba(255, 255, 255, 0.025); }
|
||
.im-blank { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; padding: 4px 0; }
|
||
|
||
.im-abrow { display: flex; align-items: center; gap: 14px; }
|
||
/* The two VERSION tiles — the artwork tiles' frame language, laid as
|
||
rows: a picked frame is the version that gets imported. */
|
||
.im-vers { display: flex; flex-direction: column; gap: 10px; }
|
||
.im-ver {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 11px 14px; border-radius: 12px; cursor: pointer;
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
|
||
transition: background 120ms ease, box-shadow 120ms ease;
|
||
}
|
||
.im-ver:hover { background: rgba(255, 255, 255, 0.04); }
|
||
.im-ver:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.im-ver.picked {
|
||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||
}
|
||
.im-ver.pending { cursor: default; opacity: 0.85; }
|
||
.im-ver-pick {
|
||
flex: none; font-size: 10px; font-weight: 800;
|
||
letter-spacing: 0.06em; text-transform: uppercase;
|
||
color: var(--text-muted); text-align: center;
|
||
padding: 5px 10px; border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
white-space: nowrap;
|
||
}
|
||
.im-ver.picked .im-ver-pick { background: var(--accent); color: #000; }
|
||
/* The find affordance under a lone "yours" row. */
|
||
.im-ab-find { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||
.im-ab-find small { color: var(--text-muted); font-size: 11.5px; line-height: 1.45; max-width: 64ch; }
|
||
/* Play: the app's one play control at this card's size — filled accent,
|
||
black glyph, no ring, the same object as .hero-play (50px), .m-play
|
||
(46px) and .hoverplay (34px). The ghost circle this card used to draw
|
||
was its own invention and read as a different kind of button.
|
||
`padding: 0` is load-bearing: global.css never zeroes Chromium's
|
||
default `1px 6px` on <button>, and with only a padding-LEFT set the
|
||
glyph centred inside a box 6px narrower on the right — 2px off, which
|
||
is exactly what it looked like. */
|
||
.im-play {
|
||
display: grid; place-items: center;
|
||
width: 30px; height: 30px; flex: 0 0 auto;
|
||
padding: 0; border: none;
|
||
border-radius: 50%;
|
||
background: var(--accent); color: #000;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
|
||
cursor: pointer;
|
||
transition: background 140ms ease, scale 140ms ease, opacity 140ms ease;
|
||
}
|
||
/* No optical nudge: the app's own play polygon already leans a unit right
|
||
inside its 24-box, which is the whole correction a box-centred triangle
|
||
needs. Chosen on the 4× loupe (research/import/play-button.html). */
|
||
.im-play:hover:not([disabled]) { background: var(--accent-hover); scale: 1.06; }
|
||
.im-play:focus-visible { outline: 2px solid var(--accent-hover); outline-offset: 3px; }
|
||
/* Disabled must not wear the accent — a lit disc beside "still arriving"
|
||
promises something that cannot happen yet. */
|
||
.im-play[disabled] {
|
||
background: rgba(255, 255, 255, 0.07); color: var(--text-muted);
|
||
box-shadow: none; cursor: default;
|
||
}
|
||
.im-ablab { flex: none; width: 150px; min-width: 0; }
|
||
.im-ablab b { display: block; font-size: 12.5px; font-weight: 700; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.im-ablab i { display: block; font-style: normal; font-size: 11px; font-weight: 400; color: var(--text-muted); line-height: 1.35; margin-top: 2px; }
|
||
.im-wave { flex: 1; min-width: 0; display: flex; align-items: end; justify-content: center; gap: var(--bar-gap, 4px); height: 38px; }
|
||
.im-wave i {
|
||
flex: 0 0 2px; width: 2px;
|
||
border-radius: 1px; min-height: 2px;
|
||
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
|
||
}
|
||
.im-wave.pending i { opacity: 0.4; }
|
||
/* Where the waveform will go, while there is no waveform to draw. Same
|
||
lane, same height, so nothing shifts when the real peaks arrive. */
|
||
.im-arriv {
|
||
flex: 1; min-width: 0; height: 38px;
|
||
display: flex; flex-direction: column; justify-content: center; gap: 7px;
|
||
}
|
||
.im-arriv-t {
|
||
font-size: 11.5px; color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.im-ver.stalled .im-arriv-t { color: var(--amber, #f0cd77); }
|
||
.im-rail {
|
||
display: block; height: 4px; border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.10); overflow: hidden;
|
||
}
|
||
.im-rail i {
|
||
display: block; height: 100%; width: 0;
|
||
background: var(--accent); border-radius: 999px;
|
||
transition: width 400ms linear;
|
||
}
|
||
/* Nothing to measure yet — connecting, or sitting in someone's queue. A
|
||
travelling sliver says "waiting" without inventing a percentage. */
|
||
.im-rail.indet i { width: 34%; animation: im-slide 1500ms ease-in-out infinite; }
|
||
@keyframes im-slide {
|
||
from { margin-left: -34%; }
|
||
to { margin-left: 100%; }
|
||
}
|
||
/* Stalled: the rail stops moving on purpose. Motion here would say the
|
||
transfer is alive, which is the exact thing it is not. */
|
||
.im-rail.dead i { animation: none; width: 100%; background: rgba(233, 185, 73, 0.4); }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.im-rail.indet i { animation: none; width: 100%; opacity: 0.5; }
|
||
}
|
||
.im-ready { color: var(--accent-hover); font-weight: 800; }
|
||
/* Resolving: the playbar's loading swell, one crest travelling the lane. */
|
||
.im-abrow.resolving .im-wave i {
|
||
animation: im-swell 2500ms linear infinite;
|
||
animation-delay: calc(-2500ms + 2000ms * var(--i) / 56);
|
||
}
|
||
@keyframes im-swell {
|
||
0%, 26%, 100% { height: 2px; opacity: 0.45; }
|
||
13% { height: 30%; opacity: 0.85; }
|
||
}
|
||
/* Arrival: the whole waveform eases in as one motion. */
|
||
.im-abrow.played .im-wave i { transition: height 1000ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.im-abrow.resolving .im-wave i { animation: none; opacity: 0.45; }
|
||
}
|
||
.im-abtime { flex: none; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.im-abnote { font-size: 12px; color: var(--text-muted); line-height: 1.45; margin-top: 12px; }
|
||
|
||
.im-tl { display: grid; grid-template-columns: 22px 1fr 1fr 66px; gap: 5px 10px; align-items: center; font-size: 12px; }
|
||
.im-tl-n { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.im-tl-d { color: var(--text-muted); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
|
||
.im-tl-d.off { color: #e9b949; font-weight: 700; }
|
||
.im-tl-warn, .im-tl-ok { font-size: 12px; line-height: 1.5; margin-bottom: 11px; padding: 9px 12px; border-radius: 8px; }
|
||
.im-tl-warn { color: var(--text-secondary); background: rgba(233, 185, 73, 0.10); box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.32); }
|
||
.im-tl-warn b { color: #f0cd77; }
|
||
.im-tl-ok { color: var(--text-muted); background: rgba(255, 255, 255, 0.03); }
|
||
.im-tl-bulk { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||
.im-tl-note { font-size: 11.5px; color: var(--text-muted); }
|
||
|
||
.im-amber { color: #f0cd77; }
|
||
.im-dim { opacity: 0.75; }
|
||
|
||
/* ---- full-screen artwork comparison (import.css, ArtworkViewer's
|
||
surface): the same #0b0b0e bed, blurred ambient at -6%/112%, 38px
|
||
round close — plus the comparison. Fixed over the whole app window,
|
||
exactly as the artwork lightbox behaves. */
|
||
.im-art-box.zoom { cursor: zoom-in; }
|
||
.im-fs-portal { display: contents; }
|
||
.im-fs {
|
||
/* ArtworkViewer's surface, layer for layer: fixed over the whole
|
||
window at z 100 — above the titlebar chrome, like the lightbox. */
|
||
position: fixed; inset: 0; z-index: 100;
|
||
overflow: hidden;
|
||
background: #0b0b0e;
|
||
display: grid; place-items: center;
|
||
animation: im-fs-in 200ms ease both;
|
||
/* The window's crossfade, carried inside: open the viewer before the
|
||
cover has been sampled and the tint arrives as a fade, not a cut. */
|
||
transition: --accent 900ms ease, --accent-hover 900ms ease;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.im-fs { transition: none; }
|
||
}
|
||
@keyframes im-fs-in { from { opacity: 0; } }
|
||
.im-fs-bg {
|
||
position: absolute; inset: -6%; width: 112%; height: 112%;
|
||
object-fit: cover; transform: scale(1.06);
|
||
filter: blur(38px) saturate(1.4) brightness(0.5);
|
||
}
|
||
.im-fs-shade {
|
||
position: absolute; inset: 0;
|
||
background: radial-gradient(120% 90% at 50% 45%, transparent 40%, rgba(6, 6, 9, 0.5) 100%);
|
||
}
|
||
.im-fs-close {
|
||
position: absolute; top: 18px; right: 18px; z-index: 6;
|
||
width: 38px; height: 38px; border-radius: 50%;
|
||
display: grid; place-items: center;
|
||
color: #fff; background: rgba(10, 10, 14, 0.55);
|
||
backdrop-filter: blur(10px); cursor: pointer;
|
||
}
|
||
.im-fs-close:hover { background: rgba(10, 10, 14, 0.85); transform: scale(1.06); }
|
||
.im-fs-stage {
|
||
position: relative; z-index: 2;
|
||
/* ArtworkViewer gives artwork 94vh; the comparison keeps only the
|
||
room its bottom chrome (meta pill + source strip) actually needs. */
|
||
width: min(82vh, 88vw); aspect-ratio: 1;
|
||
border-radius: 6px; overflow: hidden;
|
||
box-shadow: 0 40px 90px -30px rgba(0, 0, 0, 0.8);
|
||
animation: im-settle 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||
}
|
||
@keyframes im-settle { from { opacity: 0; transform: scale(0.965); } }
|
||
.im-fs-stage img {
|
||
position: absolute; inset: 0;
|
||
width: 100%; height: 100%; object-fit: contain;
|
||
background: transparent;
|
||
}
|
||
.im-fs:not([data-solo]) .im-fs-stage img { background: #0b0b0e; }
|
||
.im-fs[data-solo="1"] .im-fs-stage { width: min(88vh, 92vw); }
|
||
.im-fs[data-mode="wipe"] .im-fs-b { clip-path: inset(0 0 0 var(--split, 50%)); }
|
||
.im-fs-handle {
|
||
position: absolute; top: 0; bottom: 0; z-index: 4;
|
||
left: var(--split, 50%);
|
||
width: 2px; margin-left: -1px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
|
||
cursor: ew-resize;
|
||
}
|
||
.im-fs-handle::after {
|
||
content: "⟺";
|
||
position: absolute; top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 34px; height: 34px; border-radius: 50%;
|
||
display: grid; place-items: center;
|
||
background: rgba(10, 10, 14, 0.72); color: #fff;
|
||
backdrop-filter: blur(8px);
|
||
font-size: 13px;
|
||
}
|
||
.im-fs:not([data-mode="wipe"]) .im-fs-handle { display: none; }
|
||
.im-fs[data-mode="side"] .im-fs-stage {
|
||
/* Two squares as tall as the wipe stage allows: width ≤ height × 2.04. */
|
||
width: min(164vh, 94vw); aspect-ratio: 2.04 / 1;
|
||
background: none; box-shadow: none; overflow: visible;
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||
}
|
||
.im-fs[data-mode="side"] .im-fs-stage img {
|
||
position: relative; inset: auto; border-radius: 6px;
|
||
box-shadow: 0 30px 70px -28px rgba(0, 0, 0, 0.8);
|
||
}
|
||
.im-fs[data-mode="toggle"] .im-fs-a { opacity: 1; }
|
||
.im-fs[data-mode="toggle"][data-show="b"] .im-fs-a { opacity: 0; }
|
||
.im-fs[data-mode="toggle"] .im-fs-b { opacity: 0; }
|
||
.im-fs[data-mode="toggle"][data-show="b"] .im-fs-b { opacity: 1; }
|
||
.im-fs-top {
|
||
position: absolute; top: 18px; left: 18px; right: 70px; z-index: 5;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.im-fs-title { font-size: 14px; font-weight: 800; color: #fff; }
|
||
.im-fs-title small { display: block; font-size: 12px; font-weight: 400; color: rgba(255, 255, 255, 0.6); }
|
||
.im-fs-modes {
|
||
margin-left: auto; display: flex; gap: 4px;
|
||
background: rgba(10, 10, 14, 0.72);
|
||
backdrop-filter: blur(10px) saturate(1.4);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
||
border-radius: 999px; padding: 3px;
|
||
}
|
||
.im-fs-modes button {
|
||
padding: 6px 12px; border-radius: 999px; border: 0;
|
||
font-size: 12px; font-weight: 800; cursor: pointer; font-family: inherit;
|
||
background: rgba(255, 255, 255, 0.09); color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
.im-fs-modes button[aria-selected="true"] { background: var(--accent); color: #000; }
|
||
.im-fs-meta {
|
||
position: absolute; bottom: 84px; left: 50%; transform: translateX(-50%);
|
||
z-index: 5; white-space: nowrap;
|
||
font-size: 12px; color: rgba(255, 255, 255, 0.85);
|
||
font-variant-numeric: tabular-nums;
|
||
background: rgba(10, 10, 14, 0.72);
|
||
backdrop-filter: blur(10px) saturate(1.4);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
||
border-radius: 999px; padding: 6px 13px;
|
||
}
|
||
.im-fs[data-solo="1"] .im-fs-meta { bottom: 26px; }
|
||
.im-fs-bot {
|
||
position: absolute; bottom: 18px; left: 0; right: 0; z-index: 5;
|
||
display: flex; justify-content: center; gap: 8px;
|
||
}
|
||
.im-fs-pick {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 7px 10px 7px 7px; border-radius: 10px;
|
||
background: rgba(10, 10, 14, 0.72);
|
||
backdrop-filter: blur(10px) saturate(1.4);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
||
cursor: pointer;
|
||
transition: transform 140ms ease, box-shadow 140ms ease;
|
||
}
|
||
.im-fs-pick:hover { transform: translateY(-2px); }
|
||
.im-fs-pick[aria-selected="true"] { box-shadow: inset 0 0 0 2px var(--accent); }
|
||
.im-fs-pick img { width: 38px; height: 38px; border-radius: 5px; object-fit: cover; }
|
||
.im-fs-pick b { display: block; font-size: 12px; font-weight: 800; color: #fff; }
|
||
.im-fs-pick i {
|
||
display: block; font-style: normal; font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums;
|
||
}
|
||
.im-fs-side-lab {
|
||
position: absolute; z-index: 5; bottom: 132px;
|
||
font-size: 11px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
background: rgba(10, 10, 14, 0.72); padding: 4px 9px; border-radius: 999px;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.im-fs-side-lab.l { left: 50%; transform: translateX(calc(-50% - 160px)); }
|
||
.im-fs-side-lab.r { left: 50%; transform: translateX(calc(-50% + 160px)); }
|
||
|
||
/* ---- the loupe: same spot, both sides, true pixels ---- */
|
||
.im-fs-stage.magnify { cursor: crosshair; }
|
||
.im-loupe {
|
||
position: fixed; z-index: 7; pointer-events: none;
|
||
transform: translate(-50%, calc(-100% - 20px));
|
||
}
|
||
.im-loupe.below { transform: translate(-50%, 20px); }
|
||
/* The translate lives on the wrapper so the pop-in scale can live here
|
||
without fighting it. */
|
||
.im-loupe-box {
|
||
position: relative; width: 100%; height: 100%;
|
||
border-radius: 14px; overflow: hidden;
|
||
display: flex;
|
||
background: #0b0b0e;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||
animation: im-loupe-in 140ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||
}
|
||
@keyframes im-loupe-in { from { transform: scale(0.92); } }
|
||
.im-loupe-half {
|
||
width: 50%; height: 100%;
|
||
background-repeat: no-repeat;
|
||
/* Pixelated on purpose: a loupe's job is showing the pixels that
|
||
are actually there — smoothing would hide exactly the difference
|
||
being judged. */
|
||
image-rendering: pixelated;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.im-loupe-box { animation: none; }
|
||
}
|
||
.im-loupe-half + .im-loupe-half { border-left: 1px solid rgba(255, 255, 255, 0.35); }
|
||
.im-loupe-lab {
|
||
position: absolute; top: 7px; z-index: 1;
|
||
font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
background: rgba(10, 10, 14, 0.65); padding: 2px 7px; border-radius: 999px;
|
||
}
|
||
.im-loupe-lab.l { left: 7px; }
|
||
.im-loupe-lab.r { right: 7px; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.im-fs, .im-fs-stage { animation: none; }
|
||
.im-fs-pick:hover { transform: none; }
|
||
}
|
||
|
||
.pill {
|
||
background: var(--accent); color: #000;
|
||
font-size: 11px; font-weight: 700;
|
||
padding: 6px 14px; border-radius: 14px; white-space: nowrap;
|
||
border: 0; cursor: pointer; font-family: inherit; line-height: 1.35;
|
||
flex: none;
|
||
}
|
||
.pill:hover { background: var(--accent-hover); }
|
||
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
|
||
.pill.ghost:hover { background: var(--bg-press, #333); }
|
||
.pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
</style>
|