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