Files
360_player/public/app.js
T
JianMiauandClaude Opus 5 2a9e59357e 初始化 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>
2026-08-21 10:57:17 +08:00

667 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
})();