mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-23 22:48:06 +02:00
Merge pull request #24 from Shaku-Med/feat/player-ui and thumbnails
Live player UI: control bar with hover thumbnails, skip, and responsive layout
This commit is contained in:
commit
0dd557dce8
6 changed files with 518 additions and 69 deletions
22
README.md
22
README.md
|
|
@ -130,6 +130,28 @@ 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.
|
||||
|
||||
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
|
||||
|
|
|
|||
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();
|
||||
|
|
|
|||
31
index.html
31
index.html
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,98 @@ 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
|
||||
# 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")
|
||||
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:
|
||||
|
|
@ -668,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')
|
||||
|
|
@ -695,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
|
||||
|
|
|
|||
154
style.css
154
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; }
|
||||
}
|
||||
126
test/test_scrub.py
Normal file
126
test/test_scrub.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
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_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()
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue