初始化 360 全景影片播放器專案並完成 NAS 部署設定

摘要:
將本機開發的 360° 全景影片播放器納入版控,同時把執行環境從 Windows
切換到 Synology NAS,改以 Docker 部署。

根本原因:
專案原本只在有 NVIDIA 顯卡的 Windows 機器上跑,config.json 寫死了
Windows 磁碟機路徑 W:/photo/Badminton,NAS 上無法直接啟動。
另外 DSM 內建的 ffmpeg 拿掉了 VAAPI 編碼器,裸跑只能走 libx264,
Celeron J4025 雙核轉 4K 360 影片的速度無法接受。

影響:
- 在 NAS 上 npm start 會因為找不到影片資料夾而列不出任何影片
- 即使把路徑改對,轉檔仍只能用 CPU,82 分鐘的 4K 360 影片要跑十幾小時

修法:
- config.json 的 videoDir 改為 NAS 實際路徑 /volume1/photo/Badminton
- docker-compose.yml 啟用 devices: /dev/dri,讓容器取得 Intel UHD 600
  的 render node;容器內 Alpine 版 ffmpeg 保有完整 VAAPI 支援,啟動時
  會自動偵測成 vaapi 模式,並保留 libx264 當備援
- .env(不進版控)提供 VIDEO_DIR / CACHE_DIR 等 NAS 路徑給 compose 使用

驗證:
容器啟動 log 顯示「轉檔引擎:VAAPI 硬體編碼(Intel/AMD,/dev/dri)」,
/api/config 回傳 modes: ["vaapi","cpu"],/api/videos 正確辨識來源影片為
3840x1920 equirectangular。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:57:17 +08:00
co-authored by Claude Opus 5
commit 2a9e59357e
17 changed files with 2966 additions and 0 deletions
+666
View File
@@ -0,0 +1,666 @@
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `<span>${esc(msg)}</span>`;
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 = `<b>${esc(v.name)}</b> · ${esc(v.error || '無影像資訊')}`; return; }
const tag = i.projection === 'equirectangular'
? `<span class="tag sph">360° ${i.projectionSource === 'metadata' ? 'metadata' : '比例推測'}</span>`
: '<span class="tag">平面</span>';
el.info.innerHTML = `<b>${esc(v.name)}</b> · ${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
? `<button class="btn primary" data-act="transcode-all" style="width:100%;margin-bottom:10px">一次轉出全部畫質(${pending.map(q => q.label).join('')}</button>
<p class="muted">來源只需讀取一次;對放在網路磁碟上的影片,這比分開轉檔快好幾倍。</p>`
: '';
el.panelVariants.innerHTML = allBtn + entries.map(q => {
const playing = q.id === state.quality;
let status, actions = '', bar = '';
if (q.ready) {
status = `<span class="status ready">${q.id === 'original' ? '原檔' : '已轉檔'}</span>`;
actions = playing
? `<span class="status">播放中</span>`
: `<button class="btn sm primary" data-act="play" data-q="${q.id}">播放</button>`;
if (q.id !== 'original') actions += `<button class="btn sm danger" data-act="delete" data-q="${q.id}">刪除</button>`;
} else if (q.job) {
const j = q.job;
status = `<span class="status ${j.status}">${j.status === 'running' ? `轉檔中 ${Math.round(j.progress * 100)}%` : '排隊中'}</span>`;
actions = `<button class="btn sm" data-act="cancel" data-job="${j.id}">取消</button>`;
bar = `<div class="bar"><i style="width:${(j.progress * 100).toFixed(1)}%"></i></div>`;
} else {
status = `<span class="status">尚未轉檔</span>`;
actions = `<button class="btn sm primary" data-act="transcode" data-q="${q.id}">轉檔</button>`;
}
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 `<div class="variant">
<div class="top"><span class="name">${esc(q.label)}</span>${status}<span class="actions">${actions}</span></div>
<div class="meta">${esc(meta)}</div>${bar}
</div>`;
}).join('') || '<div class="empty">請先選擇影片</div>';
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' ? `<button class="btn sm" data-act="cancel" data-job="${j.id}">取消</button>` : '';
return `<div class="job">
<div class="top"><span class="name">${esc(j.name)}</span><span class="status ${j.status}">${st}</span><span class="actions">${cancel}</span></div>
<div class="meta">${esc(meta)}</div>
${j.status === 'running' ? `<div class="bar"><i style="width:${(j.progress * 100).toFixed(1)}%"></i></div>` : ''}
${j.error ? `<div class="err">${esc(j.error)}</div>` : ''}
</div>`;
}).join('') || '<div class="empty">目前沒有轉檔工作</div>';
}
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();
})();