import * as THREE from 'three'; // ---------------------------------------------------------------- helpers const $ = (s) => document.querySelector(s); const el = { stage: $('#stage'), canvas: $('#gl'), video: $('#video'), videoSel: $('#videoSelect'), qualSel: $('#qualitySelect'), refreshBtn: $('#refreshBtn'), projBtn: $('#projBtn'), panelBtn: $('#panelBtn'), panelBadge: $('#panelBadge'), panel: $('#panel'), panelClose: $('#panelClose'), panelVideoName: $('#panelVideoName'), panelVariants: $('#panelVariants'), panelJobs: $('#panelJobs'), clearJobsBtn: $('#clearJobsBtn'), encoderInfo: $('#encoderInfo'), controls: $('#controls'), seek: $('#seek'), playBtn: $('#playBtn'), time: $('#time'), muteBtn: $('#muteBtn'), vol: $('#vol'), rate: $('#rate'), resetBtn: $('#resetBtn'), fsBtn: $('#fsBtn'), bigPlay: $('#bigPlay'), toast: $('#toast'), info: $('#info'), hint: $('#hint'), spinner: $('#spinner'), }; const video = el.video; const LS = { get(k, d = null) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } }, }; const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); function fmtTime(s) { if (!isFinite(s) || s < 0) s = 0; s = Math.floor(s); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; const mm = h ? String(m).padStart(2, '0') : String(m); return `${h ? h + ':' : ''}${mm}:${String(sec).padStart(2, '0')}`; } function fmtBytes(b) { if (!b) return '—'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return `${b.toFixed(i >= 3 ? 2 : i >= 2 ? 1 : 0)} ${u[i]}`; } const fmtBps = (bps) => bps ? `${(bps / 1e6).toFixed(1)} Mbps` : '—'; const fmtRate = (r) => String(r).replace(/[mM]$/, ' Mbps').replace(/[kK]$/, ' kbps'); const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); async function api(url, opts = {}) { const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts }); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || `${r.status} ${r.statusText}`); return j; } let toastTimer = null; function toast(msg, { action = null, timeout = 4500, error = false } = {}) { clearTimeout(toastTimer); el.toast.innerHTML = `${esc(msg)}`; el.toast.classList.toggle('error', error); if (action) { const b = document.createElement('button'); b.textContent = action.label; b.onclick = () => { hideToast(); action.fn(); }; el.toast.appendChild(b); } el.toast.hidden = false; if (timeout) toastTimer = setTimeout(hideToast, timeout); } function hideToast() { el.toast.hidden = true; } // ---------------------------------------------------------------- state const state = { config: null, videos: [], jobs: [], current: null, // entry from /api/videos quality: 'original', projection: 'equirectangular', lon: 0, lat: 0, fov: 75, velLon: 0, velLat: 0, pendingSeek: null, wantPlay: false, }; // ---------------------------------------------------------------- three.js const renderer = new THREE.WebGLRenderer({ canvas: el.canvas, antialias: false, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100); const geometry = new THREE.SphereGeometry(500, 80, 50); geometry.scale(-1, 1, 1); // view from the inside const texture = new THREE.VideoTexture(video); texture.colorSpace = THREE.SRGBColorSpace; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = false; const sphere = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map: texture })); scene.add(sphere); const lookTarget = new THREE.Vector3(); let needsRender = true; function resize() { const w = el.stage.clientWidth, h = el.stage.clientHeight; if (!w || !h) return; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); needsRender = true; } new ResizeObserver(resize).observe(el.stage); function applyCamera() { state.lat = clamp(state.lat, -85, 85); const phi = THREE.MathUtils.degToRad(state.lat); const theta = THREE.MathUtils.degToRad(state.lon); // lon = 0 looks at the centre of the equirect image; +lon turns right. lookTarget.set(-Math.cos(phi) * Math.cos(theta), Math.sin(phi), -Math.cos(phi) * Math.sin(theta)); camera.lookAt(lookTarget); if (camera.fov !== state.fov) { camera.fov = state.fov; camera.updateProjectionMatrix(); } } let hasNewFrame = false; if ('requestVideoFrameCallback' in video) { const onFrame = () => { hasNewFrame = true; video.requestVideoFrameCallback(onFrame); }; video.requestVideoFrameCallback(onFrame); } function loop() { requestAnimationFrame(loop); if (state.projection !== 'equirectangular') return; // inertia after a fling if (!dragging && (Math.abs(state.velLon) > 0.02 || Math.abs(state.velLat) > 0.02)) { state.lon += state.velLon; state.lat += state.velLat; state.velLon *= 0.92; state.velLat *= 0.92; needsRender = true; } const playing = !video.paused && !video.ended && video.readyState >= 2; const frameReady = 'requestVideoFrameCallback' in video ? hasNewFrame : playing; if (!needsRender && !frameReady) return; hasNewFrame = false; needsRender = false; applyCamera(); renderer.render(scene, camera); } // ---------------------------------------------------------------- look controls let dragging = false; const pointers = new Map(); let downX = 0, downY = 0, downT = 0, lastX = 0, lastY = 0, lastT = 0, pinchDist = 0, pinchFov = 75; function degPerPx() { return state.fov / el.stage.clientHeight; } el.canvas.addEventListener('pointerdown', e => { el.canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.size === 1) { dragging = true; el.canvas.classList.add('dragging'); downX = lastX = e.clientX; downY = lastY = e.clientY; downT = lastT = performance.now(); state.velLon = state.velLat = 0; } else if (pointers.size === 2) { const [a, b] = [...pointers.values()]; pinchDist = Math.hypot(a.x - b.x, a.y - b.y); pinchFov = state.fov; } wake(); }); el.canvas.addEventListener('pointermove', e => { if (!pointers.has(e.pointerId)) return; pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.size === 2) { const [a, b] = [...pointers.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (pinchDist > 0) { state.fov = clamp(pinchFov * (pinchDist / d), 30, 110); needsRender = true; } return; } if (!dragging) return; const dx = e.clientX - lastX, dy = e.clientY - lastY; const s = degPerPx(); state.lon -= dx * s; // grab-and-drag: image follows the pointer state.lat += dy * s; const now = performance.now(), dt = Math.max(1, now - lastT); state.velLon = -dx * s * (16 / dt); state.velLat = dy * s * (16 / dt); lastX = e.clientX; lastY = e.clientY; lastT = now; needsRender = true; }); function endPointer(e) { if (!pointers.has(e.pointerId)) return; pointers.delete(e.pointerId); if (pointers.size === 0) { dragging = false; el.canvas.classList.remove('dragging'); const moved = Math.hypot(e.clientX - downX, e.clientY - downY); const quick = performance.now() - downT < 350; if (performance.now() - lastT > 80) state.velLon = state.velLat = 0; // held still before release if (moved < 6 && quick && e.type === 'pointerup') { if (e.pointerType === 'mouse') togglePlay(); else if (el.stage.classList.contains('idle')) wake(); else el.stage.classList.add('idle'); } } else if (pointers.size === 1) { const p = [...pointers.values()][0]; lastX = p.x; lastY = p.y; lastT = performance.now(); state.velLon = state.velLat = 0; } } el.canvas.addEventListener('pointerup', endPointer); el.canvas.addEventListener('pointercancel', endPointer); el.canvas.addEventListener('wheel', e => { e.preventDefault(); state.fov = clamp(state.fov + e.deltaY * 0.05, 30, 110); needsRender = true; wake(); }, { passive: false }); el.canvas.addEventListener('dblclick', toggleFullscreen); video.addEventListener('dblclick', toggleFullscreen); video.addEventListener('click', () => { if (state.projection === 'flat') togglePlay(); }); function resetView() { state.lon = 0; state.lat = 0; state.fov = 75; state.velLon = state.velLat = 0; needsRender = true; } // ---------------------------------------------------------------- projection function setProjection(p) { state.projection = p; el.stage.classList.toggle('flat', p === 'flat'); el.projBtn.textContent = p === 'flat' ? '平面' : '360°'; el.projBtn.classList.toggle('active', p !== 'flat'); el.hint.style.display = p === 'flat' ? 'none' : ''; if (p !== 'flat') { resize(); needsRender = true; } } el.projBtn.addEventListener('click', () => { setProjection(state.projection === 'flat' ? 'equirectangular' : 'flat'); if (state.current) LS.set('proj:' + state.current.name, state.projection); }); // ---------------------------------------------------------------- playback function togglePlay() { if (!video.src) return; if (video.paused) video.play().catch(err => toast(`無法播放:${err.message}`, { error: true })); else video.pause(); } el.playBtn.addEventListener('click', togglePlay); el.bigPlay.addEventListener('click', togglePlay); function updatePlayUI() { const paused = video.paused; el.playBtn.textContent = paused ? '▶' : '❚❚'; el.bigPlay.hidden = !paused || !video.src; if (paused) wake(); } video.addEventListener('play', () => { updatePlayUI(); needsRender = true; }); video.addEventListener('pause', updatePlayUI); video.addEventListener('ended', updatePlayUI); let seeking = false; video.addEventListener('loadedmetadata', () => { if (state.pendingSeek != null && isFinite(video.duration)) { video.currentTime = clamp(state.pendingSeek, 0, Math.max(0, video.duration - 1)); } state.pendingSeek = null; updateTime(); updatePlayUI(); }); video.addEventListener('loadeddata', () => { needsRender = true; }); video.addEventListener('seeked', () => { needsRender = true; }); video.addEventListener('timeupdate', () => { if (!seeking) updateTime(); savePosition(); }); video.addEventListener('progress', updateBuffered); video.addEventListener('durationchange', updateTime); video.addEventListener('waiting', () => { el.spinner.hidden = false; }); video.addEventListener('seeking', () => { el.spinner.hidden = false; }); video.addEventListener('playing', () => { el.spinner.hidden = true; }); video.addEventListener('canplay', () => { el.spinner.hidden = true; }); video.addEventListener('error', () => { el.spinner.hidden = true; const code = video.error?.code; const msg = { 1: '載入被中止', 2: '網路錯誤,無法讀取影片', 3: '解碼失敗(影片可能損壞或編碼不支援)', 4: '瀏覽器不支援這個影片格式/編碼(例如 HEVC),請改用轉檔後的畫質', }[code] || '未知錯誤'; toast(`播放失敗:${msg}`, { error: true, timeout: 10000 }); }); function updateTime() { const d = video.duration || 0, t = video.currentTime || 0; el.time.textContent = `${fmtTime(t)} / ${fmtTime(d)}`; const pct = d ? (t / d) * 100 : 0; el.seek.value = String(Math.round(pct * 10)); el.seek.style.setProperty('--played', pct + '%'); updateBuffered(); } function updateBuffered() { const d = video.duration || 0; let end = 0; for (let i = 0; i < video.buffered.length; i++) { if (video.buffered.start(i) <= video.currentTime + 0.5 && video.buffered.end(i) > end) end = video.buffered.end(i); } el.seek.style.setProperty('--buffered', (d ? (end / d) * 100 : 0) + '%'); } el.seek.addEventListener('input', () => { seeking = true; const d = video.duration || 0; const t = (el.seek.value / 1000) * d; el.time.textContent = `${fmtTime(t)} / ${fmtTime(d)}`; el.seek.style.setProperty('--played', (el.seek.value / 10) + '%'); }); el.seek.addEventListener('change', () => { seeking = false; const d = video.duration || 0; if (d) video.currentTime = (el.seek.value / 1000) * d; }); function seekBy(sec) { if (!video.duration) return; video.currentTime = clamp(video.currentTime + sec, 0, video.duration); wake(); } let saveTimer = 0; function savePosition() { const now = Date.now(); if (now - saveTimer < 3000 || !state.current || !video.duration) return; saveTimer = now; LS.set('pos:' + state.current.name, video.currentTime); } // volume / rate video.volume = LS.get('volume', 1); video.muted = LS.get('muted', false); el.vol.value = video.volume; function updateVolumeUI() { el.muteBtn.textContent = video.muted || video.volume === 0 ? '🔇' : video.volume < 0.5 ? '🔉' : '🔊'; el.vol.value = video.muted ? 0 : video.volume; } el.vol.addEventListener('input', () => { video.volume = +el.vol.value; video.muted = video.volume === 0; }); el.muteBtn.addEventListener('click', () => { video.muted = !video.muted; }); video.addEventListener('volumechange', () => { updateVolumeUI(); LS.set('volume', video.volume); LS.set('muted', video.muted); }); updateVolumeUI(); el.rate.addEventListener('change', () => { video.playbackRate = +el.rate.value; }); el.resetBtn.addEventListener('click', resetView); // fullscreen function toggleFullscreen() { if (document.fullscreenElement) document.exitFullscreen(); else if (el.stage.requestFullscreen) el.stage.requestFullscreen().catch(() => {}); else if (video.webkitEnterFullscreen && state.projection === 'flat') video.webkitEnterFullscreen(); } el.fsBtn.addEventListener('click', toggleFullscreen); // auto-hide controls let idleTimer = null; function wake() { el.stage.classList.remove('idle'); clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (!video.paused && !el.controls.matches(':hover')) el.stage.classList.add('idle'); }, 2500); } el.stage.addEventListener('pointermove', wake); el.stage.addEventListener('pointerdown', wake); el.controls.addEventListener('pointermove', wake); wake(); // keyboard document.addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName) || e.ctrlKey || e.metaKey || e.altKey) return; const look = state.projection !== 'flat'; switch (e.key) { case ' ': case 'k': e.preventDefault(); togglePlay(); break; case 'f': toggleFullscreen(); break; case 'm': video.muted = !video.muted; break; case 'j': seekBy(-10); break; case 'l': seekBy(10); break; case ',': seekBy(-1 / 30); break; case '.': seekBy(1 / 30); break; case '0': resetView(); break; case '+': case '=': state.fov = clamp(state.fov - 5, 30, 110); needsRender = true; break; case '-': state.fov = clamp(state.fov + 5, 30, 110); needsRender = true; break; case 'ArrowLeft': e.preventDefault(); if (look) { state.lon -= 5; needsRender = true; } else seekBy(-5); break; case 'ArrowRight': e.preventDefault(); if (look) { state.lon += 5; needsRender = true; } else seekBy(5); break; case 'ArrowUp': e.preventDefault(); if (look) { state.lat += 5; needsRender = true; } else video.volume = clamp(video.volume + 0.1, 0, 1); break; case 'ArrowDown': e.preventDefault(); if (look) { state.lat -= 5; needsRender = true; } else video.volume = clamp(video.volume - 0.1, 0, 1); break; default: return; } wake(); }); // ---------------------------------------------------------------- sources & quality function streamUrl(name, q) { return `/stream/${encodeURIComponent(name)}?q=${encodeURIComponent(q)}`; } function loadSource({ keepTime = false } = {}) { const v = state.current; if (!v) return; if (keepTime && video.src) { state.pendingSeek = video.currentTime; state.wantPlay = !video.paused && !video.ended; } el.spinner.hidden = false; video.src = streamUrl(v.name, state.quality); video.load(); if (state.wantPlay) video.play().catch(() => {}); state.wantPlay = false; updatePlayUI(); } /** Quality entries for the current video: original + every applicable variant. */ function qualityEntries(v) { if (!v) return []; const list = [{ id: 'original', label: '原始', ready: true, width: v.info?.video?.width, height: v.info?.video?.height, size: v.size, bitrateText: fmtBps(v.info?.bitrate), }]; for (const q of state.config.qualities) { const var_ = v.variants?.[q.id]; if (!var_) continue; list.push({ id: q.id, label: q.label, ready: !!var_.ready, width: var_.width, height: var_.height, size: var_.size, bitrateText: fmtRate(var_.bitrate), job: activeJobFor(v.name, q.id), }); } return list; } function activeJobFor(name, qid) { return state.jobs.find(j => j.name === name && (j.status === 'queued' || j.status === 'running') && j.targets.some(t => t.quality === qid)); } const targetsText = (job) => job.targets.map(t => `${t.label} ${t.width}×${t.height}`).join('+'); function renderQualitySelect() { const entries = qualityEntries(state.current); el.qualSel.innerHTML = ''; for (const q of entries) { const o = document.createElement('option'); o.value = q.id; let text = `${q.label} ${q.width}×${q.height} · ${q.bitrateText}`; if (q.ready) text += q.id === 'original' ? ` · ${fmtBytes(q.size)}` : ` · ${fmtBytes(q.size)} ✓`; else if (q.job) text += q.job.status === 'running' ? ` · 轉檔中 ${Math.round(q.job.progress * 100)}%` : ' · 排隊中'; else text += ' · 尚未轉檔(選取即開始)'; o.textContent = text; el.qualSel.appendChild(o); } el.qualSel.value = state.quality; el.qualSel.disabled = !entries.length; } async function setQuality(qid, { silent = false } = {}) { const entry = qualityEntries(state.current).find(q => q.id === qid); if (!entry) return; if (entry.ready) { if (qid !== state.quality) { state.quality = qid; LS.set('qualityPref', qid); loadSource({ keepTime: true }); if (!silent) toast(`切換到 ${entry.label} ${entry.width}×${entry.height}`); } } else if (entry.job) { toast(entry.job.status === 'running' ? `${entry.label} 正在轉檔:${Math.round(entry.job.progress * 100)}%` : `${entry.label} 已在排隊中`); } else { await requestTranscode(state.current.name, { quality: qid }); } renderQualitySelect(); } el.qualSel.addEventListener('change', () => setQuality(el.qualSel.value)); /** payload: { quality } | { qualities } | { all: true } */ async function requestTranscode(name, payload) { try { const job = await api('/api/transcode', { method: 'POST', body: JSON.stringify({ name, ...payload }) }); toast(`已加入轉檔佇列:${targetsText(job)},完成後會通知你`, { timeout: 6000 }); } catch (e) { toast(`無法開始轉檔:${e.message}`, { error: true, timeout: 8000 }); } } function pickQuality(v) { const entries = qualityEntries(v); const pref = LS.get('qualityPref', 'original'); const hit = entries.find(q => q.id === pref && q.ready); return hit ? hit.id : 'original'; } // ---------------------------------------------------------------- videos async function loadVideos() { try { state.videos = await api('/api/videos'); } catch (e) { toast(`無法讀取影片清單:${e.message}`, { error: true, timeout: 10000 }); state.videos = []; } const cur = state.current?.name; el.videoSel.innerHTML = ''; if (!state.videos.length) { const o = document.createElement('option'); o.textContent = '(資料夾裡沒有影片)'; el.videoSel.appendChild(o); } for (const v of state.videos) { const o = document.createElement('option'); o.value = v.name; const res = v.info?.video ? ` (${v.info.video.width}×${v.info.video.height}` + (v.info.projection === 'equirectangular' ? ', 360°)' : ')') : (v.error ? ' (無法讀取)' : ''); o.textContent = `${v.name}${res}`; el.videoSel.appendChild(o); } if (cur) { el.videoSel.value = cur; state.current = state.videos.find(v => v.name === cur) || null; } renderQualitySelect(); renderPanel(); renderInfo(); } function selectVideo(name) { const v = state.videos.find(x => x.name === name); if (!v) return; state.current = v; el.videoSel.value = name; LS.set('lastVideo', name); const autoProj = v.info?.projection === 'equirectangular' ? 'equirectangular' : 'flat'; setProjection(LS.get('proj:' + name, autoProj)); resetView(); state.quality = pickQuality(v); const pos = LS.get('pos:' + name, 0); state.pendingSeek = pos > 5 && v.info?.duration && pos < v.info.duration - 10 ? pos : null; loadSource(); renderQualitySelect(); renderInfo(); renderPanel(); el.stage.focus({ preventScroll: true }); } el.videoSel.addEventListener('change', () => { state.wantPlay = true; selectVideo(el.videoSel.value); }); el.refreshBtn.addEventListener('click', async () => { await loadVideos(); toast('已重新掃描資料夾'); }); function renderInfo() { const v = state.current; if (!v) { el.info.innerHTML = ''; return; } const i = v.info; if (!i?.video) { el.info.innerHTML = `${esc(v.name)} · ${esc(v.error || '無影像資訊')}`; return; } const tag = i.projection === 'equirectangular' ? `360° ${i.projectionSource === 'metadata' ? 'metadata' : '比例推測'}` : '平面'; el.info.innerHTML = `${esc(v.name)} · ${i.video.width}×${i.video.height} · ${i.video.fps || '?'} fps · ${fmtBps(i.bitrate)} · ${fmtTime(i.duration)}${tag}`; } // ---------------------------------------------------------------- panel el.panelBtn.addEventListener('click', () => { el.panel.hidden = !el.panel.hidden; renderPanel(); }); el.panelClose.addEventListener('click', () => { el.panel.hidden = true; }); el.clearJobsBtn.addEventListener('click', () => api('/api/jobs/finished', { method: 'DELETE' }).catch(() => {})); function renderJobsBadge() { const active = state.jobs.filter(j => j.status === 'queued' || j.status === 'running').length; el.panelBadge.textContent = active; el.panelBadge.hidden = !active; } function renderPanel() { renderJobsBadge(); if (el.panel.hidden) return; const v = state.current; el.panelVideoName.textContent = v ? v.name : '—'; const entries = qualityEntries(v); const pending = entries.filter(q => !q.ready && !q.job); const allBtn = pending.length >= 2 ? `

來源只需讀取一次;對放在網路磁碟上的影片,這比分開轉檔快好幾倍。

` : ''; el.panelVariants.innerHTML = allBtn + entries.map(q => { const playing = q.id === state.quality; let status, actions = '', bar = ''; if (q.ready) { status = `${q.id === 'original' ? '原檔' : '已轉檔'}`; actions = playing ? `播放中` : ``; if (q.id !== 'original') actions += ``; } else if (q.job) { const j = q.job; status = `${j.status === 'running' ? `轉檔中 ${Math.round(j.progress * 100)}%` : '排隊中'}`; actions = ``; bar = `
`; } else { status = `尚未轉檔`; actions = ``; } const meta = [`${q.width}×${q.height}`, q.bitrateText, q.size ? fmtBytes(q.size) : null, q.job?.status === 'running' ? `${q.job.fps.toFixed(0)} fps · ${q.job.speed.toFixed(2)}× · 剩餘 ${q.job.eta != null ? fmtTime(q.job.eta) : '…'}` : null, q.job?.note || null, ].filter(Boolean).join(' · '); return `
${esc(q.label)}${status}${actions}
${esc(meta)}
${bar}
`; }).join('') || '
請先選擇影片
'; const jobs = [...state.jobs].sort((a, b) => b.createdAt - a.createdAt); el.panelJobs.innerHTML = jobs.map(j => { const st = { queued: '排隊中', running: `轉檔中 ${Math.round(j.progress * 100)}%`, done: '完成', error: '失敗', cancelled: '已取消' }[j.status] || j.status; const meta = [targetsText(j), j.status === 'running' ? `${j.fps.toFixed(0)} fps · ${j.speed.toFixed(2)}× · 剩餘 ${j.eta != null ? fmtTime(j.eta) : '…'}` : null, j.mode ? { cuda: 'GPU', nvenc: 'NVENC', cpu: 'CPU' }[j.mode] : null, j.status === 'done' && j.startedAt && j.finishedAt ? `耗時 ${fmtTime((j.finishedAt - j.startedAt) / 1000)}` : null, j.note || null, ].filter(Boolean).join(' · '); const cancel = j.status === 'queued' || j.status === 'running' ? `` : ''; return `
${esc(j.name)}${st}${cancel}
${esc(meta)}
${j.status === 'running' ? `
` : ''} ${j.error ? `
${esc(j.error)}
` : ''}
`; }).join('') || '
目前沒有轉檔工作
'; } el.panel.addEventListener('click', async e => { const b = e.target.closest('button[data-act]'); if (!b) return; const { act, q, job } = b.dataset; try { if (act === 'play') await setQuality(q); else if (act === 'transcode') await requestTranscode(state.current.name, { quality: q }); else if (act === 'transcode-all') await requestTranscode(state.current.name, { all: true }); else if (act === 'cancel') await api(`/api/jobs/${job}`, { method: 'DELETE' }); else if (act === 'delete') { await api(`/api/variant?name=${encodeURIComponent(state.current.name)}&quality=${encodeURIComponent(q)}`, { method: 'DELETE' }); if (state.quality === q) { state.quality = 'original'; loadSource({ keepTime: true }); } await loadVideos(); toast('已刪除轉檔檔案'); } } catch (err) { toast(err.message, { error: true }); } }); // ---------------------------------------------------------------- live updates function connectSSE() { const es = new EventSource('/api/events'); es.addEventListener('jobs', e => { state.jobs = JSON.parse(e.data); renderQualitySelect(); renderPanel(); }); es.addEventListener('finished', async e => { const job = JSON.parse(e.data); await loadVideos(); if (job.status === 'done') { if (state.current?.name === job.name) { const best = job.targets[0]; // targets are sorted widest first toast(`${targetsText(job)} 轉檔完成`, { timeout: 15000, action: { label: `切換到「${best.label}」`, fn: () => setQuality(best.quality) }, }); } else { toast(`${job.name}:${targetsText(job)} 轉檔完成`, { timeout: 8000 }); } } else if (job.status === 'error') { toast(`轉檔失敗(${job.name}):${job.error}`, { error: true, timeout: 15000 }); } }); es.onerror = () => { /* EventSource reconnects by itself */ }; } // ---------------------------------------------------------------- boot (async function init() { try { state.config = await api('/api/config'); } catch (e) { toast(`無法連線到伺服器:${e.message}`, { error: true, timeout: 0 }); return; } el.encoderInfo.textContent = `轉檔引擎:${state.config.encoder.label} | 資料夾:${state.config.videoDir}`; await loadVideos(); connectSSE(); const last = LS.get('lastVideo'); const pick = state.videos.find(v => v.name === last) || state.videos[0]; if (pick) selectVideo(pick.name); resize(); loop(); })();