AutoSUB1 / static /app.js
CVNSS's picture
Upload 7 files
ebc2b00 verified
/* ============================================================
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,
},
cvnssMode: false, // false = Vietnamese (CQN), true = CVNSS4.0
originalTexts: [], // store original Vietnamese texts for round-trip conversion
subtitlePreviewVisible: false, // track overlay state
};
/* --- 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"),
// CVNSS Toggle
btnToggleCvnss: $("btnToggleCvnss"),
cvnssToggleLabel:$("cvnssToggleLabel"),
// 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,
};
}
/* --- Parse SRT timestamp to seconds ------------------------- */
function srtTimeToSeconds(timeStr) {
if (!timeStr) return 0;
// Format: HH:MM:SS,mmm or HH:MM:SS.mmm
const parts = timeStr.replace(',', '.').split(':');
if (parts.length !== 3) return 0;
const h = parseFloat(parts[0]) || 0;
const m = parseFloat(parts[1]) || 0;
const s = parseFloat(parts[2]) || 0;
return h * 3600 + m * 60 + s;
}
/* --- Get current subtitle text at a given time -------------- */
function getSubtitleAtTime(currentTime) {
const segs = state.segments;
if (!segs || !segs.length) return null;
for (let i = 0; i < segs.length; i++) {
const start = srtTimeToSeconds(segs[i].start);
const end = srtTimeToSeconds(segs[i].end);
if (currentTime >= start && currentTime <= end) {
return { text: segs[i].text, index: i, start, end };
}
}
return null;
}
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)";
}
// Show actual subtitle text synced to video time, or demo text if no subtitles
const video = els.preview;
const currentTime = video && video.duration ? video.currentTime : 0;
const activeSub = getSubtitleAtTime(currentTime);
if (activeSub && activeSub.text) {
// Show real subtitle content
if (ks.karaokeMode && activeSub.start !== undefined) {
// Karaoke word-by-word: highlight words progressively
const words = activeSub.text.split(/\s+/);
const duration = activeSub.end - activeSub.start;
const elapsed = currentTime - activeSub.start;
const progress = duration > 0 ? elapsed / duration : 0;
const highlightCount = Math.ceil(progress * words.length);
let html = '';
words.forEach((word, i) => {
if (i < highlightCount) {
html += '<span class="ks-word-active" style="color:' + ks.highlight + '">' + escapeHtml(word) + '</span> ';
} else {
html += escapeHtml(word) + ' ';
}
});
textEl.innerHTML = html.trim();
} else {
textEl.textContent = activeSub.text;
}
overlay.hidden = false;
} else if (state.segments.length > 0) {
// Has subtitles but none active at this time — hide overlay
textEl.textContent = '';
overlay.hidden = true;
} else {
// No subtitles at all — show demo text
if (ks.karaokeMode) {
textEl.innerHTML = 'Ph\u1EE5 \u0111\u1EC1 <span class="ks-word-active" style="color:' + ks.highlight + '">m\u1EABu</span> — Xem tr\u01B0\u1EDBc <span class="ks-word-active" style="color:' + ks.highlight + '">Karaoke</span>';
} else {
textEl.textContent = "Ph\u1EE5 \u0111\u1EC1 m\u1EABu — Xem tr\u01B0\u1EDBc Karaoke";
}
}
}
/* --- HTML escape helper ------------------------------------ */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showSubtitlePreview() {
state.subtitlePreviewVisible = true;
if (els.subPreviewOverlay) {
els.subPreviewOverlay.hidden = false;
updatePreviewOverlay();
}
startSubtitleSync();
}
function hideSubtitlePreview() {
state.subtitlePreviewVisible = false;
if (els.subPreviewOverlay) els.subPreviewOverlay.hidden = true;
stopSubtitleSync();
}
/* --- Live subtitle sync with video playback ----------------- */
let subtitleSyncRAF = null;
let lastSyncCollectTime = 0;
function syncSubtitleLoop() {
if (!state.subtitlePreviewVisible) return;
// Re-collect segments from table every 500ms (not every frame, for perf)
const now = Date.now();
if (now - lastSyncCollectTime > 500) {
const tableSegs = collectSegmentsFromTable();
if (tableSegs.length) state.segments = tableSegs;
lastSyncCollectTime = now;
}
updatePreviewOverlay();
subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop);
}
function startSubtitleSync() {
stopSubtitleSync();
lastSyncCollectTime = 0;
subtitleSyncRAF = requestAnimationFrame(syncSubtitleLoop);
}
function stopSubtitleSync() {
if (subtitleSyncRAF) {
cancelAnimationFrame(subtitleSyncRAF);
subtitleSyncRAF = null;
}
}
// 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ụ đề';
}
});
}
}
/* --- CVNSS4.0 Toggle --------------------------------------- */
function toggleCvnss() {
if (typeof CVNSSConverter === 'undefined') {
setStatus('Th\u01B0 vi\u1EC7n CVNSS4.0 ch\u01B0a \u0111\u01B0\u1EE3c t\u1EA3i. Vui l\u00F2ng refresh trang.', 'error');
return;
}
// Collect current table data
state.segments = collectSegmentsFromTable();
if (!state.segments.length) {
setStatus('Ch\u01B0a c\u00F3 subtitle \u0111\u1EC3 chuy\u1EC3n \u0111\u1ED5i.', 'error');
return;
}
if (!state.cvnssMode) {
// Vietnamese → CVNSS4.0
// Save originals for round-trip
state.originalTexts = state.segments.map(s => s.text);
state.segments = state.segments.map(seg => {
try {
const result = CVNSSConverter.convert(seg.text, 'cqn');
return { ...seg, text: result.cvss };
} catch (_) {
return seg;
}
});
state.cvnssMode = true;
} else {
// CVNSS4.0 → Vietnamese
// Use saved originals if available, otherwise convert back
if (state.originalTexts.length === state.segments.length) {
state.segments = state.segments.map((seg, i) => ({
...seg,
text: state.originalTexts[i],
}));
} else {
// Fallback: convert from CVSS back
state.segments = state.segments.map(seg => {
try {
const result = CVNSSConverter.convert(seg.text, 'cvss');
return { ...seg, text: result.cqn };
} catch (_) {
return seg;
}
});
}
state.cvnssMode = false;
state.originalTexts = [];
}
renderTable();
updateCvnssUI();
}
function updateCvnssUI() {
const btn = els.btnToggleCvnss;
const label = els.cvnssToggleLabel;
if (btn) btn.classList.toggle('cvnss-active', state.cvnssMode);
if (label) label.textContent = state.cvnssMode ? 'Ti\u1EBFng Vi\u1EC7t' : 'CVNSS4.0';
}
// Wire up CVNSS toggle button
if (els.btnToggleCvnss) {
els.btnToggleCvnss.addEventListener('click', toggleCvnss);
}
/* --- Init -------------------------------------------------- */
setStep(1);
setMode("music");
renderTable();
checkHealth();
setupKaraokeEvents();
updateCvnssUI();