From a253c1750707effa889307405263ff787215e966 Mon Sep 17 00:00:00 2001 From: Shaku-Med Date: Thu, 18 Jun 2026 11:39:39 -0400 Subject: [PATCH 1/4] 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 From 743e6e933c52a2997b68145fd43285f6ad583a5d Mon Sep 17 00:00:00 2001 From: Shaku-Med Date: Thu, 18 Jun 2026 11:41:49 -0400 Subject: [PATCH 2/4] test: cover the scrub sprite endpoint (hover thumbnails) Self-contained: generates a tiny video, checks the sprite grid math, the JPEG output, and the /scrub + /scrub_sprite endpoints. The ffmpeg parts skip if ffmpeg is missing. --- test/test_scrub.py | 118 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/test_scrub.py diff --git a/test/test_scrub.py b/test/test_scrub.py new file mode 100644 index 0000000..345b1a3 --- /dev/null +++ b/test/test_scrub.py @@ -0,0 +1,118 @@ +""" +Tests for the live player UI backend bit we added. + +Just the new stuff: the scrub sprite endpoint that powers the hover thumbnails. +The control bar itself is frontend, so it isn't covered here. Makes its own tiny +video and never touches your real files. The ffmpeg parts skip themselves if +ffmpeg isn't around. + + python -m unittest discover -s test + pytest test/ +""" +import os +import sys +import json +import shutil +import asyncio +import tempfile +import unittest + +import numpy as np +import cv2 + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import stream_server as ss + + +def _has_ffmpeg(): + return shutil.which("ffmpeg") is not None + + +def _make_video(path, frames=40, w=64, h=48, fps=10.0): + vw = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"MJPG"), fps, (w, h)) + if not vw.isOpened(): + return False + + for i in range(frames): + img = np.zeros((h, w, 3), np.uint8) + img[:, : w // 2] = (40, 80, 120) + img[:, w // 2 :] = (120, 80, 40) + x = (i * 2) % max(1, w - 8) + img[h // 2 : h // 2 + 8, x : x + 8] = (255, 255, 255) + vw.write(img) + + vw.release() + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def _entry(video): + return {"video": video, "mode": 5, "pixel": False, "cols": 80, "rows": 0, "vol": 1} + + +class ScrubTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="asciline_ui_") + cls.video = os.path.join(cls.tmp, "clip.avi") + if not _make_video(cls.video): + raise unittest.SkipTest("OpenCV could not write a test video here.") + ss.app.state.queue = [_entry(cls.video)] + ss.app.state.current_index = 0 + ss._scrub_cache.clear() + + @classmethod + def tearDownClass(cls): + ss._scrub_cache.clear() + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_video_path_lookup(self): + self.assertEqual(ss._scrub_video_path(0), self.video) + # an out of range index just falls back to the current entry + self.assertEqual(ss._scrub_video_path(99), self.video) + + def test_missing_video_says_unavailable(self): + ss.app.state.queue = [_entry(os.path.join(self.tmp, "nope.mp4"))] + try: + body = json.loads(asyncio.run(ss.scrub_meta(0)).body) + self.assertFalse(body["available"]) + finally: + ss.app.state.queue = [_entry(self.video)] + + def test_sprite_404_before_it_is_built(self): + from fastapi import HTTPException + ss._scrub_cache.clear() + with self.assertRaises(HTTPException): + asyncio.run(ss.scrub_sprite(0)) + + @unittest.skipUnless(_has_ffmpeg(), "ffmpeg not installed") + def test_sprite_grid_and_image(self): + import math + built = ss._build_scrub_sprite(self.video, max_count=16, cell_w=80) + self.assertIsNotNone(built) + + m = built["meta"] + self.assertTrue(m["available"]) + self.assertEqual(m["gridCols"], math.ceil(math.sqrt(m["count"]))) + self.assertGreaterEqual(m["gridCols"] * m["gridRows"], m["count"]) + + # the bytes really are a JPEG, and it decodes to the full grid size + arr = cv2.imdecode(np.frombuffer(built["jpeg"], np.uint8), cv2.IMREAD_COLOR) + self.assertIsNotNone(arr) + self.assertEqual(arr.shape[0], m["gridRows"] * m["cellH"]) + self.assertEqual(arr.shape[1], m["gridCols"] * m["cellW"]) + + @unittest.skipUnless(_has_ffmpeg(), "ffmpeg not installed") + def test_endpoint_builds_then_serves(self): + ss._scrub_cache.clear() + body = json.loads(asyncio.run(ss.scrub_meta(0)).body) + self.assertTrue(body["available"]) + self.assertIn("sprite", body) + + # it's cached now, so the sprite serves as jpeg bytes + resp = asyncio.run(ss.scrub_sprite(0)) + self.assertEqual(resp.media_type, "image/jpeg") + self.assertGreater(len(resp.body), 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 41f430d46dad6569de33d619e9056194e918de55 Mon Sep 17 00:00:00 2001 From: Shaku-Med Date: Fri, 19 Jun 2026 00:07:52 -0400 Subject: [PATCH 3/4] docs: document the player controls in the README --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 7c1176b..e37f2a7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,25 @@ Use `playlist.json` when you need different `--mode` or `--vol` settings for eac Open `http://localhost:8000` in your browser. +### Player Controls + +Once a video is playing, the page has a full control bar under it: + +* Play and pause with the button, by clicking the video, or with the space bar. +* Skip back and forward ten seconds with the `«10` and `10»` buttons. +* Drag the seek bar to jump anywhere. The filled part shows how far along you are. +* Hover over the seek bar and a small picture of that moment pops up, so you can + find the spot you want before you let go. +* A volume slider on the right. + +The bar wraps and the whole player scales down so it still works on small screens +and phones. + +The hover previews come from a light preview sprite. The server builds it once per +video the first time you hover, in a single quick ffmpeg pass, and keeps it in +memory so nothing is written to disk. If you already have your own sprite, point +the `/scrub` route at it instead. + ### 4. Run directly in Terminal (Standalone) If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color): ```bash From c230c6b7d74801aa9e4a6c2c206bc80b6a894863 Mon Sep 17 00:00:00 2001 From: Shaku-Med Date: Fri, 19 Jun 2026 00:14:34 -0400 Subject: [PATCH 4/4] feat: make hover thumbnails optional (--no-thumbnails) Thumbnails stay on by default. --no-thumbnails skips building the preview sprite, so the server does no extra work and the rest of the player still works. The frontend already shows no preview when the sprite is unavailable. --- README.md | 3 +++ stream_server.py | 10 ++++++++++ test/test_scrub.py | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index e37f2a7..2b0bd41 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,9 @@ video the first time you hover, in a single quick ffmpeg pass, and keeps it in memory so nothing is written to disk. If you already have your own sprite, point the `/scrub` route at it instead. +Hover previews are on by default. If you would rather not build them at all, start +the server with `--no-thumbnails` and the rest of the player keeps working. + ### 4. Run directly in Terminal (Standalone) If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color): ```bash diff --git a/stream_server.py b/stream_server.py index 83d7a23..0ddb5c3 100644 --- a/stream_server.py +++ b/stream_server.py @@ -310,6 +310,9 @@ async def scrub_meta(v: int | None = None): loop) the first time it's asked for, then reuses it from memory.""" from fastapi import Response import json as _json + # Thumbnails are on by default; --no-thumbnails turns the whole thing off. + if not getattr(app.state, "thumbnails", True): + return Response(content='{"available": false}', media_type="application/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") @@ -757,6 +760,12 @@ if __name__ == "__main__": help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = " "smaller stream via lossy temporal delta). Chars always exact." ) + playback.add_argument( + "--no-thumbnails", + action="store_true", default=False, + help="Turn off the hover thumbnails on the seek bar (skips building the " + "preview sprite). The rest of the player still works." + ) # ── Server ── srv = parser.add_argument_group('\033[33mServer\033[0m') @@ -784,6 +793,7 @@ if __name__ == "__main__": app.state.loop = args.loop app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality] app.state.debug = args.debug + app.state.thumbnails = not args.no_thumbnails global_default_cols = args.cols if args.cols is not None else (450 if args.pixel else 200) app.state.cols = global_default_cols app.state.rows = args.rows diff --git a/test/test_scrub.py b/test/test_scrub.py index 345b1a3..b2851dd 100644 --- a/test/test_scrub.py +++ b/test/test_scrub.py @@ -78,6 +78,14 @@ class ScrubTests(unittest.TestCase): finally: ss.app.state.queue = [_entry(self.video)] + def test_thumbnails_can_be_disabled(self): + ss.app.state.thumbnails = False + try: + body = json.loads(asyncio.run(ss.scrub_meta(0)).body) + self.assertFalse(body["available"]) + finally: + ss.app.state.thumbnails = True + def test_sprite_404_before_it_is_built(self): from fastapi import HTTPException ss._scrub_cache.clear()