Download static/app.js from khantbhko/dublab: direct link, hf CLI and curl.
- Browser
- Download file 25.1 kB
-
https://huggingface.co/khantbhko/dublab/resolve/main/static/app.js
- Command line
-
hf download hf://khantbhko/dublab/static/app.js
-
curl -L -o app.js https://huggingface.co/khantbhko/dublab/resolve/main/static/app.js
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) {} | |
| }); | |