Spaces:
Sleeping
Sleeping
| /* ============================================================ | |
| Viet AutoSub Editor – Dashboard JavaScript | |
| Tương thích cả offline (file://) và online (HF Spaces) | |
| Hỗ trợ 2 chế độ: Lời bài hát (music) + Giọng nói (speech) | |
| ============================================================ */ | |
| const state = { | |
| jobId: null, | |
| file: null, | |
| segments: [], | |
| isOnline: false, | |
| mode: "music", // "music" | "speech" | |
| karaokeStyle: { | |
| font: "Bangers", | |
| color: "#FFFFFF", | |
| highlight: "#FFD700", | |
| outline: "#000000", | |
| outlineWidth: 2, | |
| sizePct: 100, | |
| positionPct: 90, | |
| karaokeMode: false, | |
| }, | |
| }; | |
| /* --- Detect environment ------------------------------------ */ | |
| const IS_FILE_PROTOCOL = window.location.protocol === "file:"; | |
| function getApiBase() { | |
| if (IS_FILE_PROTOCOL) return null; | |
| return ""; | |
| } | |
| /* --- 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"), | |
| // Offline banner + badge | |
| offlineBanner: $("offlineBanner"), | |
| offlineBannerText: $("offlineBannerText"), | |
| offlineBannerClose:$("offlineBannerClose"), | |
| badgeEnv: $("badgeEnv"), | |
| badgeEnvText: $("badgeEnvText"), | |
| pulseDot: $("pulseDot"), | |
| // Mode selector | |
| modeToggle: $("modeToggle"), | |
| modeMusic: $("modeMusic"), | |
| modeSpeech: $("modeSpeech"), | |
| modeHint: $("modeHint"), | |
| // Coverage | |
| coverageBar: $("coverageBar"), | |
| coveragePct: $("coveragePct"), | |
| coverageFill: $("coverageFill"), | |
| // Karaoke Style | |
| ksFont: $("ksFont"), | |
| ksColor: $("ksColor"), | |
| ksColorHex: $("ksColorHex"), | |
| ksHighlight: $("ksHighlight"), | |
| ksHighlightHex: $("ksHighlightHex"), | |
| ksOutline: $("ksOutline"), | |
| ksOutlineHex: $("ksOutlineHex"), | |
| ksOutlineWidth: $("ksOutlineWidth"), | |
| ksOutlineWidthVal: $("ksOutlineWidthVal"), | |
| ksSize: $("ksSize"), | |
| ksSizeVal: $("ksSizeVal"), | |
| ksPosition: $("ksPosition"), | |
| ksPositionVal: $("ksPositionVal"), | |
| ksKaraokeMode: $("ksKaraokeMode"), | |
| ksKaraokeHint: $("ksKaraokeHint"), | |
| btnPreviewStyle: $("btnPreviewStyle"), | |
| videoWrap: $("videoWrap"), | |
| subPreviewOverlay: $("subPreviewOverlay"), | |
| subPreviewText: $("subPreviewText"), | |
| }; | |
| /* --- Mode selector ----------------------------------------- */ | |
| const MODE_HINTS = { | |
| music: "Tối ưu cho Vietsub lời bài hát, nhận diện toàn bộ lyrics khớp timeline.", | |
| speech: "Tối ưu cho giọng nói, thuyết trình, podcast. Lọc tiếng ồn nền.", | |
| }; | |
| function setMode(mode) { | |
| state.mode = mode; | |
| // Update toggle buttons | |
| if (els.modeMusic) els.modeMusic.classList.toggle("active", mode === "music"); | |
| if (els.modeSpeech) els.modeSpeech.classList.toggle("active", mode === "speech"); | |
| if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || ""; | |
| } | |
| // Mode toggle event listeners | |
| if (els.modeToggle) { | |
| els.modeToggle.addEventListener("click", (e) => { | |
| const btn = e.target.closest(".mode-btn"); | |
| if (!btn) return; | |
| const mode = btn.dataset.mode; | |
| if (mode) setMode(mode); | |
| }); | |
| } | |
| /* --- Coverage display -------------------------------------- */ | |
| function showCoverage(pct) { | |
| if (!els.coverageBar) return; | |
| els.coverageBar.hidden = false; | |
| const val = Math.min(100, Math.max(0, pct)); | |
| if (els.coveragePct) els.coveragePct.textContent = val + "%"; | |
| if (els.coverageFill) els.coverageFill.style.width = val + "%"; | |
| // Color coding | |
| if (els.coverageFill) { | |
| els.coverageFill.classList.remove("cov-low", "cov-mid", "cov-high"); | |
| if (val >= 80) els.coverageFill.classList.add("cov-high"); | |
| else if (val >= 50) els.coverageFill.classList.add("cov-mid"); | |
| else els.coverageFill.classList.add("cov-low"); | |
| } | |
| } | |
| function hideCoverage() { | |
| if (els.coverageBar) els.coverageBar.hidden = true; | |
| } | |
| /* --- Health check ------------------------------------------ */ | |
| let healthRetryTimer = null; | |
| async function checkHealth() { | |
| 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"); | |
| 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; | |
| 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"; | |
| } | |
| 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..."; | |
| } | |
| if (!IS_FILE_PROTOCOL && !healthRetryTimer) { | |
| healthRetryTimer = setInterval(checkHealth, 30000); | |
| } | |
| } else { | |
| if (els.offlineBanner) els.offlineBanner.hidden = true; | |
| if (healthRetryTimer) { clearInterval(healthRetryTimer); healthRetryTimer = null; } | |
| } | |
| } | |
| 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 = ` | |
| <tr class="empty-row"> | |
| <td colspan="5"> | |
| <div class="empty-state"> | |
| <svg viewBox="0 0 48 48" fill="none" class="empty-icon"> | |
| <rect x="6" y="10" width="36" height="28" rx="4" stroke="currentColor" stroke-width="1.5"/> | |
| <line x1="12" y1="20" x2="36" y2="20" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| <line x1="12" y1="26" x2="30" y2="26" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| <line x1="12" y1="32" x2="24" y2="32" stroke="currentColor" stroke-width="1.5" opacity="0.3"/> | |
| </svg> | |
| <p>Chưa có subtitle. Upload video rồi bấm <strong>Auto sub tiếng Việt</strong> để bắt đầu.</p> | |
| </div> | |
| </td> | |
| </tr>`; | |
| 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); | |
| const tdStart = document.createElement("td"); | |
| tdStart.appendChild(createInput(seg.start, "start-input time-input")); | |
| const tdEnd = document.createElement("td"); | |
| tdEnd.appendChild(createInput(seg.end, "end-input time-input")); | |
| const tdText = document.createElement("td"); | |
| tdText.appendChild(createTextArea(seg.text)); | |
| const tdAct = document.createElement("td"); | |
| tdAct.style.textAlign = "center"; | |
| const delBtn = document.createElement("button"); | |
| delBtn.className = "btn btn-danger-sm"; | |
| delBtn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" style="width:14px;height:14px"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>`; | |
| 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; | |
| } | |
| 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); | |
| fd.append("mode", state.mode); | |
| els.btnTranscribe.disabled = true; | |
| els.btnTranscribe.classList.add("btn-loading"); | |
| const modeLabel = state.mode === "music" ? "lời bài hát" : "giọng nói"; | |
| setStatus(`Đang nhận diện ${modeLabel} tiếng Việt...`, "loading"); | |
| setStep(2); | |
| startProgress(`Đang upload và nhận diện ${modeLabel}...`); | |
| showDownload(els.downloadSrt, "#", false); | |
| showDownload(els.downloadMp4, "#", false); | |
| showDownloadGroup(false); | |
| hideCoverage(); | |
| 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(); | |
| // Show coverage | |
| if (data.coverage_pct !== undefined) { | |
| showCoverage(data.coverage_pct); | |
| } | |
| const coverageInfo = data.coverage_pct ? ` (phủ ${data.coverage_pct}% timeline)` : ""; | |
| setStatus(`Hoàn tất. Đã tạo ${state.segments.length} dòng Vietsub${coverageInfo}.`, "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); | |
| 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 (!burnIn && (!state.isOnline || !state.jobId)) { | |
| downloadSrtOffline(); | |
| return; | |
| } | |
| 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; | |
| } | |
| const ks = getKaraokeStyle(); | |
| const payload = { | |
| job_id: state.jobId, | |
| burn_in: burnIn, | |
| segments: collectSegmentsFromTable(), | |
| style: { | |
| font_name: ks.font, | |
| font_color: ks.color, | |
| highlight_color: ks.highlight, | |
| outline_color: ks.outline, | |
| outline_width: ks.outlineWidth, | |
| font_size_pct: ks.sizePct, | |
| position_pct: ks.positionPct, | |
| karaoke_mode: ks.karaokeMode, | |
| }, | |
| }; | |
| 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); | |
| hideCoverage(); | |
| const url = URL.createObjectURL(file); | |
| els.preview.src = url; | |
| els.preview.classList.add("has-src"); | |
| els.videoPlaceholder.classList.add("hidden"); | |
| 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); | |
| hideCoverage(); | |
| setStatus("Sẵn sàng. Hãy upload video để bắt đầu.", "idle"); | |
| setStep(1); | |
| } | |
| /* --- Event listeners --------------------------------------- */ | |
| els.fileInput.addEventListener("change", (e) => { | |
| const [file] = e.target.files || []; | |
| if (file) handleFile(file); | |
| }); | |
| els.dropZone.addEventListener("click", () => els.fileInput.click()); | |
| 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); | |
| } | |
| }); | |
| els.btnClearFile.addEventListener("click", clearFile); | |
| els.btnTranscribe.addEventListener("click", transcribeVideo); | |
| els.btnExportSrt.addEventListener("click", () => exportResult(false)); | |
| els.btnExportMp4.addEventListener("click", () => exportResult(true)); | |
| 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; | |
| }); | |
| 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)"; | |
| }); | |
| } | |
| /* --- Karaoke Style ----------------------------------------- */ | |
| function getKaraokeStyle() { | |
| return { | |
| font: els.ksFont ? els.ksFont.value : "Bangers", | |
| color: els.ksColor ? els.ksColor.value : "#FFFFFF", | |
| highlight: els.ksHighlight ? els.ksHighlight.value : "#FFD700", | |
| outline: els.ksOutline ? els.ksOutline.value : "#000000", | |
| outlineWidth: els.ksOutlineWidth ? parseInt(els.ksOutlineWidth.value, 10) : 2, | |
| sizePct: els.ksSize ? parseInt(els.ksSize.value, 10) : 100, | |
| positionPct: els.ksPosition ? parseInt(els.ksPosition.value, 10) : 90, | |
| karaokeMode: els.ksKaraokeMode ? els.ksKaraokeMode.checked : false, | |
| }; | |
| } | |
| function updatePreviewOverlay() { | |
| const ks = getKaraokeStyle(); | |
| state.karaokeStyle = ks; | |
| const overlay = els.subPreviewOverlay; | |
| const textEl = els.subPreviewText; | |
| if (!overlay || !textEl) return; | |
| // Position: convert 0-100% to bottom offset | |
| // 0% = top (bottom: 90%), 100% = bottom (bottom: 2%) | |
| const bottomPct = Math.max(2, 90 - (ks.positionPct / 100) * 88); | |
| overlay.style.bottom = bottomPct + "%"; | |
| // Font | |
| textEl.style.fontFamily = "'" + ks.font + "', sans-serif"; | |
| // Size: base 1.3rem * sizePct / 100 | |
| textEl.style.fontSize = (1.3 * ks.sizePct / 100) + "rem"; | |
| // Color | |
| textEl.style.color = ks.color; | |
| // Text shadow for outline | |
| const ow = ks.outlineWidth; | |
| const oc = ks.outline; | |
| if (ow > 0) { | |
| textEl.style.textShadow = [ | |
| `${ow}px ${ow}px 0 ${oc}`, | |
| `-${ow}px -${ow}px 0 ${oc}`, | |
| `${ow}px -${ow}px 0 ${oc}`, | |
| `-${ow}px ${ow}px 0 ${oc}`, | |
| `0 0 8px rgba(0,0,0,0.7)`, | |
| ].join(", "); | |
| } else { | |
| textEl.style.textShadow = "0 0 8px rgba(0,0,0,0.7)"; | |
| } | |
| // Demo text with karaoke highlight | |
| if (ks.karaokeMode) { | |
| textEl.innerHTML = 'Phụ đề <span class="ks-word-active" style="color:' + ks.highlight + '">mẫu</span> — Xem trước <span class="ks-word-active" style="color:' + ks.highlight + '">Karaoke</span>'; | |
| } else { | |
| textEl.textContent = "Phụ đề mẫu — Xem trước Karaoke"; | |
| } | |
| } | |
| function showSubtitlePreview() { | |
| if (els.subPreviewOverlay) { | |
| els.subPreviewOverlay.hidden = false; | |
| updatePreviewOverlay(); | |
| } | |
| } | |
| function hideSubtitlePreview() { | |
| if (els.subPreviewOverlay) els.subPreviewOverlay.hidden = true; | |
| } | |
| // Wire up karaoke control events | |
| function setupKaraokeEvents() { | |
| // Font selector → update preview font display | |
| if (els.ksFont) { | |
| els.ksFont.addEventListener("change", () => { | |
| els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif"; | |
| updatePreviewOverlay(); | |
| }); | |
| // Set initial font display | |
| els.ksFont.style.fontFamily = "'" + els.ksFont.value + "', sans-serif"; | |
| } | |
| // Color pickers | |
| if (els.ksColor) { | |
| els.ksColor.addEventListener("input", () => { | |
| if (els.ksColorHex) els.ksColorHex.textContent = els.ksColor.value.toUpperCase(); | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| if (els.ksHighlight) { | |
| els.ksHighlight.addEventListener("input", () => { | |
| if (els.ksHighlightHex) els.ksHighlightHex.textContent = els.ksHighlight.value.toUpperCase(); | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| if (els.ksOutline) { | |
| els.ksOutline.addEventListener("input", () => { | |
| if (els.ksOutlineHex) els.ksOutlineHex.textContent = els.ksOutline.value.toUpperCase(); | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| if (els.ksOutlineWidth) { | |
| els.ksOutlineWidth.addEventListener("input", () => { | |
| if (els.ksOutlineWidthVal) els.ksOutlineWidthVal.textContent = els.ksOutlineWidth.value + "px"; | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| // Size slider | |
| if (els.ksSize) { | |
| els.ksSize.addEventListener("input", () => { | |
| if (els.ksSizeVal) els.ksSizeVal.textContent = els.ksSize.value + "%"; | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| // Position slider | |
| if (els.ksPosition) { | |
| els.ksPosition.addEventListener("input", () => { | |
| if (els.ksPositionVal) els.ksPositionVal.textContent = els.ksPosition.value + "%"; | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| // Karaoke mode toggle | |
| if (els.ksKaraokeMode) { | |
| els.ksKaraokeMode.addEventListener("change", () => { | |
| updatePreviewOverlay(); | |
| }); | |
| } | |
| // Preview button | |
| if (els.btnPreviewStyle) { | |
| let previewVisible = false; | |
| els.btnPreviewStyle.addEventListener("click", () => { | |
| previewVisible = !previewVisible; | |
| if (previewVisible) { | |
| showSubtitlePreview(); | |
| els.btnPreviewStyle.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd"/><path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"/></svg> Ẩn phụ đề'; | |
| } else { | |
| hideSubtitlePreview(); | |
| els.btnPreviewStyle.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" class="icon-btn"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg> Xem trước phụ đề'; | |
| } | |
| }); | |
| } | |
| } | |
| /* --- Init -------------------------------------------------- */ | |
| setStep(1); | |
| setMode("music"); | |
| renderTable(); | |
| checkHealth(); | |
| setupKaraokeEvents(); | |