v5: standalone Tauri rewrite — Rust backend + TypeScript UI

Complete rewrite from the Python/Tkinter app to a native Tauri 2 desktop
app. Drops the song-detection feature, so it needs no Python and no ML
model — just ffmpeg. Replaces video_concat.py entirely.

- Rust backend (src-tauri): stream-copy concat via ffmpeg with honest
  real-time progress (-progress pipe:1, out_time vs total duration), held
  at 99% through the +faststart finalising pass so the bar never jumps
- ffprobe duration scanning parallelised across threads; cancel kills the
  whole ffmpeg process tree; CREATE_NO_WINDOW hides console flashes
- BiliBili Live filename helper ported to Rust (bililive_rename)
- TypeScript UI, no framework: tiny h() DOM helper, drag-and-drop,
  reorderable/checkable file list with live totals, light/dark theme,
  reusable toast + progress components
- Optional delete-originals-after-merge with a clear warning
- New app icon (three clips merging into one); build.bat assembles a
  portable folder with ffmpeg/ffprobe bundled next to the exe
- Ignore tauri-generated gen/schemas (regenerated on every build)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:24:59 +08:00
parent 3261a461cb
commit 37ec549544
78 changed files with 8101 additions and 1100 deletions

67
src/ui.ts Normal file
View File

@@ -0,0 +1,67 @@
// Shared UI helpers: toasts and a reusable progress panel.
import { h, fmtElapsed } from "./dom";
export function toast(message: string, kind: "ok" | "err" | "" = "") {
const host = document.getElementById("toasts")!;
const el = h("div", { class: `toast ${kind}` },
h("span", {}, kind === "ok" ? "✓" : kind === "err" ? "⚠" : "•"),
h("span", {}, message),
);
host.append(el);
setTimeout(() => {
el.style.transition = "opacity .25s, transform .25s";
el.style.opacity = "0";
el.style.transform = "translateY(8px)";
setTimeout(() => el.remove(), 260);
}, kind === "err" ? 6000 : 3200);
}
/** A progress bar + status/timing line + collapsible log, returned as one unit. */
export function makeProgress() {
const fill = h("i");
const bar = h("div", { class: "bar" }, fill);
const status = h("span", { class: "status" }, "Ready");
const timing = h("span", { class: "timing" });
const log = h("div", { class: "log" });
const root = h("div", { class: "progress-wrap" },
bar,
h("div", { class: "progress-meta" }, status, timing),
log,
);
return {
root,
reset() {
bar.classList.remove("indeterminate");
fill.style.width = "0%";
status.textContent = "Ready";
timing.textContent = "";
log.textContent = "";
},
indeterminate(on: boolean, text?: string) {
bar.classList.toggle("indeterminate", on);
if (on) fill.style.width = "35%";
if (text) status.textContent = text;
},
set(pct: number, statusText: string, opts?: { elapsed?: number; eta?: number; speed?: number }) {
bar.classList.remove("indeterminate");
fill.style.width = `${Math.round(pct * 100)}%`;
status.textContent = statusText;
if (opts) {
const bits: string[] = [];
if (opts.elapsed != null) bits.push(`Elapsed ${fmtElapsed(opts.elapsed)}`);
if (opts.eta != null && opts.eta > 0) bits.push(`ETA ${fmtElapsed(opts.eta)}`);
if (opts.speed != null && opts.speed > 0) bits.push(`${opts.speed.toFixed(1)}×`);
timing.textContent = bits.join(" · ");
}
},
setTiming(text: string) { timing.textContent = text; },
appendLog(line: string) {
log.textContent += (log.textContent ? "\n" : "") + line;
log.scrollTop = log.scrollHeight;
},
};
}
export type Progress = ReturnType<typeof makeProgress>;