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.
|
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)
|
### 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):
|
If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color):
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
155
app.js
155
app.js
|
|
@ -20,6 +20,16 @@ const seekBar = document.getElementById('seek-slider');
|
||||||
const timeCurrent = document.getElementById('time-current');
|
const timeCurrent = document.getElementById('time-current');
|
||||||
const timeTotal = document.getElementById('time-total');
|
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) {
|
function formatTime(seconds) {
|
||||||
if (isNaN(seconds) || seconds < 0) return "00:00";
|
if (isNaN(seconds) || seconds < 0) return "00:00";
|
||||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
|
@ -193,8 +203,10 @@ function connectWebSocket() {
|
||||||
}
|
}
|
||||||
if (timeTotal) timeTotal.textContent = formatTime(duration);
|
if (timeTotal) timeTotal.textContent = formatTime(duration);
|
||||||
if (timeCurrent) timeCurrent.textContent = "00:00";
|
if (timeCurrent) timeCurrent.textContent = "00:00";
|
||||||
|
if (seekPlayed) seekPlayed.style.width = '0%';
|
||||||
|
|
||||||
audioOffset = 0;
|
audioOffset = 0;
|
||||||
|
setupScrub(currentQueueIdx); // load hover thumbnails for this video
|
||||||
|
|
||||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||||
|
|
||||||
|
|
@ -308,6 +320,7 @@ function renderFrame(now) {
|
||||||
if (!isSeeking && seekBar) {
|
if (!isSeeking && seekBar) {
|
||||||
if (now - lastUiUpdateTime >= 100) {
|
if (now - lastUiUpdateTime >= 100) {
|
||||||
seekBar.value = masterClock;
|
seekBar.value = masterClock;
|
||||||
|
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (masterClock / duration) * 100) + '%';
|
||||||
lastUiUpdateTime = now;
|
lastUiUpdateTime = now;
|
||||||
}
|
}
|
||||||
const formattedTime = formatTime(masterClock);
|
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) {
|
if (seekBar) {
|
||||||
seekBar.addEventListener('input', () => {
|
seekBar.addEventListener('input', () => {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value);
|
if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
seekBar.addEventListener('change', () => {
|
seekBar.addEventListener('change', () => {
|
||||||
const targetSec = parseFloat(seekBar.value);
|
doSeek(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
isSeeking = false;
|
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 ──
|
// ── EVENT LISTENERS ──
|
||||||
overlay.addEventListener('click', (e) => {
|
overlay.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
31
index.html
31
index.html
|
|
@ -53,21 +53,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Controls Bar -->
|
<!-- 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%;">
|
<div class="player-controls">
|
||||||
<!-- Play/Pause -->
|
<button id="play-pause-btn" class="ctrl-btn" title="Play / Pause (Space)">❚❚</button>
|
||||||
<div class="ctrl-group" style="flex: 0 0 auto;">
|
<button id="btn-back" class="ctrl-btn" title="Back 10s">«10</button>
|
||||||
<button id="play-pause-btn" style="background: none; border: none; color: var(--accent-color, #00ff41); font-family: monospace; font-size: 16px; cursor: pointer;">❚❚</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>
|
</div>
|
||||||
<!-- Seek Bar -->
|
<span id="time-total" class="ctrl-time">00:00</span>
|
||||||
<div class="ctrl-group seek-group" style="flex: 1; max-width: 500px; display: flex; align-items: center;">
|
<button id="btn-fwd" class="ctrl-btn" title="Forward 10s">10»</button>
|
||||||
<span id="time-current" style="margin-right: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px; text-align: right;">00:00</span>
|
<div class="ctrl-group">
|
||||||
<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 class="ctrl-icon">VOL_</span>
|
||||||
<span id="time-total" style="margin-left: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px;">00:00</span>
|
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
|
||||||
</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);">
|
|
||||||
</div>
|
</div>
|
||||||
</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:
|
def _origin_allowed(origin: str | None, host_header: str | None = None) -> bool:
|
||||||
"""Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin."""
|
"""Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin."""
|
||||||
if not origin:
|
if not origin:
|
||||||
|
|
@ -668,6 +760,12 @@ if __name__ == "__main__":
|
||||||
help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = "
|
help="Adaptive-codec colour fidelity (lossless = bit-exact; lower = "
|
||||||
"smaller stream via lossy temporal delta). Chars always exact."
|
"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 ──
|
# ── Server ──
|
||||||
srv = parser.add_argument_group('\033[33mServer\033[0m')
|
srv = parser.add_argument_group('\033[33mServer\033[0m')
|
||||||
|
|
@ -695,6 +793,7 @@ if __name__ == "__main__":
|
||||||
app.state.loop = args.loop
|
app.state.loop = args.loop
|
||||||
app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality]
|
app.state.tolerance = {"lossless": 0, "high": 4, "balanced": 8, "low": 16}[args.quality]
|
||||||
app.state.debug = args.debug
|
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)
|
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.cols = global_default_cols
|
||||||
app.state.rows = args.rows
|
app.state.rows = args.rows
|
||||||
|
|
|
||||||
154
style.css
154
style.css
|
|
@ -115,8 +115,9 @@ body {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--player-bg);
|
background: var(--player-bg);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
width: 860px;
|
width: 100%;
|
||||||
height: 560px;
|
max-width: 860px;
|
||||||
|
aspect-ratio: 860 / 560; /* scales with width, keeps shape */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -207,7 +208,8 @@ body {
|
||||||
.player-controls {
|
.player-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
|
|
@ -222,7 +224,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctrl-icon {
|
.ctrl-icon {
|
||||||
|
|
@ -231,18 +233,133 @@ body {
|
||||||
flex-shrink: 0;
|
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 {
|
#volume-slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
flex: 1;
|
width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: #444;
|
background: #444;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#volume-slider::-webkit-slider-thumb {
|
#volume-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|
@ -252,7 +369,6 @@ body {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#volume-slider::-moz-range-thumb {
|
#volume-slider::-moz-range-thumb {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|
@ -282,4 +398,26 @@ body {
|
||||||
|
|
||||||
#player-container.paused #ascii-player {
|
#player-container.paused #ascii-player {
|
||||||
pointer-events: none;
|
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