dublab / static /app.js
khantbhko's picture
DubLab on Hugging Face (Dockerfile + Flask app, Nara Mini voices)
475d141 verified
Raw History Blame Contribute Delete
25.1 kB
const $ = (s) => document.querySelector(s);
const drop = $('#drop'), fileInput = $('#file'), preview = $('#preview');
let direction = 'en2mm', voice = 'female', engine = 'nara', naraVoice = 'shoon', pollTimer = null, lastJid = null;
let mode = 'file', checkedUrl = '', checkedTitle = '';
let subsOn = false, subBg = 'box', blurOn = false, linkSid = '';
let fileSid = '', pvServer = false, pvStaging = false, pvTimer = null;
function selectGrid(gridSel, attr, cb) {
document.querySelectorAll(gridSel + ' .opt').forEach((b) => {
b.addEventListener('click', () => {
document.querySelectorAll(gridSel + ' .opt').forEach((x) => x.classList.remove('selected'));
b.classList.add('selected');
cb(b.dataset[attr]);
});
});
}
selectGrid('#dirgrid', 'dir', (v) => (direction = v));
// Nara voice follows the gender pick (female -> Shoon, male -> Ko Banyar)
// until the user taps a Nara voice directly.
function syncNaraVoice() {
naraVoice = voice === 'male' ? 'ko-banyar' : 'shoon';
document.querySelectorAll('#naravoicegrid .opt').forEach((x) =>
x.classList.toggle('selected', x.dataset.naravoice === naraVoice));
}
selectGrid('#voicegrid', 'voice', (v) => { voice = v; if (engine === 'nara') syncNaraVoice(); });
selectGrid('#enginegrid', 'engine', (v) => {
engine = v;
$('#naravoicewrap').hidden = v !== 'nara';
if (v === 'nara') syncNaraVoice();
});
selectGrid('#naravoicegrid', 'naravoice', (v) => (naraVoice = v));
selectGrid('#subgrid', 'subs', (v) => {
subsOn = v === 'on';
$('#subbgwrap').hidden = !subsOn;
});
selectGrid('#subbggrid', 'subbg', (v) => (subBg = v));
selectGrid('#blurgrid', 'blur', (v) => { blurOn = v === 'on'; updateBlurUI(); });
// ---- blur box editor: one draggable/resizable box over a media frame ----
// The box is stored as fractions of the frame (x, y, w, h in 0..1), so the
// same editor works over the local <video> preview and over link frames.
function makeBoxEditor(wrap, box) {
const frac = { x: 0.02, y: 0.80, w: 0.96, h: 0.15 };
function draw() {
box.style.left = (frac.x * 100) + '%';
box.style.top = (frac.y * 100) + '%';
box.style.width = (frac.w * 100) + '%';
box.style.height = (frac.h * 100) + '%';
}
function bind(el, resize) {
el.addEventListener('pointerdown', (ev) => {
ev.preventDefault(); ev.stopPropagation();
const r = wrap.getBoundingClientRect();
const sx = ev.clientX, sy = ev.clientY, f0 = { ...frac };
el.setPointerCapture(ev.pointerId);
const move = (e2) => {
const dx = (e2.clientX - sx) / r.width, dy = (e2.clientY - sy) / r.height;
if (resize) {
frac.w = Math.min(Math.max(f0.w + dx, 0.05), 1 - frac.x);
frac.h = Math.min(Math.max(f0.h + dy, 0.03), 1 - frac.y);
} else {
frac.x = Math.min(Math.max(f0.x + dx, 0), 1 - frac.w);
frac.y = Math.min(Math.max(f0.y + dy, 0), 1 - frac.h);
}
draw();
};
const up = () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
});
}
bind(box, false);
bind(box.querySelector('.boxhandle'), true);
draw();
return {
show() { box.hidden = false; draw(); },
hide() { box.hidden = true; },
get() { return [frac.x, frac.y, frac.w, frac.h].map((n) => n.toFixed(4)).join(','); },
};
}
const pvEditor = makeBoxEditor($('#pvwrap'), $('#blurbox'));
const frEditor = makeBoxEditor($('#frwrap'), $('#blurbox2'));
function updateBlurUI() {
$('#bluredit').hidden = !blurOn;
if (!blurOn) { pvEditor.hide(); frEditor.hide(); return; }
if (mode === 'file') {
$('#linkframes').hidden = true;
$('#blurhint').textContent = fileInput.files.length
? 'แ€กแ€•แ€ฑแ€ซแ€บแ€€ video preview แ€™แ€พแ€ฌ subtitle แ€•แ€ฑแ€ซแ€บแ€แ€ฒแ€ท แ€”แ€ฑแ€›แ€ฌแ€‘แ€ญ แ€–แ€ฝแ€„แ€ทแ€บแ€•แ€ผแ€ฎแ€ธ แ€กแ€แ€ซแ€›แ€ฑแ€ฌแ€„แ€บ box แ€€แ€ญแ€ฏ แ€…แ€ฌแ€แ€”แ€บแ€ธ แ€”แ€ฑแ€›แ€ฌแ€žแ€ญแ€ฏแ€ท แ€›แ€ฝแ€พแ€ฑแ€ทแ€‘แ€ฌแ€ธแ€•แ€ซ ยท แ€Šแ€ฌแ€กแ€ฑแ€ฌแ€€แ€บ แ€‘แ€ฑแ€ฌแ€„แ€ทแ€บแ€€ แ€กแ€…แ€€แ€บแ€€แ€ญแ€ฏ แ€†แ€ฝแ€ฒแ€›แ€„แ€บ box แ€กแ€›แ€ฝแ€šแ€บ แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€œแ€ญแ€ฏแ€ทแ€›แ€แ€šแ€บ'
: 'แ€กแ€›แ€„แ€บ video file แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ โ€” preview แ€•แ€ฑแ€ซแ€บแ€™แ€พแ€ฌ box แ€™แ€พแ€แ€บแ€œแ€ญแ€ฏแ€ทแ€›แ€™แ€šแ€บ';
if (fileInput.files.length) pvEditor.show(); else pvEditor.hide();
} else {
$('#linkframes').hidden = false;
$('#blurhint').textContent = 'Link video แ€กแ€แ€ฝแ€€แ€บ server แ€€ frame แ€‘แ€ฏแ€แ€บแ€•แ€ฑแ€ธแ€™แ€šแ€บ โ€” แ€กแ€ฑแ€ฌแ€€แ€บแ€€ แ€แ€œแ€ฏแ€แ€บแ€€แ€ญแ€ฏ แ€”แ€พแ€ญแ€•แ€บแ€•แ€ซ';
pvEditor.hide();
if (!$('#framearea').hidden) frEditor.show();
}
}
preview.addEventListener('loadedmetadata', () => {
if (preview.videoWidth && preview.videoHeight)
$('#pvwrap').style.aspectRatio = preview.videoWidth + ' / ' + preview.videoHeight;
if (blurOn && mode === 'file') pvEditor.show();
});
preview.addEventListener('error', () => {
if (mode === 'file' && fileInput.files.length && !fileSid) showPvFallback();
});
function showPvFallback() {
if (pvServer || !fileInput.files.length) return;
$('#pvfallback').hidden = false;
}
$('#pvserverbtn').addEventListener('click', () => {
const f = fileInput.files[0];
if (!f || pvStaging) return;
pvStaging = true;
const btn = $('#pvserverbtn'), note = $('#pvnote');
note.hidden = true;
btn.disabled = true;
const fd = new FormData();
fd.append('video', f);
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari POST bug)
xhr.open('POST', '/api/jobs/file-frames');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable)
btn.textContent = 'โณ แ€แ€„แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ ' + Math.round(e.loaded / e.total * 100) + '%';
};
xhr.onload = () => {
pvStaging = false; btn.disabled = false;
btn.textContent = 'โ–ถ Server preview แ€‘แ€ฏแ€แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.sid) {
fileSid = j.sid; pvServer = true;
clearTimeout(pvTimer);
$('#pvfallback').hidden = true;
preview.src = j.preview;
preview.load();
if (blurOn)
$('#blurhint').textContent = 'Server preview แ€•แ€ฑแ€ซแ€บแ€™แ€พแ€ฌ subtitle แ€•แ€ฑแ€ซแ€บแ€แ€ฒแ€ท แ€”แ€ฑแ€›แ€ฌแ€‘แ€ญ แ€–แ€ฝแ€„แ€ทแ€บแ€•แ€ผแ€ฎแ€ธ แ€กแ€แ€ซแ€›แ€ฑแ€ฌแ€„แ€บ box แ€€แ€ญแ€ฏ แ€…แ€ฌแ€แ€”แ€บแ€ธ แ€”แ€ฑแ€›แ€ฌแ€žแ€ญแ€ฏแ€ท แ€›แ€ฝแ€พแ€ฑแ€ทแ€‘แ€ฌแ€ธแ€•แ€ซ ยท แ€Šแ€ฌแ€กแ€ฑแ€ฌแ€€แ€บ แ€‘แ€ฑแ€ฌแ€„แ€ทแ€บแ€€ แ€กแ€…แ€€แ€บแ€€แ€ญแ€ฏ แ€†แ€ฝแ€ฒแ€›แ€„แ€บ box แ€กแ€›แ€ฝแ€šแ€บ แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€œแ€ญแ€ฏแ€ทแ€›แ€แ€šแ€บ';
} else {
note.textContent = j.error || 'preview แ€‘แ€ฏแ€แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; note.hidden = false;
}
} catch (e) { note.textContent = 'preview แ€‘แ€ฏแ€แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; note.hidden = false; }
};
xhr.onerror = () => {
pvStaging = false; btn.disabled = false;
btn.textContent = 'โ–ถ Server preview แ€‘แ€ฏแ€แ€บแ€™แ€šแ€บ';
note.textContent = 'แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ โ€” connection แ€…แ€…แ€บแ€•แ€ซ'; note.hidden = false;
};
xhr.send(fd);
});
drop.addEventListener('click', () => fileInput.click());
['dragover', 'dragenter'].forEach((e) => drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add('over'); }));
['dragleave', 'drop'].forEach((e) => drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove('over'); }));
drop.addEventListener('drop', (ev) => {
if (ev.dataTransfer.files.length) handleFile(ev.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
function handleFile(f) {
if (!/^video\//.test(f.type) && !/\.(mp4|mov|mkv|webm|m4v)$/i.test(f.name)) {
alert('แ€—แ€ฎแ€’แ€ฎแ€šแ€ญแ€ฏแ€–แ€ญแ€ฏแ€„แ€บแ€•แ€ฒ แ€แ€„แ€บแ€œแ€ญแ€ฏแ€ทแ€›แ€แ€šแ€บ'); return;
}
fileSid = ''; pvServer = false;
$('#pvfallback').hidden = true;
preview.src = URL.createObjectURL(f);
// Some codecs (AV1 in an .mp4) never load in Safari's <video> โ€” if
// no frame data arrives quickly, offer a server-made preview.
clearTimeout(pvTimer);
pvTimer = setTimeout(() => {
if (!fileSid && preview.readyState < 2) showPvFallback();
}, 3500);
$('#pvwrap').hidden = false;
const meta = $('#filemeta');
meta.hidden = false;
meta.textContent = `${f.name} ยท ${(f.size / 1048576).toFixed(1)} MB`;
$('#card-opts').hidden = false;
drop.querySelector('.drop-title').innerHTML = 'โœ… แ€›แ€ฝแ€ฑแ€ธแ€•แ€ผแ€ฎแ€ธแ€•แ€ผแ€ฎ <span class="dim">โ€” แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€แ€ปแ€„แ€บแ€›แ€„แ€บ แ€”แ€พแ€ญแ€•แ€บแ€•แ€ซ</span>';
updateBlurUI();
$('#card-opts').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ---- source mode: file upload or pasted link ----
function setMode(m) {
mode = m;
$('#mode-file').classList.toggle('selected', m === 'file');
$('#mode-link').classList.toggle('selected', m === 'link');
$('#panel-file').hidden = m !== 'file';
$('#panel-link').hidden = m !== 'link';
const hasFile = fileInput.files && fileInput.files.length > 0;
$('#card-opts').hidden = !(m === 'file' ? hasFile : !!checkedUrl);
updateBlurUI();
if (m === 'link') $('#urlinput').focus();
}
$('#mode-file').addEventListener('click', () => setMode('file'));
$('#mode-link').addEventListener('click', () => setMode('link'));
$('#pastebtn').addEventListener('click', async () => {
try {
const t = await navigator.clipboard.readText();
if (t) { $('#urlinput').value = t.trim(); checkUrl(); return; }
} catch (e) { /* clipboard blocked โ€” let the user paste by hand */ }
$('#urlinput').focus();
});
function fmtDur(s) {
if (!s) return '';
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function checkUrl() {
const url = $('#urlinput').value.trim();
const errEl = $('#urlerr'), btn = $('#checkbtn');
errEl.hidden = true; $('#urlinfo').hidden = true;
checkedUrl = ''; checkedTitle = '';
if (!url) { errEl.textContent = 'link แ€กแ€›แ€„แ€บ แ€‘แ€Šแ€ทแ€บแ€•แ€ซ'; errEl.hidden = false; return; }
btn.disabled = true; btn.textContent = 'โณ แ€…แ€…แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ';
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari POST bug)
xhr.open('POST', '/api/jobs/check-url');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
btn.disabled = false; btn.textContent = '๐Ÿ” แ€…แ€…แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.ok) {
checkedUrl = j.url; checkedTitle = j.title || '';
$('#urltitle').textContent = j.title || '(แ€แ€ฑแ€ซแ€„แ€บแ€ธแ€…แ€‰แ€บ แ€™แ€›แ€พแ€ญ)';
$('#urlmeta').textContent =
[j.site, j.duration ? 'โฑ ' + fmtDur(j.duration) : ''].filter(Boolean).join(' ยท ');
const th = $('#urlthumb');
if (j.thumbnail) { th.src = j.thumbnail; th.hidden = false; } else th.hidden = true;
$('#urlinfo').hidden = false;
$('#card-opts').hidden = false;
$('#card-opts').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
errEl.textContent = j.error || 'แ€…แ€…แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false;
}
} catch (e) { errEl.textContent = 'แ€…แ€…แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false; }
};
xhr.onerror = () => { btn.disabled = false; btn.textContent = '๐Ÿ” แ€…แ€…แ€บแ€™แ€šแ€บ'; errEl.textContent = 'แ€…แ€…แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false; };
xhr.send(JSON.stringify({ url: url }));
}
$('#checkbtn').addEventListener('click', checkUrl);
$('#urlinput').addEventListener('input', () => {
// editing the link after a successful check invalidates the check
if (checkedUrl && $('#urlinput').value.trim() !== checkedUrl) {
checkedUrl = ''; checkedTitle = '';
$('#urlinfo').hidden = true; $('#card-opts').hidden = true;
// blur frames were fetched for the old link โ€” drop them too
linkSid = ''; $('#framearea').hidden = true; frEditor.hide();
}
});
// ---- link mode: fetch sample frames so the blur box can be marked ----
$('#framesbtn').addEventListener('click', () => {
const errEl = $('#frameserr'), btn = $('#framesbtn');
errEl.hidden = true;
if (!checkedUrl) { checkUrl(); return; }
btn.disabled = true; btn.textContent = 'โณ Frame แ€‘แ€ฏแ€แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ (video แ€†แ€ฝแ€ฒแ€แ€ปแ€”แ€ฑแ€แ€šแ€บ)';
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari POST bug)
xhr.open('POST', '/api/jobs/link-frames');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
btn.disabled = false; btn.textContent = '๐Ÿ–ผ๏ธ Frame แ€‘แ€ฏแ€แ€บแ€•แ€ผแ€ฎแ€ธ box แ€™แ€พแ€แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.sid) {
linkSid = j.sid;
$('#frwrap').style.aspectRatio = j.w + ' / ' + j.h;
const chips = $('#framechips');
chips.innerHTML = '';
j.frames.forEach((src, i) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = 'Frame ' + (i + 1);
if (i === 0) b.classList.add('on');
b.addEventListener('click', () => {
chips.querySelectorAll('button').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
$('#frameimg').src = src;
});
chips.appendChild(b);
});
$('#frameimg').src = j.frames[0];
$('#framearea').hidden = false;
if (blurOn) frEditor.show();
} else {
errEl.textContent = j.error || 'frame แ€‘แ€ฏแ€แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false;
}
} catch (e) { errEl.textContent = 'frame แ€‘แ€ฏแ€แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false; }
};
xhr.onerror = () => { btn.disabled = false; btn.textContent = '๐Ÿ–ผ๏ธ Frame แ€‘แ€ฏแ€แ€บแ€•แ€ผแ€ฎแ€ธ box แ€™แ€พแ€แ€บแ€™แ€šแ€บ'; errEl.textContent = 'frame แ€‘แ€ฏแ€แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'; errEl.hidden = false; };
xhr.send(JSON.stringify({ url: checkedUrl }));
});
$('#go').addEventListener('click', () => {
if (mode === 'link') {
if (!checkedUrl) { checkUrl(); return; }
const btn = $('#go');
btn.disabled = true; btn.textContent = 'โณ แ€แ€„แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ';
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari POST bug)
xhr.open('POST', '/api/jobs/from-url');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.job_id) showProgress(j.job_id);
else showError(j.error || 'แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ');
} catch (e) { showError('แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'); }
};
xhr.onerror = () => { btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ'; showError('แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'); };
xhr.send(JSON.stringify({
url: checkedUrl, title: checkedTitle, direction: direction, voice: voice, engine: engine,
nara_voice: naraVoice,
subs: subsOn ? 1 : 0, sub_bg: subBg,
blur: blurOn && linkSid ? frEditor.get() : '',
sid: linkSid || '',
}));
return;
}
if (fileSid) {
// file already staged for the server preview โ€” reuse that upload
const btn = $('#go');
btn.disabled = true; btn.textContent = 'โณ แ€แ€„แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ';
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari POST bug)
xhr.open('POST', '/api/jobs/from-file');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.job_id) showProgress(j.job_id);
else showError(j.error || 'แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ');
} catch (e) { showError('แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'); }
};
xhr.onerror = () => { btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ'; showError('แ€แ€„แ€บแ€™แ€›แ€˜แ€ฐแ€ธ'); };
xhr.send(JSON.stringify({
sid: fileSid, direction: direction, voice: voice, engine: engine,
nara_voice: naraVoice,
subs: subsOn ? 1 : 0, sub_bg: subBg,
blur: blurOn ? pvEditor.get() : '',
}));
return;
}
const f = fileInput.files[0];
if (!f) return;
const btn = $('#go');
btn.disabled = true; btn.textContent = 'โณ แ€แ€„แ€บแ€”แ€ฑแ€แ€šแ€บโ€ฆ';
const fd = new FormData();
fd.append('video', f); fd.append('direction', direction); fd.append('voice', voice); fd.append('engine', engine); fd.append('nara_voice', naraVoice);
fd.append('subs', subsOn ? '1' : '0'); fd.append('sub_bg', subBg);
if (blurOn) fd.append('blur', pvEditor.get());
// NOTE: iOS Safari's fetch() throws "The string did not match the expected
// pattern" on FormData POST uploads (WebKit bug) โ€” XHR works reliably.
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/jobs');
xhr.onload = () => {
btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ';
try {
const j = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && j.job_id) showProgress(j.job_id);
else showError(j.error || 'upload failed');
} catch (e) { showError('upload failed'); }
};
xhr.onerror = () => { btn.disabled = false; btn.textContent = 'โœจ Dub แ€…แ€œแ€ฏแ€•แ€บแ€™แ€šแ€บ'; showError('upload failed'); };
xhr.send(fd);
});
function showProgress(jid) {
try { localStorage.setItem('dublab_job', jid); } catch (e) {}
const jl = $('#joblink');
if (jl) { const u = location.origin + '/r/' + jid; jl.href = u; jl.textContent = u; }
['card-upload', 'card-opts', 'card-result', 'card-error'].forEach((id) => ($('#' + id).hidden = true));
$('#card-progress').hidden = false;
$('#card-progress').scrollIntoView({ behavior: 'smooth' });
const order = ['download', 'blur', 'audio', 'transcribe', 'translate', 'voice', 'mux'];
const stopLog = startLiveLog(jid);
const stopAll = () => { clearInterval(pollTimer); stopLog(); };
pollTimer = setInterval(async () => {
try {
const r = await fetch('/api/jobs/' + jid);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'status failed');
$('#barfill').style.width = j.pct + '%';
$('#stagelabel').textContent = j.label + (j.pct ? ` ยท ${j.pct}%` : '');
document.querySelectorAll('#stages li').forEach((li) => {
const s = li.dataset.s;
li.classList.remove('active', 'done');
if (j.stage === 'done' || order.indexOf(s) < order.indexOf(j.stage)) li.classList.add('done');
else if (s === j.stage) li.classList.add('active');
});
if (j.status === 'done') { stopAll(); showResult(jid, j.has_srt); }
if (j.status === 'failed') { stopAll(); showError(j.error || 'แ€กแ€™แ€พแ€ฌแ€ธแ€›แ€พแ€ญแ€แ€šแ€บ'); }
} catch (e) { stopAll(); showError(e.message); }
}, 2000);
}
// Live job logs โ€” polls /api/jobs/<jid>/log, auto-scrolls.
function startLiveLog(jid) {
const pre = $('#livelog');
pre.textContent = '';
const t = setInterval(async () => {
try {
const r = await fetch('/api/jobs/' + jid + '/log');
const j = await r.json();
if (r.ok && typeof j.log === 'string' && j.log !== pre.textContent) {
pre.textContent = j.log;
pre.scrollTop = pre.scrollHeight;
}
} catch (e) { /* keep old content */ }
}, 2000);
return () => clearInterval(t);
}
function showResult(jid, hasSrt) {
$('#card-progress').hidden = true;
$('#card-result').hidden = false;
lastJid = jid;
$('#resultvideo').src = '/r/' + jid;
setSpeed(1);
$('#download').href = '/r/' + jid;
const srt = $('#srtdl');
if (srt) { srt.hidden = !hasSrt; if (hasSrt) srt.href = '/api/jobs/' + jid + '/srt'; }
$('#card-result').scrollIntoView({ behavior: 'smooth' });
}
function showError(msg) {
if (pollTimer) clearInterval(pollTimer);
['card-upload', 'card-opts', 'card-progress', 'card-result'].forEach((id) => ($('#' + id).hidden = true));
$('#card-error').hidden = false;
$('#errmsg').textContent = msg;
}
$('#again').addEventListener('click', () => { try { localStorage.removeItem('dublab_job'); } catch (e) {} location.reload(); });
$('#retry').addEventListener('click', () => { try { localStorage.removeItem('dublab_job'); } catch (e) {} location.reload(); });
// ---- reviews ----
function makeStars(id) {
const el = document.getElementById(id);
let val = 0;
const btns = el.querySelectorAll('button');
btns.forEach((b) => b.addEventListener('click', () => {
val = +b.dataset.s;
btns.forEach((x) => (x.textContent = +x.dataset.s <= val ? 'โญ' : 'โ˜†'));
}));
return {
get: () => val,
reset: () => { val = 0; btns.forEach((x) => (x.textContent = 'โ˜†')); },
};
}
const rstars = makeStars('rstars'), fstars = makeStars('fstars');
function postReview(stars, msgEl, jid, doneEl, starsObj) {
const msg = msgEl.value.trim();
if (!stars && !msg) { alert('star แ€’แ€ซแ€™แ€พแ€™แ€Ÿแ€ฏแ€แ€บ แ€…แ€ฌแ€แ€…แ€บแ€แ€ฏแ€แ€ฏ แ€•แ€ฑแ€ธแ€•แ€ซ'); return; }
const xhr = new XMLHttpRequest(); // XHR, not fetch (iOS Safari FormData/POST bug)
xhr.open('POST', '/api/reviews');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
let ok = false, err = '';
try { const j = JSON.parse(xhr.responseText); ok = xhr.status < 300 && !!j.ok; err = j.error || ''; }
catch (e) { /* ignore */ }
if (ok) {
doneEl.hidden = false;
msgEl.value = ''; starsObj.reset();
setTimeout(() => (doneEl.hidden = true), 4000);
} else if (err) alert(err);
else alert('แ€•แ€ญแ€ฏแ€ทแ€™แ€›แ€˜แ€ฐแ€ธ');
};
xhr.onerror = () => alert('แ€•แ€ญแ€ฏแ€ทแ€™แ€›แ€˜แ€ฐแ€ธ');
xhr.send(JSON.stringify({ stars: stars, message: msg, job_id: jid || '' }));
}
$('#rsend').addEventListener('click', () =>
postReview(rstars.get(), $('#rmsg'), lastJid, $('#rdone'), rstars));
$('#fsend').addEventListener('click', () =>
postReview(fstars.get(), $('#fmsg'), null, $('#fdone'), fstars));
// ---- playback speed (review request: แ€กแ€”แ€พแ€ฑแ€ธ/แ€กแ€™แ€ผแ€”แ€บ) ----
function setSpeed(r) {
const v = $('#resultvideo');
if (v) v.playbackRate = r;
const slider = $('#speedslider'), val = $('#speedval');
if (slider) slider.value = r;
if (val) val.textContent = r.toFixed(2) + 'x';
document.querySelectorAll('#speedrow button').forEach((x) =>
x.classList.toggle('on', parseFloat(x.dataset.r) === r));
}
document.querySelectorAll('#speedrow button').forEach((b) => b.addEventListener('click', () => {
setSpeed(parseFloat(b.dataset.r));
}));
(function () {
const slider = $('#speedslider');
if (slider) slider.addEventListener('input', () => setSpeed(parseFloat(slider.value)));
})();
// ---- update notice box (show once per device) ----
(function () {
const KEY = 'dublab_notice_nllb_v3';
const notice = $('#updatenotice');
if (!notice) return;
let seen = false;
try { seen = localStorage.getItem(KEY) === '1'; } catch (e) { /* private mode */ }
if (!seen) notice.hidden = false;
const okBtn = $('#noticeok');
if (okBtn) okBtn.addEventListener('click', () => {
try { if ($('#noshowagain') && $('#noshowagain').checked) localStorage.setItem(KEY, '1'); } catch (e) {}
notice.hidden = true;
});
})();
// ---- tabs: Dubbing / SRT translator ----
(function () {
const tabs = document.querySelectorAll('#tabs .tab');
if (!tabs.length) return;
const panels = { dub: document.getElementById('tab-dub'), srt: document.getElementById('tab-srt') };
tabs.forEach(btn => btn.addEventListener('click', () => {
tabs.forEach(b => b.classList.toggle('on', b === btn));
const name = btn.dataset.tab;
if (panels.dub) panels.dub.hidden = name !== 'dub';
if (panels.srt) panels.srt.hidden = name !== 'srt';
}));
})();
// ---- resume: reopening the page restores the running/finished job view ----
(function resumeJob() {
let jid = null;
try { jid = localStorage.getItem('dublab_job'); } catch (e) {}
if (!jid) return;
fetch('/api/jobs/' + jid).then(async (r) => {
const j = await r.json().catch(() => ({}));
if (!r.ok) { try { localStorage.removeItem('dublab_job'); } catch (e) {} return; }
if (j.status === 'processing') showProgress(jid);
else if (j.status === 'done') showResult(jid, j.has_srt);
else if (j.status === 'failed') { try { localStorage.removeItem('dublab_job'); } catch (e) {} showError(j.error || 'แ€กแ€™แ€พแ€ฌแ€ธแ€›แ€พแ€ญแ€แ€šแ€บ'); }
}).catch(() => {});
})();
const cpBtn = $('#copylink');
if (cpBtn) cpBtn.addEventListener('click', async () => {
const a = $('#joblink');
if (!a || !a.href) return;
try { await navigator.clipboard.writeText(a.href); cpBtn.textContent = 'โœ… Copied'; setTimeout(() => { cpBtn.textContent = '๐Ÿ“‹ Copy link'; }, 1500); } catch (e) {}
});