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:
Shaku-Med 2026-06-18 11:39:39 -04:00
parent 461e0bd939
commit a253c17507
4 changed files with 360 additions and 69 deletions

155
app.js
View file

@ -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();

View file

@ -53,21 +53,24 @@
</div>
<!-- Player Controls Bar -->
<div class="player-controls" style="display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 0; border-top: 1px solid #333; margin-top: 10px; width: 100%;">
<!-- Play/Pause -->
<div class="ctrl-group" style="flex: 0 0 auto;">
<button id="play-pause-btn" style="background: none; border: none; color: var(--accent-color, #00ff41); font-family: monospace; font-size: 16px; cursor: pointer;">❚❚</button>
<div class="player-controls">
<button id="play-pause-btn" class="ctrl-btn" title="Play / Pause (Space)">❚❚</button>
<button id="btn-back" class="ctrl-btn" title="Back 10s">«10</button>
<span id="time-current" class="ctrl-time">00:00</span>
<div class="seek-wrap">
<div class="seek-track"></div>
<div class="seek-played" id="seek-played"></div>
<input id="seek-slider" class="seek-slider" type="range" min="0" max="100" step="0.1" value="0" title="Seek">
<div class="seek-preview" id="seek-preview">
<div class="seek-preview-img" id="seek-preview-img"></div>
<div class="seek-preview-time" id="seek-preview-time">0:00</div>
</div>
</div>
<!-- Seek Bar -->
<div class="ctrl-group seek-group" style="flex: 1; max-width: 500px; display: flex; align-items: center;">
<span id="time-current" style="margin-right: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px; text-align: right;">00:00</span>
<input id="seek-slider" type="range" min="0" max="100" step="0.1" value="0" style="flex: 1; cursor: pointer; accent-color: var(--accent-color, #00ff41);">
<span id="time-total" style="margin-left: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px;">00:00</span>
</div>
<!-- Volume Control -->
<div class="ctrl-group" style="display: flex; align-items: center; flex: 0 0 auto;">
<span class="ctrl-icon" style="margin-right: 5px; color: #888;">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" style="cursor: pointer; width: 60px; accent-color: var(--accent-color, #00ff41);">
<span id="time-total" class="ctrl-time">00:00</span>
<button id="btn-fwd" class="ctrl-btn" title="Forward 10s">10»</button>
<div class="ctrl-group">
<span class="ctrl-icon">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
</div>
</div>

View file

@ -244,6 +244,95 @@ async def audio_stream(v: int | None = None, start: float = 0.0):
)
# ── Scrub-preview sprite (powers the hover thumbnails on the seek bar) ──
# A grid of small frames sampled across the video, like a YouTube preview strip.
# Built once per video on first request and kept in memory only (no disk cache).
# If you'd rather serve a sprite from the static compiler, just point /scrub at it.
_scrub_cache: dict = {} # video_path -> {"meta": {...}, "jpeg": bytes} or None
def _build_scrub_sprite(video_path: str, max_count: int = 64, cell_w: int = 160):
import math
# Probe size + duration quickly (metadata only, no frame decoding).
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return None
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w0 = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h0 = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
duration = (total / fps) if fps else 0
if duration <= 0 or w0 <= 0 or h0 <= 0:
return None
cell_h = max(1, round(cell_w * h0 / w0))
n = max(1, min(max_count, int(duration))) # roughly one frame per second
cols = max(1, math.ceil(math.sqrt(n)))
rows = max(1, math.ceil(n / cols))
interval = duration / n
# One ffmpeg pass: sample frames, scale them, tile into a single grid image.
# Sequential decode (no per-frame seeking), so this is fast even on long clips.
vf = f"fps={n}/{duration:.3f},scale={cell_w}:{cell_h},tile={cols}x{rows}"
try:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-i", video_path, "-vf", vf,
"-frames:v", "1", "-q:v", "4", "-f", "image2", "-c:v", "mjpeg",
"-loglevel", "error", "pipe:1"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=120,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if proc.returncode != 0 or not proc.stdout:
return None
return {
"meta": {"available": True, "count": n, "gridCols": cols, "gridRows": rows,
"cellW": cell_w, "cellH": cell_h, "interval": interval, "duration": duration},
"jpeg": proc.stdout,
}
def _scrub_video_path(v: int | None) -> str:
queue = getattr(app.state, "queue", [])
idx = getattr(app.state, "current_index", 0)
if v is not None and 0 <= v < len(queue):
idx = v
entry = queue[idx] if queue and 0 <= idx < len(queue) else {}
return entry.get("video", "")
@app.get("/scrub")
async def scrub_meta(v: int | None = None):
"""Layout for the hover thumbnails. Builds the sprite lazily (off the event
loop) the first time it's asked for, then reuses it from memory."""
from fastapi import Response
import json as _json
video_path = _scrub_video_path(v)
if not video_path or not os.path.exists(video_path):
return Response(content='{"available": false}', media_type="application/json")
if video_path not in _scrub_cache:
loop = asyncio.get_event_loop()
_scrub_cache[video_path] = await loop.run_in_executor(None, _build_scrub_sprite, video_path)
built = _scrub_cache.get(video_path)
if not built:
return Response(content='{"available": false}', media_type="application/json")
meta = dict(built["meta"])
meta["sprite"] = f"/scrub_sprite?v={v if v is not None else 0}"
return Response(content=_json.dumps(meta), media_type="application/json")
@app.get("/scrub_sprite")
async def scrub_sprite(v: int | None = None):
from fastapi import Response, HTTPException
built = _scrub_cache.get(_scrub_video_path(v))
if not built:
raise HTTPException(status_code=404, detail="Not found")
return Response(content=built["jpeg"], media_type="image/jpeg")
def _origin_allowed(origin: str | None, host_header: str | None = None) -> bool:
"""Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin."""
if not origin:

154
style.css
View file

@ -115,8 +115,9 @@ body {
position: relative;
background: var(--player-bg);
border-radius: 4px;
width: 860px;
height: 560px;
width: 100%;
max-width: 860px;
aspect-ratio: 860 / 560; /* scales with width, keeps shape */
display: flex;
align-items: center;
justify-content: center;
@ -207,7 +208,8 @@ body {
.player-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
padding: 8px 12px;
background: #1a1a1a;
@ -222,7 +224,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-shrink: 0;
}
.ctrl-icon {
@ -231,18 +233,133 @@ body {
flex-shrink: 0;
}
/* Styled range slider */
/* Buttons (play / pause / skip) */
.ctrl-btn {
background: #050505;
color: var(--accent-color);
border: 1px solid #333;
border-radius: 4px;
font-family: var(--font-tech);
font-size: 12px;
font-weight: bold;
min-width: 34px;
height: 28px;
padding: 0 8px;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.ctrl-btn:hover { border-color: var(--accent-color); }
.ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; border-color: #333; }
/* Time readouts */
.ctrl-time {
font-family: var(--font-tech);
font-size: 11px;
color: #aaa;
flex-shrink: 0;
white-space: nowrap;
min-width: 42px;
text-align: center;
}
/* Seek bar (track + played fill + transparent slider on top) */
.seek-wrap {
position: relative;
flex: 1;
min-width: 80px;
height: 14px;
display: flex;
align-items: center;
}
.seek-track {
position: absolute;
left: 0; right: 0;
height: 4px;
background: #444;
border-radius: 2px;
pointer-events: none;
}
.seek-played {
position: absolute;
left: 0;
height: 4px;
width: 0;
background: var(--accent-color);
border-radius: 2px;
pointer-events: none;
}
.seek-slider {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
height: 14px;
background: transparent;
outline: none;
cursor: pointer;
margin: 0;
}
.seek-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
cursor: pointer;
}
.seek-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
border: none;
cursor: pointer;
}
/* Hover scrub-preview thumbnail */
.seek-preview {
position: absolute;
bottom: 20px;
left: 0;
transform: translateX(-50%);
display: none;
flex-direction: column;
align-items: center;
pointer-events: none;
z-index: 60;
}
.seek-preview.show { display: flex; }
.seek-preview-img {
background-repeat: no-repeat;
border: 1px solid var(--accent-color);
border-radius: 3px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
}
.seek-preview-time {
margin-top: 4px;
font-family: var(--font-tech);
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.85);
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
}
/* Volume */
#volume-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
width: 80px;
flex-shrink: 0;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
}
#volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
@ -252,7 +369,6 @@ body {
border-radius: 50%;
cursor: pointer;
}
#volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
@ -282,4 +398,26 @@ body {
#player-container.paused #ascii-player {
pointer-events: none;
}
/* ── RESPONSIVE ─────────────────────────── */
@media (max-width: 920px) {
.blog-container { margin: 30px auto; }
.blog-post { padding: 24px; }
.blog-header { padding: 50px 16px; }
.blog-header h1 { font-size: 32px; }
}
@media (max-width: 560px) {
.blog-post { padding: 16px; }
.blog-header { padding: 36px 12px; }
.blog-header h1 { font-size: 26px; letter-spacing: 2px; }
.post-title { font-size: 19px; }
.post-content { font-size: 15px; }
.player-controls { gap: 8px; padding: 8px; }
.ctrl-btn { min-width: 30px; height: 26px; padding: 0 6px; font-size: 11px; }
.ctrl-time { min-width: 0; font-size: 10px; }
.seek-wrap { order: 5; flex-basis: 100%; } /* seek bar gets its own row */
#volume-slider { width: 64px; }
}