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();