From a253c1750707effa889307405263ff787215e966 Mon Sep 17 00:00:00 2001 From: Shaku-Med Date: Thu, 18 Jun 2026 11:39:39 -0400 Subject: [PATCH] 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. --- app.js | 155 +++++++++++++++++++++++++++++++++-------------- index.html | 31 +++++----- stream_server.py | 89 +++++++++++++++++++++++++++ style.css | 154 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 360 insertions(+), 69 deletions(-) diff --git a/app.js b/app.js index 7f4621a..f8e22b2 100644 --- a/app.js +++ b/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(); diff --git a/index.html b/index.html index 5689a55..46a9d65 100644 --- a/index.html +++ b/index.html @@ -53,21 +53,24 @@ -
- -
- +
+ + + 00:00 +
+
+
+ +
+
+
0:00
+
- -
- 00:00 - - 00:00 -
- -
- VOL_ - + 00:00 + +
+ VOL_ +
diff --git a/stream_server.py b/stream_server.py index 18c90b1..83d7a23 100644 --- a/stream_server.py +++ b/stream_server.py @@ -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: diff --git a/style.css b/style.css index 6510e21..20bc27f 100644 --- a/style.css +++ b/style.css @@ -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; } } \ No newline at end of file