Files
SoulStream/src/components/ImportPicker.svelte
T

1669 lines
90 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}
&nbsp;·&nbsp;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}&nbsp;<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}&nbsp;·&nbsp;<span class="im-dim">no service has this record — nothing to compare against</span>
{:else if !yoursSrc}&nbsp;·&nbsp;<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" : ""}
&nbsp;vs&nbsp; {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>