mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +02:00
feat: Add seek bar, pause/resume, and optimize async audio streaming
This commit is contained in:
parent
88b261eae9
commit
27aeca9d20
5 changed files with 220 additions and 43 deletions
141
app.js
141
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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
18
index.html
18
index.html
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ body {
|
|||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 860px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue