/* ============================================================ Viet AutoSub Editor – Dashboard JavaScript Tương thích cả offline (file://) và online (HF Spaces) ============================================================ */ const state = { jobId: null, file: null, segments: [], isOnline: false, // server reachable? }; /* --- Detect environment ------------------------------------ */ const IS_FILE_PROTOCOL = window.location.protocol === "file:"; /** * Determine the API base URL. * - file:// → cannot reach backend at all * - http(s):// → use same origin (relative paths) */ function getApiBase() { if (IS_FILE_PROTOCOL) return null; return ""; // same-origin: "/api/transcribe" etc. } /* --- DOM refs ---------------------------------------------- */ const $ = (id) => document.getElementById(id); const els = { fileInput: $("videoFile"), preview: $("preview"), videoPlaceholder:$("videoPlaceholder"), status: $("status"), statusText: $("statusText"), btnTranscribe: $("btnTranscribe"), btnAddRow: $("btnAddRow"), btnExportSrt: $("btnExportSrt"), btnExportMp4: $("btnExportMp4"), btnClearFile: $("btnClearFile"), subtitleBody: $("subtitleBody"), segmentCount: $("segmentCount"), downloadSrt: $("downloadSrt"), downloadMp4: $("downloadMp4"), downloadGroup: $("downloadGroup"), dropZone: $("dropZone"), uploadPanel: $("uploadPanel"), fileInfo: $("fileInfo"), fileName: $("fileName"), fileSize: $("fileSize"), progressWrap: $("progressWrap"), progressFill: $("progressFill"), progressText: $("progressText"), // New: offline banner + badge offlineBanner: $("offlineBanner"), offlineBannerText: $("offlineBannerText"), offlineBannerClose:$("offlineBannerClose"), badgeEnv: $("badgeEnv"), badgeEnvText: $("badgeEnvText"), pulseDot: $("pulseDot"), }; /* --- Health check ------------------------------------------ */ let healthRetryTimer = null; async function checkHealth() { // file:// → always offline if (IS_FILE_PROTOCOL) { setOnlineState(false, "Offline (file://)"); return; } try { const res = await fetch("/health", { method: "GET", cache: "no-store" }); if (res.ok) { const data = await res.json(); setOnlineState(true, "HF Space"); // Stop retrying if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } } else { setOnlineState(false, "Server lỗi"); } } catch (_) { setOnlineState(false, "Không kết nối"); } } function setOnlineState(online, label) { state.isOnline = online; // Badge if (els.badgeEnv) { els.badgeEnv.classList.toggle("badge-online", online); els.badgeEnv.classList.toggle("badge-offline", !online); } if (els.badgeEnvText) { els.badgeEnvText.textContent = label || (online ? "Online" : "Offline"); } if (els.pulseDot) { els.pulseDot.className = online ? "pulse-dot pulse-online" : "pulse-dot pulse-offline"; } // Offline banner if (!online) { if (els.offlineBanner) els.offlineBanner.hidden = false; if (els.offlineBannerText) { els.offlineBannerText.textContent = IS_FILE_PROTOCOL ? "Đang chạy offline (file://) — Bạn có thể sửa subtitle và xuất SRT. Auto sub & xuất MP4 cần deploy lên HF Space." : "Không kết nối được server — Đang thử lại mỗi 30 giây..."; } // Auto-retry every 30s if on http but server is down if (!IS_FILE_PROTOCOL && !healthRetryTimer) { healthRetryTimer = setInterval(checkHealth, 30000); } } else { if (els.offlineBanner) els.offlineBanner.hidden = true; if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } } } // Close banner button if (els.offlineBannerClose) { els.offlineBannerClose.addEventListener("click", () => { if (els.offlineBanner) els.offlineBanner.hidden = true; }); } /* --- Steps ------------------------------------------------- */ function setStep(num) { document.querySelectorAll(".step").forEach((el) => { const s = parseInt(el.dataset.step, 10); el.classList.toggle("active", s === num); el.classList.toggle("done", s < num); }); } /* --- Status ------------------------------------------------ */ function setStatus(message, type = "idle") { els.status.className = `status-box status-${type}`; els.statusText.textContent = message; } /* --- Buttons state ----------------------------------------- */ function setEditButtons(enabled) { els.btnAddRow.disabled = !enabled; els.btnExportSrt.disabled = !enabled; els.btnExportMp4.disabled = !enabled; } /* --- Download link helpers --------------------------------- */ function showDownload(el, url, visible) { el.href = visible ? url : "#"; el.classList.toggle("disabled", !visible); } function showDownloadGroup(show) { els.downloadGroup.hidden = !show; } /* --- Progress simulation ----------------------------------- */ let progressTimer = null; function startProgress(label) { els.progressWrap.hidden = false; els.progressFill.style.width = "0%"; els.progressText.textContent = label || "Đang xử lý..."; let pct = 0; clearInterval(progressTimer); progressTimer = setInterval(() => { const remaining = 90 - pct; const step = Math.max(0.3, remaining * 0.04); pct = Math.min(90, pct + step); els.progressFill.style.width = pct + "%"; }, 300); } function finishProgress() { clearInterval(progressTimer); els.progressFill.style.width = "100%"; setTimeout(() => { els.progressWrap.hidden = true; els.progressFill.style.width = "0%"; }, 600); } function cancelProgress() { clearInterval(progressTimer); els.progressWrap.hidden = true; els.progressFill.style.width = "0%"; } /* --- File size formatter ----------------------------------- */ function formatSize(bytes) { if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; return (bytes / (1024 * 1024)).toFixed(1) + " MB"; } /* --- Create table cell inputs ------------------------------ */ function createInput(value, className) { const input = document.createElement("input"); input.type = "text"; input.value = value || ""; input.className = className; input.spellcheck = false; return input; } function createTextArea(value) { const textarea = document.createElement("textarea"); textarea.value = value || ""; textarea.rows = 2; textarea.className = "text-input"; return textarea; } /* --- Collect segments from table --------------------------- */ function collectSegmentsFromTable() { const rows = Array.from(els.subtitleBody.querySelectorAll("tr[data-row='1']")); return rows.map((row, index) => ({ id: index + 1, start: row.querySelector(".start-input").value.trim(), end: row.querySelector(".end-input").value.trim(), text: row.querySelector(".text-input").value.trim(), })); } /* --- Render table ------------------------------------------ */ function renderTable() { els.subtitleBody.innerHTML = ""; if (!state.segments.length) { els.subtitleBody.innerHTML = `

Chưa có subtitle. Upload video rồi bấm Auto sub tiếng Việt để bắt đầu.

`; els.segmentCount.textContent = "0 dòng"; setEditButtons(false); return; } state.segments.forEach((seg, index) => { const tr = document.createElement("tr"); tr.dataset.row = "1"; // # const tdIdx = document.createElement("td"); tdIdx.className = "idx-cell"; tdIdx.textContent = String(index + 1); // Start const tdStart = document.createElement("td"); tdStart.appendChild(createInput(seg.start, "start-input time-input")); // End const tdEnd = document.createElement("td"); tdEnd.appendChild(createInput(seg.end, "end-input time-input")); // Text const tdText = document.createElement("td"); tdText.appendChild(createTextArea(seg.text)); // Delete const tdAct = document.createElement("td"); tdAct.style.textAlign = "center"; const delBtn = document.createElement("button"); delBtn.className = "btn btn-danger-sm"; delBtn.innerHTML = ``; delBtn.title = "Xóa dòng"; delBtn.addEventListener("click", () => { state.segments = collectSegmentsFromTable(); state.segments.splice(index, 1); renderTable(); }); tdAct.appendChild(delBtn); tr.append(tdIdx, tdStart, tdEnd, tdText, tdAct); els.subtitleBody.appendChild(tr); }); els.segmentCount.textContent = `${state.segments.length} dòng`; setEditButtons(true); } /* --- Transcribe -------------------------------------------- */ async function transcribeVideo() { if (!state.file) { setStatus("Hãy chọn video trước.", "error"); return; } // Offline guard if (!state.isOnline) { setStatus( IS_FILE_PROTOCOL ? "Đang offline — Auto sub cần chạy trên HF Space (server). Hãy upload ứng dụng lên HF Space trước." : "Server không phản hồi. Đang thử kết nối lại...", "error" ); if (!IS_FILE_PROTOCOL) checkHealth(); return; } const fd = new FormData(); fd.append("file", state.file); els.btnTranscribe.disabled = true; els.btnTranscribe.classList.add("btn-loading"); setStatus("Đang nhận diện lời nói tiếng Việt...", "loading"); setStep(2); startProgress("Đang upload và nhận diện giọng nói..."); showDownload(els.downloadSrt, "#", false); showDownload(els.downloadMp4, "#", false); showDownloadGroup(false); try { const res = await fetch("/api/transcribe", { method: "POST", body: fd, }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Không thể nhận diện subtitle."); state.jobId = data.job_id; state.segments = data.segments || []; renderTable(); finishProgress(); setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng subtitle.`, "success"); setStep(3); } catch (err) { cancelProgress(); const msg = err.message.includes("Failed to fetch") ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space." : (err.message || "Có lỗi khi auto sub."); setStatus(msg, "error"); setStep(1); // Re-check health checkHealth(); } finally { els.btnTranscribe.disabled = false; els.btnTranscribe.classList.remove("btn-loading"); } } /* --- Client-side SRT generation (offline-capable) ---------- */ function generateSrtString(segments) { let lines = []; segments.forEach((seg, idx) => { const start = seg.start || "00:00:00,000"; const end = seg.end || "00:00:02,000"; const text = (seg.text || "").trim(); if (!text) return; lines.push(String(idx + 1)); lines.push(`${start} --> ${end}`); lines.push(text); lines.push(""); }); return lines.join("\n"); } function downloadSrtOffline() { const segments = collectSegmentsFromTable(); if (!segments.length) { setStatus("Chưa có subtitle để xuất.", "error"); return; } const srtContent = generateSrtString(segments); const blob = new Blob([srtContent], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "subtitle.srt"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setStatus("Đã xuất file SRT thành công (offline).", "success"); setStep(4); } /* --- Export ------------------------------------------------- */ async function exportResult(burnIn) { // If offline or no jobId and requesting SRT only → use client-side export if (!burnIn && (!state.isOnline || !state.jobId)) { downloadSrtOffline(); return; } // MP4 burn-in requires server if (burnIn && !state.isOnline) { setStatus( IS_FILE_PROTOCOL ? "Xuất MP4 burn sub cần server HF Space. Hãy deploy ứng dụng lên HF Space trước." : "Server không phản hồi. Xuất MP4 cần kết nối server.", "error" ); return; } if (!state.jobId) { setStatus("Chưa có job để xuất file. Hãy bấm Auto sub trước.", "error"); return; } // Collect latest from table const payload = { job_id: state.jobId, burn_in: burnIn, segments: collectSegmentsFromTable(), }; const label = burnIn ? "Đang xuất MP4 có sub..." : "Đang tạo file SRT..."; setStatus(label, "loading"); startProgress(label); setStep(4); els.btnExportSrt.disabled = true; els.btnExportMp4.disabled = true; try { const res = await fetch("/api/export", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Xuất file thất bại."); finishProgress(); showDownloadGroup(true); showDownload(els.downloadSrt, data.srt_url, true); if (data.mp4_url) { showDownload(els.downloadMp4, data.mp4_url, true); } const msg = burnIn ? `Xuất MP4 thành công${data.mp4_size_mb ? ` (${data.mp4_size_mb} MB)` : ""}.` : "Đã tạo file SRT thành công."; setStatus(msg, "success"); } catch (err) { cancelProgress(); const msg = err.message.includes("Failed to fetch") ? "Mất kết nối server. Kiểm tra lại mạng hoặc server HF Space." : (err.message || "Có lỗi khi xuất file."); setStatus(msg, "error"); checkHealth(); } finally { setEditButtons(true); } } /* --- File selection ---------------------------------------- */ function handleFile(file) { if (!file) return; state.file = file; state.jobId = null; state.segments = []; renderTable(); showDownload(els.downloadSrt, "#", false); showDownload(els.downloadMp4, "#", false); showDownloadGroup(false); // Show video preview const url = URL.createObjectURL(file); els.preview.src = url; els.preview.classList.add("has-src"); els.videoPlaceholder.classList.add("hidden"); // Show file info els.fileInfo.hidden = false; els.fileName.textContent = file.name; els.fileSize.textContent = formatSize(file.size); setStatus(`Đã chọn: ${file.name}`, "idle"); setStep(1); } function clearFile() { state.file = null; state.jobId = null; state.segments = []; renderTable(); els.preview.removeAttribute("src"); els.preview.classList.remove("has-src"); els.videoPlaceholder.classList.remove("hidden"); els.fileInfo.hidden = true; els.fileInput.value = ""; showDownloadGroup(false); setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle"); setStep(1); } /* --- Event listeners --------------------------------------- */ // File input els.fileInput.addEventListener("change", (e) => { const [file] = e.target.files || []; if (file) handleFile(file); }); // Click on drop zone els.dropZone.addEventListener("click", () => els.fileInput.click()); // Drag & drop els.dropZone.addEventListener("dragover", (e) => { e.preventDefault(); els.dropZone.classList.add("drag-over"); }); els.dropZone.addEventListener("dragleave", () => { els.dropZone.classList.remove("drag-over"); }); els.dropZone.addEventListener("drop", (e) => { e.preventDefault(); els.dropZone.classList.remove("drag-over"); const file = e.dataTransfer.files[0]; if (file) { const dt = new DataTransfer(); dt.items.add(file); els.fileInput.files = dt.files; handleFile(file); } }); // Clear file els.btnClearFile.addEventListener("click", clearFile); // Transcribe els.btnTranscribe.addEventListener("click", transcribeVideo); // Export els.btnExportSrt.addEventListener("click", () => exportResult(false)); els.btnExportMp4.addEventListener("click", () => exportResult(true)); // Add row els.btnAddRow.addEventListener("click", () => { state.segments = collectSegmentsFromTable(); state.segments.push({ id: state.segments.length + 1, start: "00:00:00,000", end: "00:00:02,000", text: "Subtitle mới", }); renderTable(); const scroll = $("tableScroll"); if (scroll) scroll.scrollTop = scroll.scrollHeight; }); // Collapse table toggle const btnCollapse = $("btnCollapseTable"); const tableScroll = $("tableScroll"); if (btnCollapse && tableScroll) { btnCollapse.addEventListener("click", () => { const collapsed = tableScroll.style.display === "none"; tableScroll.style.display = collapsed ? "" : "none"; btnCollapse.querySelector("svg").style.transform = collapsed ? "" : "rotate(180deg)"; }); } /* --- Init -------------------------------------------------- */ setStep(1); renderTable(); checkHealth();