mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-26 22:49:39 +02:00
feat: polished player UI for live mode (hover thumbnails, skip, responsive)
Builds on the existing live seek/play/volume. Adds a polished, responsive control bar with play/pause, +/-10s skip, a played-progress fill, and a YouTube-style hover thumbnail preview on the seek bar. Thumbnails come from a small lazy /scrub endpoint that builds an in-memory sprite once per video with a single ffmpeg pass (no disk cache); easy to point at the static compiler's sprite instead.
This commit is contained in:
parent
461e0bd939
commit
a253c17507
4 changed files with 360 additions and 69 deletions
155
app.js
155
app.js
|
|
@ -20,6 +20,16 @@ const seekBar = document.getElementById('seek-slider');
|
|||
const timeCurrent = document.getElementById('time-current');
|
||||
const timeTotal = document.getElementById('time-total');
|
||||
|
||||
// Added controls: skip buttons, played fill, and the hover scrub preview
|
||||
const btnBack = document.getElementById('btn-back');
|
||||
const btnFwd = document.getElementById('btn-fwd');
|
||||
const seekPlayed = document.getElementById('seek-played');
|
||||
const seekWrap = document.querySelector('.seek-wrap');
|
||||
const seekPreview = document.getElementById('seek-preview');
|
||||
const seekPreviewImg = document.getElementById('seek-preview-img');
|
||||
const seekPreviewTime = document.getElementById('seek-preview-time');
|
||||
let scrubMeta = null; // hover sprite layout from /scrub
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (isNaN(seconds) || seconds < 0) return "00:00";
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
|
|
@ -193,8 +203,10 @@ function connectWebSocket() {
|
|||
}
|
||||
if (timeTotal) timeTotal.textContent = formatTime(duration);
|
||||
if (timeCurrent) timeCurrent.textContent = "00:00";
|
||||
|
||||
if (seekPlayed) seekPlayed.style.width = '0%';
|
||||
|
||||
audioOffset = 0;
|
||||
setupScrub(currentQueueIdx); // load hover thumbnails for this video
|
||||
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
|
||||
|
|
@ -308,6 +320,7 @@ function renderFrame(now) {
|
|||
if (!isSeeking && seekBar) {
|
||||
if (now - lastUiUpdateTime >= 100) {
|
||||
seekBar.value = masterClock;
|
||||
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (masterClock / duration) * 100) + '%';
|
||||
lastUiUpdateTime = now;
|
||||
}
|
||||
const formattedTime = formatTime(masterClock);
|
||||
|
|
@ -477,62 +490,110 @@ if (playPauseBtn) {
|
|||
});
|
||||
}
|
||||
|
||||
// Seek to an absolute time. Reuses the live seek (tell the server, then reload
|
||||
// the audio from that point). Shared by the slider and the skip buttons.
|
||||
function doSeek(targetSec) {
|
||||
if (duration) targetSec = Math.max(0, Math.min(targetSec, duration));
|
||||
if (seekBar) seekBar.value = targetSec;
|
||||
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (targetSec / duration) * 100) + '%';
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
|
||||
}
|
||||
|
||||
// Drop stale frames, then restart audio from the seek point
|
||||
frameBuffer.length = 0;
|
||||
audioOffset = targetSec;
|
||||
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.src = `/audio?v=${currentQueueIdx}&start=${targetSec}&t=${Date.now()}`;
|
||||
audioEl.load();
|
||||
|
||||
if (state === 'PLAYING') {
|
||||
readyToRender = false;
|
||||
audioEl.play().catch(() => {});
|
||||
const onAudioStart = () => {
|
||||
if (!readyToRender) {
|
||||
readyToRender = true;
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = performance.now();
|
||||
frameCount = 0;
|
||||
requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
if (audioEl.readyState >= 3) onAudioStart();
|
||||
else {
|
||||
audioEl.addEventListener('playing', onAudioStart, { once: true });
|
||||
setTimeout(onAudioStart, 500);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
function getMasterClock() {
|
||||
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) return audioEl.currentTime + audioOffset;
|
||||
return (performance.now() - streamStartTime) / 1000.0;
|
||||
}
|
||||
|
||||
function skip(delta) {
|
||||
if (state !== 'PLAYING' && state !== 'PAUSED') return;
|
||||
if (!duration) return;
|
||||
doSeek(getMasterClock() + delta);
|
||||
}
|
||||
|
||||
// Pull the hover thumbnail sprite for this video (built lazily by the server).
|
||||
function setupScrub(v) {
|
||||
scrubMeta = null;
|
||||
if (seekPreviewImg) seekPreviewImg.style.backgroundImage = '';
|
||||
fetch('/scrub?v=' + (v || 0)).then(r => r.json()).then(m => {
|
||||
if (!m || !m.available || !seekPreviewImg) return;
|
||||
scrubMeta = m;
|
||||
seekPreviewImg.style.width = m.cellW + 'px';
|
||||
seekPreviewImg.style.height = m.cellH + 'px';
|
||||
seekPreviewImg.style.backgroundImage = `url(${m.sprite})`;
|
||||
seekPreviewImg.style.backgroundSize = (m.gridCols * m.cellW) + 'px ' + (m.gridRows * m.cellH) + 'px';
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function onSeekHover(e) {
|
||||
if (!scrubMeta || !duration || !seekWrap) return;
|
||||
const rect = seekWrap.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const time = (x / rect.width) * duration;
|
||||
const idx = Math.max(0, Math.min(Math.floor(time / scrubMeta.interval), scrubMeta.count - 1));
|
||||
const col = idx % scrubMeta.gridCols, row = Math.floor(idx / scrubMeta.gridCols);
|
||||
seekPreviewImg.style.backgroundPosition = `-${col * scrubMeta.cellW}px -${row * scrubMeta.cellH}px`;
|
||||
seekPreviewTime.textContent = formatTime(time);
|
||||
const half = scrubMeta.cellW / 2;
|
||||
seekPreview.style.left = Math.max(half, Math.min(x, rect.width - half)) + 'px';
|
||||
seekPreview.classList.add('show');
|
||||
}
|
||||
|
||||
if (seekBar) {
|
||||
seekBar.addEventListener('input', () => {
|
||||
isSeeking = true;
|
||||
if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value);
|
||||
});
|
||||
|
||||
seekBar.addEventListener('change', () => {
|
||||
const targetSec = parseFloat(seekBar.value);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
|
||||
}
|
||||
|
||||
// Clear buffer so we don't render stale frames
|
||||
frameBuffer.length = 0;
|
||||
audioOffset = targetSec;
|
||||
|
||||
// Reload audio with correct start offset
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
const qs = `?v=${currentQueueIdx}&start=${targetSec}&`;
|
||||
audioEl.src = `/audio${qs}t=${Date.now()}`;
|
||||
audioEl.load();
|
||||
|
||||
if (state === 'PLAYING') {
|
||||
readyToRender = false;
|
||||
audioEl.play().catch(() => {});
|
||||
|
||||
const onAudioStart = () => {
|
||||
if (!readyToRender) {
|
||||
readyToRender = true;
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = performance.now();
|
||||
frameCount = 0;
|
||||
requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
|
||||
if (audioEl.readyState >= 3) {
|
||||
onAudioStart();
|
||||
} else {
|
||||
audioEl.addEventListener('playing', onAudioStart, { once: true });
|
||||
// Fallback in case audio is muted or fails to play
|
||||
setTimeout(onAudioStart, 500);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
|
||||
doSeek(parseFloat(seekBar.value));
|
||||
isSeeking = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (btnBack) btnBack.addEventListener('click', (e) => { e.stopPropagation(); skip(-10); });
|
||||
if (btnFwd) btnFwd.addEventListener('click', (e) => { e.stopPropagation(); skip(10); });
|
||||
|
||||
if (seekWrap) {
|
||||
seekWrap.addEventListener('mousemove', onSeekHover);
|
||||
seekWrap.addEventListener('mouseleave', () => { if (seekPreview) seekPreview.classList.remove('show'); });
|
||||
}
|
||||
|
||||
// ── EVENT LISTENERS ──
|
||||
overlay.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue