667 lines
28 KiB
JavaScript
667 lines
28 KiB
JavaScript
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 = `<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();
|
|||
|
|
})();
|