feat: Add seek bar, pause/resume, and optimize async audio streaming

This commit is contained in:
YusufB5 2026-06-17 14:05:29 +03:00
parent 88b261eae9
commit 27aeca9d20
5 changed files with 220 additions and 43 deletions

141
app.js
View file

@ -15,6 +15,18 @@ const overlay = document.getElementById('play-overlay');
const audioEl = document.getElementById('ascii-audio');
const volumeSlider = document.getElementById('volume-slider');
const playPauseBtn = document.getElementById('play-pause-btn');
const seekBar = document.getElementById('seek-slider');
const timeCurrent = document.getElementById('time-current');
const timeTotal = document.getElementById('time-total');
function formatTime(seconds) {
if (isNaN(seconds) || seconds < 0) return "00:00";
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
// ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING | PAUSED
let ws = null;
@ -27,6 +39,10 @@ let renderMode = 1;
let pixelMode = false;
let readyToRender = false;
let pauseStartTime = 0;
let duration = 0;
let isSeeking = false;
let currentQueueIdx = 0;
let audioOffset = 0;
// Grid & Dimensions
let gridCols = 0, gridRows = 0;
@ -44,6 +60,8 @@ let selectionBuffer = null;
let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
let streamStartTime = 0;
let lastUiUpdateTime = 0;
let lastFormattedTime = "";
const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
@ -166,6 +184,18 @@ function connectWebSocket() {
renderMode = parseInt(p[2]);
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
const currentQueueIndex = (p.length > 6) ? parseInt(p[6]) : null;
duration = (p.length > 7) ? parseFloat(p[7]) : 0;
currentQueueIdx = currentQueueIndex !== null ? currentQueueIndex : 0;
if (seekBar) {
seekBar.max = duration;
seekBar.value = 0;
}
if (timeTotal) timeTotal.textContent = formatTime(duration);
if (timeCurrent) timeCurrent.textContent = "00:00";
audioOffset = 0;
buildCanvas(parseInt(p[3]), parseInt(p[4]));
// Initialize adaptive codec decoder (pixel=3 bytes, ASCII color=4 bytes)
@ -268,20 +298,33 @@ function renderFrame(now) {
if (state !== 'PLAYING' || !readyToRender) return;
requestAnimationFrame(renderFrame);
// ── MASTER CLOCK LOGIC ──
let masterClock;
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) {
masterClock = audioEl.currentTime;
masterClock = audioEl.currentTime + audioOffset;
} else {
masterClock = (now - streamStartTime) / 1000.0;
}
if (!isSeeking && seekBar) {
if (now - lastUiUpdateTime >= 100) {
seekBar.value = masterClock;
lastUiUpdateTime = now;
}
const formattedTime = formatTime(masterClock);
if (timeCurrent && formattedTime !== lastFormattedTime) {
timeCurrent.textContent = formattedTime;
lastFormattedTime = formattedTime;
}
}
if (frameBuffer.length === 0) return;
// A/V Sync: Drop frames that are too far behind the master clock (catch up)
while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) {
while (frameBuffer.length > 0 && frameBuffer[0].time < masterClock - 0.1) {
frameBuffer.shift();
}
if (frameBuffer.length === 0) return;
// A/V Sync: Wait if the frame is in the future
if (frameBuffer[0].time > masterClock + 0.05) {
@ -368,6 +411,7 @@ function finishStream() {
overlay.classList.remove('hidden');
statusEl.textContent = 'Ready';
statusEl.style.color = 'rgba(255,255,255,0.6)';
if (playPauseBtn) playPauseBtn.textContent = '▶';
readyToRender = false;
pauseStartTime = 0;
frameBuffer.length = 0;
@ -381,24 +425,32 @@ function togglePause() {
if (state === 'PLAYING') {
state = 'PAUSED';
pauseStartTime = performance.now();
// Live stream approach: mute audio instead of pausing it,
// so the master clock keeps ticking with the server.
if (audioEl && !audioEl.paused) {
audioEl.dataset.prePauseVolume = audioEl.volume;
audioEl.volume = 0;
audioEl.pause();
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pause', paused: true }));
}
container.classList.add('paused');
if (playPauseBtn) playPauseBtn.textContent = '▶';
statusEl.textContent = '❚❚ PAUSED';
statusEl.style.color = '#888';
} else if (state === 'PAUSED') {
state = 'PLAYING';
// Update streamStartTime to account for the pause duration
const pauseDuration = performance.now() - pauseStartTime;
streamStartTime += pauseDuration;
pauseStartTime = 0;
// Restore audio volume
if (audioEl && !audioEl.paused) {
audioEl.volume = audioEl.dataset.prePauseVolume !== undefined
? parseFloat(audioEl.dataset.prePauseVolume)
: (volumeSlider ? volumeSlider.value : 1.0);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pause', paused: false }));
}
// Restore audio playback
if (audioEl && audioEl.paused) {
audioEl.play().catch(() => {});
}
// Flush stale buffer frames — A/V sync catch-up handles the rest
@ -409,6 +461,7 @@ function togglePause() {
statusEl.style.color = 'var(--accent-color)';
// Restart render loop
if (playPauseBtn) playPauseBtn.textContent = '❚❚';
lastRenderTime = performance.now();
lastFpsUpdate = performance.now();
frameCount = 0;
@ -416,6 +469,70 @@ function togglePause() {
}
}
if (playPauseBtn) {
playPauseBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (state === 'IDLE') startStream();
else togglePause();
});
}
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);
}
isSeeking = false;
});
}
// ── EVENT LISTENERS ──
overlay.addEventListener('click', (e) => {
e.stopPropagation();

View file

@ -72,6 +72,12 @@ class VideoDecoder:
Used by stream_server for FPS decimation of high-FPS sources."""
return self._cap.grab()
def seek(self, target_sec: float) -> bool:
"""Seeks the video capture to the specified target second."""
if self._cap:
return self._cap.set(cv2.CAP_PROP_POS_MSEC, target_sec * 1000)
return False
def __del__(self):
self.release()

View file

@ -53,11 +53,21 @@
</div>
<!-- Player Controls Bar -->
<div class="player-controls">
<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>
<!-- 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">
<span class="ctrl-icon">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
<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>

View file

@ -164,7 +164,7 @@ async def root():
@app.get("/audio")
async def audio_stream(v: int | None = None):
async def audio_stream(v: int | None = None, start: float = 0.0):
"""
Extracts and streams audio from the currently active video entry.
Server-side volume control via the entry's 'vol' field (0-5 scale).
@ -194,38 +194,48 @@ async def audio_stream(v: int | None = None):
# Map 1-5 → 1.0x-2.0x FFmpeg volume
ffmpeg_vol = 1.0 + (vol_level - 1) * 0.25
def audio_generator():
process = subprocess.Popen(
[
"ffmpeg",
"-nostdin",
"-i", video_path,
"-vn",
"-filter:a", f"volume={ffmpeg_vol}",
"-acodec", "libmp3lame",
"-ab", "128k",
"-ar", "44100",
"-f", "mp3",
"-loglevel", "quiet",
"pipe:1"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
async def audio_generator():
ffmpeg_cmd = [
"ffmpeg",
"-nostdin"
]
if start > 0:
ffmpeg_cmd.extend(["-ss", str(start)])
ffmpeg_cmd.extend([
"-i", video_path,
"-vn",
"-filter:a", f"volume={ffmpeg_vol}",
"-acodec", "libmp3lame",
"-ab", "128k",
"-ar", "44100",
"-f", "mp3",
"-loglevel", "quiet",
"pipe:1"
])
process = await asyncio.create_subprocess_exec(
*ffmpeg_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL
)
try:
while True:
chunk = process.stdout.read(4096)
chunk = await process.stdout.read(4096)
if not chunk:
break
yield chunk
except asyncio.CancelledError:
pass
finally:
process.stdout.close()
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
await asyncio.wait_for(process.wait(), timeout=1.0)
except Exception:
try:
process.kill()
except Exception:
pass
return StreamingResponse(
audio_generator(),
@ -345,7 +355,8 @@ async def websocket_endpoint(websocket: WebSocket):
effective_fps = source_fps
frame_t = 1.0 / effective_fps
await websocket.send_text(f"INIT:{effective_fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}:{queue_index}")
duration = decoder.frame_count / decoder.fps if decoder.fps > 0 else 0
await websocket.send_text(f"INIT:{effective_fps}:{render_mode}:{cols}:{rows}:{int(pixel_mode)}:{queue_index}:{duration:.3f}")
if skip_n > 1:
print(f"[FPS CAP] {source_fps} FPS → {effective_fps} FPS (skip every {skip_n} frames)")
@ -369,9 +380,41 @@ async def websocket_endpoint(websocket: WebSocket):
# ASCII Color: 4-byte header + [char,R,G,B] per pixel
ascii_send_buf = bytearray(4 + rows * cols * 4)
cmd_queue = asyncio.Queue()
is_paused = False
async def receive_commands():
try:
while True:
msg = await websocket.receive_json()
await cmd_queue.put(msg)
except Exception:
pass
receive_task = asyncio.create_task(receive_commands())
raw_frame_num = 0
try:
while True:
while not cmd_queue.empty():
msg = cmd_queue.get_nowait()
if msg.get("type") == "pause":
is_paused = msg.get("paused", False)
if not is_paused:
start_time = asyncio.get_event_loop().time() - (frame_index * frame_t)
bw_start_time = time.time()
elif msg.get("type") == "seek":
target_sec = float(msg.get("time", 0))
decoder.seek(target_sec)
prev_frame = None
frame_index = int(target_sec * effective_fps)
start_time = asyncio.get_event_loop().time() - (frame_index * frame_t)
bw_start_time = time.time()
if is_paused:
await asyncio.sleep(0.1)
continue
# ── FPS DECIMATION via grab() ──
# For 60→30 fps: grab (skip) 1 frame, then decode 1 frame.
# grab() is ~10x faster than read() because it skips decoding.
@ -453,6 +496,7 @@ async def websocket_endpoint(websocket: WebSocket):
frame_index += 1
finally:
receive_task.cancel()
decoder.release()
# Video finished → advance queue
@ -465,7 +509,7 @@ async def websocket_endpoint(websocket: WebSocket):
print("[DONE] All videos finished.")
break
except (WebSocketDisconnect, ConnectionClosed):
except (WebSocketDisconnect, ConnectionClosed, RuntimeError):
print("Client disconnected from the stream.")

View file

@ -214,7 +214,7 @@ body {
border-radius: 8px;
border: 1px solid #333;
width: 100%;
max-width: 320px;
max-width: 860px;
box-sizing: border-box;
}