ui: DedupUI + stili gruppi duplicati

- DedupUI: state locale + folder picker + start/stop scan +
  render gruppi (checkbox multi-select), file col bitrate max
  auto-preselezionato come "TIENI" (verde, checkbox disabled),
  audio preview riusa _makePreviewBtn dal picker Upgrade.
- Listener bridge dedup:progress / dedup:done, ripristino
  ultima cartella + recursive da config al mount.
- CSS: .dedup-group-card, .dedup-file-row, .dedup-keep (verde),
  .dedup-keep-badge, .dedup-footer-bar sticky.
This commit is contained in:
luciano committed 2026-08-02 13:23:04 +02:00
1 parent e90e38edc8
commit 00ff5a6220
2 files changed
+432

No files matched your search

+136
View File
@@ -1826,3 +1826,139 @@ input[type="number"]::-webkit-inner-spin-button {
word-break: break-all;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
/* ===============================
DEDUP tab (audio duplicati)
=============================== */
.dedup-summary {
font-size: 14px;
color: var(--text-2);
font-weight: 600;
}
.dedup-group-card {
padding: 14px 16px;
}
.dedup-group-header {
font-size: 13px;
color: var(--text-2);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.dedup-group-header strong {
color: var(--text-1);
font-size: 14px;
}
.dedup-fp {
font-family: "SF Mono", Menlo, Consolas, monospace;
color: var(--text-3);
font-size: 11px;
}
.dedup-file-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.dedup-file-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-elev);
transition: background 0.15s ease;
}
.dedup-file-row:hover {
background: var(--bg-elev-hover, var(--bg-elev));
}
.dedup-file-check {
flex-shrink: 0;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--red);
}
.dedup-file-check:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.dedup-file-info {
flex: 1;
min-width: 0;
}
.dedup-file-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dedup-file-meta {
font-size: 11px;
color: var(--text-3);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.dedup-file-path {
font-size: 10px;
color: var(--text-3);
margin-top: 2px;
font-family: "SF Mono", Menlo, Consolas, monospace;
word-break: break-all;
opacity: 0.6;
}
.dedup-file-play {
flex-shrink: 0;
display: flex;
align-items: center;
}
/* File "da tenere" (bitrate massimo del gruppo) — verde */
.dedup-keep {
background: var(--green-dim);
border-left: 3px solid var(--green);
}
.dedup-keep .dedup-file-title {
color: var(--green-text);
}
.dedup-keep-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
background: var(--green);
color: white;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.5px;
margin-right: 6px;
vertical-align: middle;
}
.dedup-footer-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-top: 12px;
border-radius: var(--r-md);
background: var(--bg-card);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border);
}
+296
View File
@@ -408,6 +408,7 @@ const logEls = {
youtube: () => $("#youtube-log"),
convert: () => $("#convert-log"),
traxsource: () => $("#traxsource-log"),
dedup: () => $("#dedup-log"),
};
function classifyLog(msg) {
@@ -525,6 +526,9 @@ async function init() {
// WAV -> MP3 converter tab
await ConvertUI.init();
// Dedup tab (audio duplicati via Chromaprint)
await DedupUI.init();
}
async function refreshRecDevices() {
@@ -2857,6 +2861,298 @@ const ConvertUI = (() => {
return { init };
})();
// ============================================================
// DedupUI — audio duplicati via Chromaprint fingerprinting
// ============================================================
const DedupUI = (() => {
const mstate = {
folder: "",
recursive: true,
scanning: false,
groups: [],
// Set di path da cancellare (chiave = string path)
toDelete: new Set(),
};
function _fmtDur(sec) {
if (!sec || sec <= 0) return "—";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function setStatus(text, kind) {
const el = $("#dedup-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
function updateSelectedCount() {
const n = mstate.toDelete.size;
const label = n === 1 ? "1 file da cancellare" : `${n} file da cancellare`;
const cnt = $("#dedup-selected-count");
if (cnt) cnt.textContent = label;
const btn = $("#dedup-trash-btn");
if (btn) btn.disabled = n === 0 || mstate.scanning;
const bar = $("#dedup-footer-bar");
if (bar) bar.hidden = mstate.groups.length === 0;
}
function renderGroups() {
const wrap = $("#dedup-groups-wrap");
if (!wrap) return;
wrap.innerHTML = "";
mstate.toDelete.clear();
if (!mstate.groups.length) {
$("#dedup-summary-card").hidden = true;
$("#dedup-footer-bar").hidden = true;
updateSelectedCount();
return;
}
// Summary line
const nGroups = mstate.groups.length;
let nDupes = 0;
let reclaim = 0;
mstate.groups.forEach((g) => {
nDupes += Math.max(0, g.length - 1);
for (let i = 1; i < g.length; i++) reclaim += (g[i].size || 0);
});
$("#dedup-summary-line").textContent =
`${nGroups} gruppi · ${nDupes} duplicati · spazio recuperabile ~${_fmtBytes(reclaim)}`;
$("#dedup-summary-card").hidden = false;
// Render each group card
mstate.groups.forEach((group, gi) => {
const card = document.createElement("div");
card.className = "card dedup-group-card";
const totalSize = group.reduce((s, e) => s + (e.size || 0), 0);
const fpPreview = (group[0].fingerprint || "").slice(0, 12);
const header = document.createElement("div");
header.className = "dedup-group-header";
header.innerHTML = `
<strong>Gruppo ${gi + 1}</strong>
· <span>${group.length} file</span>
· <span>totale ${_fmtBytes(totalSize)}</span>
· <span class="dedup-fp">fp ${_escapeHtml(fpPreview)}…</span>
`;
card.appendChild(header);
const list = document.createElement("div");
list.className = "dedup-file-list";
group.forEach((entry, idx) => {
const isKeep = idx === 0; // primo = bitrate piu' alto = da tenere
const row = document.createElement("div");
row.className = "dedup-file-row" + (isKeep ? " dedup-keep" : "");
// Pre-selezione: cancella tutti tranne il primo
if (!isKeep) mstate.toDelete.add(entry.path);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "dedup-file-check";
checkbox.checked = !isKeep;
checkbox.disabled = isKeep;
checkbox.title = isKeep
? "File da tenere (bitrate massimo)"
: "Marca per la cancellazione";
checkbox.addEventListener("change", () => {
if (checkbox.checked) mstate.toDelete.add(entry.path);
else mstate.toDelete.delete(entry.path);
updateSelectedCount();
});
row.appendChild(checkbox);
const info = document.createElement("div");
info.className = "dedup-file-info";
const kbps = entry.bitrate ? `${entry.bitrate}k` : "—";
const keepBadge = isKeep ? '<span class="dedup-keep-badge">TIENI</span> ' : "";
info.innerHTML = `
<div class="dedup-file-title">${keepBadge}${_escapeHtml(_basename(entry.path))}</div>
<div class="dedup-file-meta">
${_escapeHtml(kbps)} · ${_escapeHtml(_fmtBytes(entry.size))} · ${_escapeHtml(_fmtDur(entry.duration))}
</div>
<div class="dedup-file-path">${_escapeHtml(entry.path)}</div>
`;
row.appendChild(info);
// Preview audio (riusa _makePreviewBtn della sezione Upgrade)
const playSlot = document.createElement("div");
playSlot.className = "dedup-file-play";
playSlot.appendChild(_makePreviewBtn(entry.path));
row.appendChild(playSlot);
list.appendChild(row);
});
card.appendChild(list);
wrap.appendChild(card);
});
updateSelectedCount();
}
async function pickFolder() {
const p = await window.pywebview.api.dedup_pick_folder();
if (p && typeof p === "string") {
mstate.folder = p;
$("#dedup-path-display").textContent = p;
$("#dedup-start-btn").disabled = false;
setStatus("", "");
}
}
async function startScan() {
if (!mstate.folder) return;
mstate.recursive = $("#dedup-recursive").checked;
mstate.scanning = true;
mstate.groups = [];
mstate.toDelete.clear();
renderGroups();
$("#dedup-start-btn").disabled = true;
$("#dedup-pick-folder").disabled = true;
$("#dedup-stop-btn").hidden = false;
$("#dedup-log").innerHTML = "";
$("#dedupProgressFill").style.width = "0%";
$("#dedupPercent").textContent = "0%";
$("#dedupCounter").textContent = "Scansione in corso…";
setStatus("Scansione in corso...", "loading");
let res;
try {
res = await window.pywebview.api.dedup_start_scan({
directory: mstate.folder,
recursive: mstate.recursive,
});
} catch (e) {
setStatus("Errore avvio: " + ((e && e.message) || e), "error");
finishScan();
return;
}
if (!res || !res.ok) {
const msg = (res && res.error) || "Impossibile avviare la scansione";
setStatus(msg, "error");
if (!handleGateBlock(res || {})) toast(msg, "error");
finishScan();
}
}
async function stopScan() {
try { await window.pywebview.api.dedup_stop_scan(); } catch (e) { console.error(e); }
}
function finishScan() {
mstate.scanning = false;
$("#dedup-start-btn").disabled = !mstate.folder;
$("#dedup-pick-folder").disabled = false;
$("#dedup-stop-btn").hidden = true;
updateSelectedCount();
}
async function moveSelectedToTrash() {
const paths = [...mstate.toDelete];
if (!paths.length) return;
const msg = paths.length === 1
? "Spostare 1 file nel cestino?"
: `Spostare ${paths.length} file nel cestino?`;
if (!confirm(msg)) return;
const btn = $("#dedup-trash-btn");
btn.disabled = true;
let res;
try {
res = await window.pywebview.api.dedup_move_to_trash(paths);
} catch (e) {
toast("Errore: " + ((e && e.message) || e), "error");
btn.disabled = false;
return;
}
if (!res || !res.ok) {
toast((res && res.error) || "Errore spostamento", "error");
btn.disabled = false;
return;
}
const moved = new Set(res.moved || []);
// Rimuovi dai gruppi i file spostati; scarta gruppi che scendono sotto i 2
mstate.groups = mstate.groups
.map((g) => g.filter((e) => !moved.has(e.path)))
.filter((g) => g.length >= 2);
renderGroups();
const okN = res.moved_count || 0;
const failN = res.failed_count || 0;
if (failN > 0) {
toast(`${okN} spostati, ${failN} falliti`, "error");
} else {
toast(`${okN} file spostati nel cestino`, "success");
}
}
async function init() {
// Bind eventi
$("#dedup-pick-folder").addEventListener("click", pickFolder);
$("#dedup-start-btn").addEventListener("click", startScan);
$("#dedup-stop-btn").addEventListener("click", stopScan);
$("#dedup-trash-btn").addEventListener("click", moveSelectedToTrash);
$("#dedup-recursive").addEventListener("change", (e) => {
mstate.recursive = e.target.checked;
});
// Ripristina ultima cartella + recursive da config
const cfg = state.config || {};
if (cfg.dedup_last_folder) {
mstate.folder = cfg.dedup_last_folder;
$("#dedup-path-display").textContent = cfg.dedup_last_folder;
$("#dedup-start-btn").disabled = false;
}
if (typeof cfg.dedup_recursive === "boolean") {
$("#dedup-recursive").checked = cfg.dedup_recursive;
mstate.recursive = cfg.dedup_recursive;
}
// Bridge handlers
bridgeHandlers["dedup:progress"] = (p) => {
if (!p) return;
if (typeof p.overall === "number") {
const pct = Math.round(p.overall * 100);
$("#dedupProgressFill").style.width = pct + "%";
$("#dedupPercent").textContent = pct + "%";
}
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
const status = p.status || "";
let label;
if (status === "cached") label = `File ${p.idx}/${p.total} · cache`;
else if (status === "computing") label = `File ${p.idx}/${p.total} · calcolo`;
else if (status === "completed") label = `Completato: ${p.total} file`;
else if (status === "stopped") label = "Interrotta";
else if (status === "error") label = `File ${p.idx}/${p.total} · errore`;
else label = `File ${p.idx}/${p.total}`;
$("#dedupCounter").textContent = label;
}
};
bridgeHandlers["dedup:done"] = (p) => {
mstate.groups = (p && p.groups) || [];
renderGroups();
if (p && p.ok === false) {
setStatus(p.error || "Errore scansione", "error");
} else if (!mstate.groups.length) {
setStatus("Nessun duplicato trovato", "ok");
} else {
setStatus(`Trovati ${mstate.groups.length} gruppi di duplicati`, "ok");
}
finishScan();
};
}
return { init };
})();
// ============================================================
// Boot
// ============================================================