初始化 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:
+666
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>360 Player</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script type="importmap">{ "imports": { "three": "/vendor/three/three.module.js" } }</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<div class="brand">360<span>Player</span></div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">影片</span>
|
||||
<select id="videoSelect" title="選擇影片"></select>
|
||||
<button id="refreshBtn" class="icon-btn" title="重新掃描資料夾">↻</button>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">畫質</span>
|
||||
<select id="qualitySelect" title="選擇播放畫質"></select>
|
||||
</label>
|
||||
|
||||
<button id="projBtn" class="pill" title="切換 360° / 平面 顯示">360°</button>
|
||||
<button id="panelBtn" class="pill" title="轉檔與畫質管理">轉檔 <span id="panelBadge" class="badge" hidden></span></button>
|
||||
<div id="info" class="info"></div>
|
||||
</header>
|
||||
|
||||
<main id="stage" tabindex="0">
|
||||
<canvas id="gl"></canvas>
|
||||
<video id="video" playsinline preload="metadata"></video>
|
||||
|
||||
<div id="hint" class="hint">拖曳環視 · 滾輪/雙指縮放 · 空白鍵播放 · F 全螢幕</div>
|
||||
<div id="spinner" class="spinner" hidden></div>
|
||||
<button id="bigPlay" class="big-play" title="播放">▶</button>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<div id="controls" class="controls">
|
||||
<input id="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="進度">
|
||||
<div class="row">
|
||||
<button id="playBtn" class="ctl" title="播放/暫停 (空白鍵)">▶</button>
|
||||
<span id="time" class="time">0:00 / 0:00</span>
|
||||
<span class="spacer"></span>
|
||||
<button id="muteBtn" class="ctl" title="靜音 (M)">🔊</button>
|
||||
<input id="vol" class="vol" type="range" min="0" max="1" step="0.02" value="1" aria-label="音量">
|
||||
<select id="rate" class="ctl-select" title="播放速度">
|
||||
<option value="0.5">0.5×</option>
|
||||
<option value="0.75">0.75×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="1.25">1.25×</option>
|
||||
<option value="1.5">1.5×</option>
|
||||
<option value="2">2×</option>
|
||||
</select>
|
||||
<button id="resetBtn" class="ctl" title="重置視角 (0)">⌖</button>
|
||||
<button id="fsBtn" class="ctl" title="全螢幕 (F)">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside id="panel" class="panel" hidden>
|
||||
<div class="panel-head">
|
||||
<h2>轉檔 / 畫質</h2>
|
||||
<button id="panelClose" class="icon-btn" title="關閉">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted" id="encoderInfo"></p>
|
||||
<h3 id="panelVideoName">—</h3>
|
||||
<div id="panelVariants" class="variants"></div>
|
||||
<div class="panel-section-head">
|
||||
<h3>轉檔佇列</h3>
|
||||
<button id="clearJobsBtn" class="link-btn">清除已完成</button>
|
||||
</div>
|
||||
<div id="panelJobs" class="jobs"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,178 @@
|
||||
:root {
|
||||
--bg: #0b0d10;
|
||||
--bar: #14181d;
|
||||
--fg: #e8ebef;
|
||||
--muted: #8a939e;
|
||||
--line: #252b33;
|
||||
--accent: #4cc2ff;
|
||||
--accent-2: #ffb347;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #5ddc8a;
|
||||
--radius: 8px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans TC", "Microsoft JhengHei", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg); overflow: hidden; }
|
||||
button, select, input { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
h2, h3 { margin: 0; font-weight: 600; }
|
||||
|
||||
#app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
#topbar {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 8px 12px; background: var(--bar); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { font-weight: 800; letter-spacing: .5px; font-size: 18px; color: var(--accent); }
|
||||
.brand span { color: var(--fg); font-weight: 300; margin-left: 4px; }
|
||||
.field { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.field-label { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
select {
|
||||
background: #1d232a; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 6px 28px 6px 10px; max-width: 46vw; text-overflow: ellipsis;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 55%, calc(100% - 9px) 55%;
|
||||
background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
|
||||
}
|
||||
select:focus { outline: 1px solid var(--accent); }
|
||||
.icon-btn {
|
||||
background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
width: 32px; height: 32px; color: var(--muted);
|
||||
}
|
||||
.icon-btn:hover { color: var(--fg); border-color: var(--muted); }
|
||||
.pill {
|
||||
background: #1d232a; border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 5px 12px; color: var(--fg); position: relative;
|
||||
}
|
||||
.pill:hover { border-color: var(--muted); }
|
||||
.pill.active { background: var(--accent); color: #06202e; border-color: var(--accent); font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 4px;
|
||||
background: var(--accent-2); color: #2b1a00; border-radius: 999px; font-size: 11px; font-weight: 700; line-height: 18px;
|
||||
}
|
||||
.badge[hidden] { display: none; }
|
||||
.info { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.info b { color: var(--fg); font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; background: #27313b; color: var(--fg); font-size: 11px; margin-left: 6px; }
|
||||
.tag.sph { background: #123a4c; color: var(--accent); }
|
||||
|
||||
/* ---------- stage ---------- */
|
||||
#stage { position: relative; background: #000; overflow: hidden; outline: none; user-select: none; -webkit-user-select: none; }
|
||||
#gl, #video { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#gl { touch-action: none; cursor: grab; }
|
||||
#gl.dragging { cursor: grabbing; }
|
||||
#video { object-fit: contain; background: #000; }
|
||||
#stage.flat #gl { display: none; }
|
||||
#stage:not(.flat) #video { opacity: 0; pointer-events: none; width: 1px; height: 1px; }
|
||||
|
||||
.hint {
|
||||
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(0,0,0,.55); color: #ddd; font-size: 12px; padding: 6px 12px; border-radius: 999px;
|
||||
pointer-events: none; transition: opacity .4s; backdrop-filter: blur(4px);
|
||||
}
|
||||
.spinner {
|
||||
position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; margin: -22px 0 0 -22px;
|
||||
border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent); border-radius: 50%;
|
||||
animation: spin .8s linear infinite; pointer-events: none;
|
||||
}
|
||||
.spinner[hidden] { display: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.big-play {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
|
||||
width: 84px; height: 84px; border-radius: 50%; border: 0;
|
||||
background: rgba(20,24,29,.75); color: #fff; font-size: 34px; padding-left: 8px;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.5); backdrop-filter: blur(6px);
|
||||
}
|
||||
.big-play:hover { background: var(--accent); color: #06202e; }
|
||||
.big-play[hidden] { display: none; }
|
||||
|
||||
.toast {
|
||||
position: absolute; left: 50%; bottom: 84px; transform: translateX(-50%);
|
||||
background: #1d232a; border: 1px solid var(--line); color: var(--fg);
|
||||
padding: 10px 14px; border-radius: var(--radius); font-size: 14px; max-width: min(90vw, 520px);
|
||||
display: flex; gap: 12px; align-items: center; box-shadow: 0 8px 30px rgba(0,0,0,.5); z-index: 5;
|
||||
}
|
||||
.toast[hidden] { display: none; }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
.toast button { background: var(--accent); color: #06202e; border: 0; border-radius: 6px; padding: 4px 10px; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* ---------- player controls ---------- */
|
||||
.controls {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; padding: 24px 14px 10px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.8), rgba(0,0,0,0));
|
||||
transition: opacity .25s; z-index: 3;
|
||||
}
|
||||
#stage.idle .controls, #stage.idle .hint { opacity: 0; pointer-events: none; }
|
||||
#stage.idle #gl { cursor: none; }
|
||||
.row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.spacer { flex: 1; }
|
||||
.ctl { background: transparent; border: 0; color: #fff; font-size: 18px; width: 36px; height: 36px; border-radius: 6px; }
|
||||
.ctl:hover { background: rgba(255,255,255,.12); }
|
||||
.time { font-variant-numeric: tabular-nums; font-size: 13px; color: #ddd; }
|
||||
.ctl-select { background: rgba(255,255,255,.1); border: 0; border-radius: 6px; padding: 4px 22px 4px 8px; font-size: 13px; }
|
||||
.vol { width: 90px; accent-color: var(--accent); }
|
||||
|
||||
#seek {
|
||||
--played: 0%; --buffered: 0%;
|
||||
width: 100%; height: 6px; appearance: none; -webkit-appearance: none; background: transparent; cursor: pointer; margin: 0;
|
||||
}
|
||||
#seek::-webkit-slider-runnable-track {
|
||||
height: 6px; border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--accent) var(--played), rgba(255,255,255,.45) var(--played), rgba(255,255,255,.45) var(--buffered), rgba(255,255,255,.18) var(--buffered));
|
||||
}
|
||||
#seek::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; margin-top: -4px;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,.3);
|
||||
}
|
||||
#seek::-moz-range-track {
|
||||
height: 6px; border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--accent) var(--played), rgba(255,255,255,.45) var(--played), rgba(255,255,255,.45) var(--buffered), rgba(255,255,255,.18) var(--buffered));
|
||||
}
|
||||
#seek::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 0; }
|
||||
|
||||
/* ---------- side panel ---------- */
|
||||
.panel {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100vw);
|
||||
background: var(--bar); border-left: 1px solid var(--line); z-index: 20;
|
||||
display: flex; flex-direction: column; box-shadow: -10px 0 40px rgba(0,0,0,.5);
|
||||
}
|
||||
.panel[hidden] { display: none; }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
.panel-body { padding: 14px 16px; overflow: auto; flex: 1; }
|
||||
.panel-body h3 { font-size: 14px; margin: 10px 0 8px; word-break: break-all; }
|
||||
.panel-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 22px; }
|
||||
.muted { color: var(--muted); font-size: 12px; margin: 0 0 8px; }
|
||||
.link-btn { background: none; border: 0; color: var(--accent); font-size: 12px; padding: 0; }
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
|
||||
.variant, .job {
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; background: #10141a;
|
||||
}
|
||||
.variant .top, .job .top { display: flex; align-items: center; gap: 8px; }
|
||||
.variant .name, .job .name { font-weight: 600; }
|
||||
.variant .meta, .job .meta { color: var(--muted); font-size: 12px; margin-top: 3px; word-break: break-all; }
|
||||
.variant .actions, .job .actions { margin-left: auto; display: flex; gap: 6px; }
|
||||
.btn {
|
||||
background: #1d232a; border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font-size: 13px;
|
||||
}
|
||||
.btn:hover { border-color: var(--muted); }
|
||||
.btn.primary { background: var(--accent); color: #06202e; border-color: var(--accent); font-weight: 600; }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.btn.sm { padding: 2px 8px; font-size: 12px; }
|
||||
.status { font-size: 12px; padding: 1px 7px; border-radius: 999px; background: #27313b; white-space: nowrap; }
|
||||
.status.ready, .status.done { background: #153f2a; color: var(--ok); }
|
||||
.status.running { background: #123a4c; color: var(--accent); }
|
||||
.status.queued { background: #3d3117; color: var(--accent-2); }
|
||||
.status.error { background: #4a1d1d; color: var(--danger); }
|
||||
.bar { height: 6px; background: #232a33; border-radius: 3px; margin-top: 8px; overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .3s; }
|
||||
.job .err { color: var(--danger); font-size: 12px; margin-top: 4px; word-break: break-all; }
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 6px 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#topbar { gap: 8px; padding: 6px 8px; }
|
||||
.brand, .info, .field-label, .hint, .vol, #resetBtn { display: none; }
|
||||
select { max-width: 40vw; }
|
||||
}
|
||||
Reference in New Issue
Block a user