diff --git a/app.js b/app.js index 1262927..7f4621a 100644 --- a/app.js +++ b/app.js @@ -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(); diff --git a/ascii_video_player2.py b/ascii_video_player2.py index 141dfb7..73f70a1 100644 --- a/ascii_video_player2.py +++ b/ascii_video_player2.py @@ -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() diff --git a/index.html b/index.html index 5c2548a..5689a55 100644 --- a/index.html +++ b/index.html @@ -53,11 +53,21 @@ -
+
+ +
+ +
+ +
+ 00:00 + + 00:00 +
-
- VOL_ - +
+ VOL_ +
diff --git a/stream_server.py b/stream_server.py index 1a008b9..18c90b1 100644 --- a/stream_server.py +++ b/stream_server.py @@ -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.") diff --git a/style.css b/style.css index d710c84..6510e21 100644 --- a/style.css +++ b/style.css @@ -214,7 +214,7 @@ body { border-radius: 8px; border: 1px solid #333; width: 100%; - max-width: 320px; + max-width: 860px; box-sizing: border-box; }