Files
video_concat/src/ui.ts
youfu 37ec549544 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>
2026-06-26 22:24:59 +08:00

68 lines
2.3 KiB
TypeScript
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.
// 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>;