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:
1 parent
e90e38edc8
commit
00ff5a6220
2 files changed
+432
No files matched your search
@@ -1826,3 +1826,139 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
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
@@ -408,6 +408,7 @@ const logEls = {
|
|||||||
youtube: () => $("#youtube-log"),
|
youtube: () => $("#youtube-log"),
|
||||||
convert: () => $("#convert-log"),
|
convert: () => $("#convert-log"),
|
||||||
traxsource: () => $("#traxsource-log"),
|
traxsource: () => $("#traxsource-log"),
|
||||||
|
dedup: () => $("#dedup-log"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function classifyLog(msg) {
|
function classifyLog(msg) {
|
||||||
@@ -525,6 +526,9 @@ async function init() {
|
|||||||
|
|
||||||
// WAV -> MP3 converter tab
|
// WAV -> MP3 converter tab
|
||||||
await ConvertUI.init();
|
await ConvertUI.init();
|
||||||
|
|
||||||
|
// Dedup tab (audio duplicati via Chromaprint)
|
||||||
|
await DedupUI.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecDevices() {
|
async function refreshRecDevices() {
|
||||||
@@ -2857,6 +2861,298 @@ const ConvertUI = (() => {
|
|||||||
return { init };
|
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
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user