let currentRunId = null;
let pollTimer = null;
let allRuns = [];
let lastRunFingerprint = null;
let lastPipelinePhase = null;
let cachedVideoRunId = null;
let estimateDebounceTimer = null;
let listItemCache = new Map();
const ACTIVE_STATUSES = new Set(["queued", "running"]);
const POLL_INTERVAL_MS = 5000;
const $ = (id) => document.getElementById(id);
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Request failed (${response.status})`);
}
return data;
}
function toast(message, type = "info") {
const container = $("toast-container");
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function statusBadge(status) {
const normalized = (status || "unknown").toLowerCase();
return `${status}`;
}
function formatDate(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr.replace(" ", "T"));
if (Number.isNaN(d.getTime())) return dateStr;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function runFingerprint(run) {
const segments = (run.segments || [])
.map((s) => `${s.index}:${s.status}`)
.join(",");
return `${run.status}|${segments}|${run.error || ""}`;
}
function listItemFingerprint(run) {
const lang = run.settings?.output_language || "";
return `${run.status}|${lang}|${run.input_filename || run.id}|${run.created_at}`;
}
function computeProgress(run) {
const segments = run.segments || [];
if (!segments.length) {
if (run.status === "queued") return { percent: 5, label: "Preparing…" };
return { percent: 0, label: "Starting…" };
}
const done = segments.filter((s) => s.status === "succeeded").length;
const total = segments.length;
const percent = Math.round((done / total) * 100);
if (run.status === "completed") return { percent: 100, label: "Done" };
if (run.status === "running") return { percent, label: `Translating… ${percent}%` };
if (run.status === "failed") return { percent, label: "Failed" };
if (run.status === "cancelled") return { percent, label: "Cancelled" };
return { percent, label: `${percent}%` };
}
function pipelinePhase(run) {
const status = run.status;
const segments = run.segments || [];
if (status === "completed") return "done";
if (!segments.length) return "split";
const allSucceeded = segments.every((s) => s.status === "succeeded");
const anyActive = segments.some((s) =>
["uploading", "processing", "downloading"].includes(s.status)
);
if (allSucceeded && status === "running") return "merge";
if (anyActive || segments.some((s) => s.status === "succeeded")) return "translate";
return "split";
}
function updatePipeline(run) {
const phase = pipelinePhase(run);
if (phase === lastPipelinePhase) return;
lastPipelinePhase = phase;
const order = ["split", "translate", "merge", "done"];
const currentIdx = order.indexOf(phase);
document.querySelectorAll(".pipeline-step").forEach((step) => {
const idx = order.indexOf(step.dataset.step);
step.classList.remove("active", "done");
if (idx < currentIdx) step.classList.add("done");
else if (idx === currentIdx) step.classList.add("active");
});
document.querySelectorAll(".pipeline-line").forEach((line, i) => {
line.classList.toggle("done", i < currentIdx);
});
}
function renderEstimatePanel(estimate) {
const panel = $("estimate-panel");
if (!estimate) {
panel.classList.add("hidden");
return;
}
$("estimate-segments").textContent = estimate.segments;
$("estimate-time").textContent = estimate.estimated_time_label;
$("estimate-cost").textContent = `~$${estimate.estimated_cost_usd}`;
$("estimate-disclaimer").textContent = estimate.disclaimer || "";
panel.classList.remove("hidden");
}
function renderRunEstimate(run) {
const panel = $("run-estimate");
if (!run.estimate) {
panel.classList.add("hidden");
return;
}
$("run-estimate-segments").textContent = run.estimate.segments;
$("run-estimate-time").textContent = run.estimate.estimated_time_label;
$("run-estimate-cost").textContent = `~$${run.estimate.estimated_cost_usd}`;
panel.classList.remove("hidden");
}
function getVideoDuration(file) {
return new Promise((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src);
resolve(Number.isFinite(video.duration) ? video.duration : null);
};
video.onerror = () => {
URL.revokeObjectURL(video.src);
resolve(null);
};
video.src = URL.createObjectURL(file);
});
}
async function updateFileEstimate(file) {
if (!file) {
renderEstimatePanel(null);
return;
}
const duration = await getVideoDuration(file);
if (!duration) {
renderEstimatePanel(null);
return;
}
const mode = $("quality-mode").value;
try {
const estimate = await fetchJSON(
`/api/estimate?duration_seconds=${encodeURIComponent(duration)}&mode=${encodeURIComponent(mode)}`
);
renderEstimatePanel(estimate);
} catch {
renderEstimatePanel(null);
}
}
function scheduleEstimateUpdate(file) {
clearTimeout(estimateDebounceTimer);
estimateDebounceTimer = setTimeout(() => updateFileEstimate(file), 350);
}
function setMessage(message) {
const node = $("run-message");
if (node.textContent !== message) node.textContent = message;
}
function setSubmitLoading(loading) {
const btn = $("submit-btn");
btn.disabled = loading;
btn.querySelector(".btn-text").textContent = loading ? "Starting…" : "Start Translation";
btn.querySelector(".btn-spinner").classList.toggle("hidden", !loading);
}
function bindRunListItem(li, run) {
li.dataset.runId = run.id;
li.addEventListener("click", () => selectRun(run.id));
li.querySelector(".run-delete-btn").addEventListener("click", (event) => {
event.stopPropagation();
deleteRun(run.id);
});
listItemCache.set(run.id, li);
}
function updateRunListItem(li, run) {
const filename = run.input_filename || run.id;
const lang = run.settings?.output_language || "";
const isActive = run.id === currentRunId;
li.classList.toggle("active", isActive);
li.querySelector(".run-item-name").textContent = filename;
li.querySelector(".run-item-name").title = filename;
li.querySelector(".run-item-meta").textContent = `${lang}${lang ? " · " : ""}${formatDate(run.created_at)}`;
li.querySelector(".run-item-badge").innerHTML = statusBadge(run.status);
}
function createRunListItem(run) {
const li = document.createElement("li");
li.className = "run-item";
const filename = run.input_filename || run.id;
const lang = run.settings?.output_language || "";
li.innerHTML = `
${filename}
${lang}${lang ? " · " : ""}${formatDate(run.created_at)}
${statusBadge(run.status)}
`;
bindRunListItem(li, run);
updateRunListItem(li, run);
return li;
}
function renderRunList(runs, { force = false } = {}) {
allRuns = runs;
const list = $("run-list");
const runIds = new Set(runs.map((r) => r.id));
if (!runs.length) {
list.innerHTML = 'No translations yet.';
listItemCache.clear();
return;
}
const empty = list.querySelector(".run-list-empty");
if (empty) empty.remove();
if (force || listItemCache.size === 0) {
list.innerHTML = "";
listItemCache.clear();
for (const run of runs) {
list.appendChild(createRunListItem(run));
}
return;
}
for (const [id, li] of listItemCache.entries()) {
if (!runIds.has(id)) {
li.remove();
listItemCache.delete(id);
}
}
const fragment = document.createDocumentFragment();
for (const run of runs) {
let li = listItemCache.get(run.id);
const fp = listItemFingerprint(run);
const prevFp = li?.dataset.fp;
if (!li) {
li = createRunListItem(run);
fragment.appendChild(li);
} else if (force || fp !== prevFp || run.id === currentRunId) {
updateRunListItem(li, run);
}
li.dataset.fp = fp;
}
if (fragment.childNodes.length) list.appendChild(fragment);
const ordered = runs.map((r) => listItemCache.get(r.id)).filter(Boolean);
for (const li of ordered) {
if (li.parentNode === list && list.lastElementChild !== li) {
list.appendChild(li);
}
}
}
function updateVideoPreview(run) {
const previewWrap = $("video-preview-wrap");
const video = $("video-preview");
if (run.status === "completed") {
previewWrap.classList.remove("hidden");
if (cachedVideoRunId !== run.id) {
video.src = `/api/runs/${run.id}/output`;
cachedVideoRunId = run.id;
}
return;
}
previewWrap.classList.add("hidden");
if (cachedVideoRunId !== null) {
video.removeAttribute("src");
video.load();
cachedVideoRunId = null;
}
}
function renderRunDetail(run, { force = false } = {}) {
const fingerprint = runFingerprint(run);
if (!force && fingerprint === lastRunFingerprint && run.id === currentRunId) {
return;
}
lastRunFingerprint = fingerprint;
$("run-overview").classList.remove("hidden");
$("active-run-title").textContent = run.input_filename || "Your translation";
$("meta-status").innerHTML = statusBadge(run.status);
const progress = computeProgress(run);
$("progress-fill").style.width = `${progress.percent}%`;
$("progress-label").textContent = progress.label;
const progressBar = $("progress-bar");
if (progressBar) progressBar.setAttribute("aria-valuenow", progress.percent);
updatePipeline(run);
renderRunEstimate(run);
const errorEl = $("run-error");
const showError = Boolean(run.error && run.status === "failed");
errorEl.classList.toggle("hidden", !showError);
if (showError) errorEl.textContent = run.error;
const lang = run.settings?.output_language || "another language";
const messages = {
completed: `Translated to ${lang}.`,
running: `Translating to ${lang}…`,
failed: "Something went wrong. You can retry.",
cancelled: "Translation cancelled.",
};
setMessage(messages[run.status] || "Waiting to start…");
const failedCount = (run.segments || []).filter((s) => s.status === "failed").length;
$("actions").classList.remove("hidden");
$("cancel-btn").classList.toggle("hidden", !ACTIVE_STATUSES.has(run.status));
$("retry-btn").classList.toggle("hidden", failedCount === 0 || run.status === "running");
$("download-btn").classList.toggle("hidden", run.status !== "completed");
updateVideoPreview(run);
}
function renderRun(run, { force = false } = {}) {
const runChanged = run.id !== currentRunId;
currentRunId = run.id;
if (runChanged) {
lastRunFingerprint = null;
lastPipelinePhase = null;
}
renderRunList(allRuns, { force: force || runChanged });
renderRunDetail(run, { force: force || runChanged });
managePolling(run);
}
function needsPolling(run) {
return (
ACTIVE_STATUSES.has(run.status) ||
(run.status === "failed" &&
run.segments?.some((s) => ["uploading", "processing", "downloading"].includes(s.status)))
);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPolling() {
if (pollTimer || document.hidden) return;
pollTimer = setInterval(refreshRun, POLL_INTERVAL_MS);
}
function managePolling(run) {
if (needsPolling(run)) startPolling();
else stopPolling();
}
async function refreshRun() {
if (!currentRunId || document.hidden) return;
try {
const run = await fetchJSON(`/api/runs/${currentRunId}`);
const idx = allRuns.findIndex((r) => r.id === run.id);
if (idx >= 0) allRuns[idx] = run;
else allRuns.unshift(run);
renderRun(run);
} catch (error) {
setMessage(`Failed to refresh: ${error.message}`);
}
}
async function selectRun(runId) {
if (runId === currentRunId) return;
currentRunId = runId;
lastRunFingerprint = null;
lastPipelinePhase = null;
try {
const run = await fetchJSON(`/api/runs/${runId}`);
renderRun(run, { force: true });
} catch (error) {
toast(error.message, "error");
}
}
async function refreshHealth() {
const node = $("health-pills");
try {
const health = await fetchJSON("/api/health");
if (!health.token_present || !health.ffmpeg_present) {
node.innerHTML = `Setup incomplete — check API token and ffmpeg`;
toast("Fix API token or ffmpeg before translating.", "error");
} else {
node.innerHTML = "";
}
} catch {
node.innerHTML = `Server unavailable`;
}
}
function setupDropZone() {
const zone = $("drop-zone");
const input = $("video-input");
const fileName = $("file-name");
function showFile(file) {
if (!file) return;
fileName.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`;
fileName.classList.remove("hidden");
zone.classList.add("dragover");
scheduleEstimateUpdate(file);
}
zone.addEventListener("click", () => input.click());
zone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
input.addEventListener("change", () => {
if (input.files[0]) showFile(input.files[0]);
});
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () => zone.classList.remove("dragover"));
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith("video/")) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
showFile(file);
} else {
toast("Please drop a video file.", "error");
}
});
}
function setupLanguageSelect() {
$("output-language").addEventListener("change", () => {
const isCustom = $("output-language").value === "custom";
$("custom-language-wrap").classList.toggle("hidden", !isCustom);
});
$("quality-mode").addEventListener("change", () => {
const file = $("video-input").files[0];
if (file) scheduleEstimateUpdate(file);
});
}
function setupVisibilityHandling() {
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopPolling();
return;
}
const current = allRuns.find((r) => r.id === currentRunId);
if (current && needsPolling(current)) {
refreshRun();
startPolling();
}
});
}
async function startRun(event) {
event.preventDefault();
const data = new FormData(event.target);
if ($("output-language").value === "custom") {
const custom = $("custom-language").value.trim();
if (!custom) {
toast("Enter a language name.", "error");
return;
}
data.set("output_language", custom);
}
if (!data.get("video")?.name) {
toast("Choose a video first.", "error");
return;
}
setSubmitLoading(true);
try {
const run = await fetchJSON("/api/runs", { method: "POST", body: data });
const idx = allRuns.findIndex((r) => r.id === run.id);
if (idx >= 0) allRuns[idx] = run;
else allRuns.unshift(run);
renderRun(run, { force: true });
toast("Translation started.", "success");
startPolling();
} catch (error) {
toast(error.message, "error");
} finally {
setSubmitLoading(false);
}
}
async function retryFailed() {
if (!currentRunId) return;
try {
const run = await fetchJSON(`/api/runs/${currentRunId}/retry`, { method: "POST" });
const idx = allRuns.findIndex((r) => r.id === run.id);
if (idx >= 0) allRuns[idx] = run;
renderRun(run, { force: true });
toast("Retrying…", "success");
startPolling();
} catch (error) {
toast(error.message, "error");
}
}
function downloadOutput() {
if (!currentRunId) return;
window.location.href = `/api/runs/${currentRunId}/download`;
}
async function cancelRun() {
if (!currentRunId || !window.confirm("Cancel this translation?")) return;
try {
const run = await fetchJSON(`/api/runs/${currentRunId}/cancel`, { method: "POST" });
const idx = allRuns.findIndex((r) => r.id === run.id);
if (idx >= 0) allRuns[idx] = run;
renderRun(run, { force: true });
toast("Cancelled.", "success");
} catch (error) {
toast(error.message, "error");
}
}
async function deleteRun(runId = currentRunId) {
if (!runId || !window.confirm("Delete this translation and its files?")) return;
try {
await fetchJSON(`/api/runs/${runId}`, { method: "DELETE" });
allRuns = allRuns.filter((run) => run.id !== runId);
listItemCache.delete(runId);
if (currentRunId === runId) {
currentRunId = null;
lastRunFingerprint = null;
lastPipelinePhase = null;
cachedVideoRunId = null;
$("run-overview").classList.add("hidden");
setMessage("Upload a video to get started.");
stopPolling();
}
renderRunList(allRuns, { force: true });
if (!currentRunId && allRuns.length > 0) await selectRun(allRuns[0].id);
toast("Deleted.", "success");
} catch (error) {
toast(error.message, "error");
}
}
async function init() {
setupDropZone();
setupLanguageSelect();
setupVisibilityHandling();
await refreshHealth();
$("run-form").addEventListener("submit", startRun);
$("cancel-btn").addEventListener("click", cancelRun);
$("retry-btn").addEventListener("click", retryFailed);
$("download-btn").addEventListener("click", downloadOutput);
try {
const { runs } = await fetchJSON("/api/runs");
allRuns = runs;
renderRunList(runs, { force: true });
if (runs.length > 0) renderRun(runs[0], { force: true });
} catch (error) {
setMessage(`Failed to load: ${error.message}`);
}
}
init();