// ASCILINE YouTube streamer — Tesla browser frontend. // // Wire protocol (unchanged from vendor/asciline): text INIT message // "INIT:fps:mode:cols:rows:pixel" then binary frames whose first 4 bytes // are a big-endian frame index and whose body is mode-dependent (mode 1 // = utf-8 text; modes 2/3 = [charCode,r,g,b] per cell). 'use strict'; const queryInput = document.getElementById('query-input'); const btnPlay = document.getElementById('btn-play'); const btnStop = document.getElementById('btn-stop'); const presetBtns = document.querySelectorAll('.preset-btn'); const presetHint = document.getElementById('preset-hint'); const btnMute = document.getElementById('btn-mute'); const volumeSlider = document.getElementById('volume-slider'); const playerContainer = document.getElementById('player-container'); const asciiPlayer = document.getElementById('ascii-player'); const canvas = document.getElementById('ascii-canvas'); const ctx = canvas.getContext('2d'); const idleOverlay = document.getElementById('idle-overlay'); const audioEl = document.getElementById('ascii-audio'); const statusTitle = document.getElementById('status-title'); const statusLive = document.getElementById('status-live'); const statusState = document.getElementById('status-state'); const statusFps = document.getElementById('status-fps'); const statusPerf = document.getElementById('status-perf-hint'); let appState = 'idle'; // idle | resolving | playing | error let ws = null; let sessionId = null; let pollTimer = null; let currentPreset = 'low'; let selectedPreset = 'low'; const WS_MAX_RETRIES = 5; let wsRetryCount = 0; let wsRetryTimer = null; // Renderer state. Master clock is wall-clock, anchored on the first // server frame (audio's currentTime updates in coarse ~250 ms steps which // caused stall→batch-drop bursts on the M4). A small lead lets the very // first frame paint immediately instead of waiting half a second. const frameBuffer = []; const FRAME_BUFFER_MAX = 3; // server queue is 2; 3 keeps ~125 ms of slack const PRESENT_LEAD_MS = 60; let targetFps = 24; let renderMode = 1; let pixelMode = false; let readyToRender = false; let gridCols = 0, gridRows = 0; let charWidth = 0, charHeight = 0; let xPos = null, yPos = null; let dotImageData = null; let selectionBuffer = null; let clockAnchored = false; let streamStartTime = 0; const textDecoder = new TextDecoder(); const CHAR_LUT = Array.from({ length: 128 }, (_, i) => String.fromCharCode(i)); // HUD: painted FPS plus worst paint-to-paint gap (JIT) and current client // buffer depth. JIT exposes bursts that a 1-second average would smear. let frameCount = 0; let measuredFps = 0; let lastFpsUpdate = 0; let fpsWindowFrames = 0; let fpsWindowStart = 0; let fpsBelowThreshold = false; let lastPaintTime = 0; let maxPaintInterval = 0; // ───────── Preset selection UI ───────── presetBtns.forEach(btn => { btn.addEventListener('click', () => { const p = btn.dataset.preset; if (p === selectedPreset) return; selectedPreset = p; presetBtns.forEach(b => b.classList.toggle('active', b.dataset.preset === p)); if (appState === 'playing') presetHint.classList.remove('hidden'); }); }); // ───────── Volume / mute ───────── let isMuted = false; volumeSlider.addEventListener('input', () => { if (!audioEl) return; audioEl.volume = parseFloat(volumeSlider.value); isMuted = audioEl.volume === 0; updateMuteBtn(); }); btnMute.addEventListener('click', () => { isMuted = !isMuted; if (audioEl) audioEl.muted = isMuted; updateMuteBtn(); }); function updateMuteBtn() { btnMute.textContent = isMuted ? '🔇' : '🔊'; btnMute.classList.toggle('muted', isMuted); } // ───────── Canvas setup ───────── function buildCanvas(cols, rows) { gridCols = cols; gridRows = rows; const syncSize = (el) => { el.style.width = playerContainer.clientWidth + 'px'; el.style.height = playerContainer.clientHeight + 'px'; el.style.objectFit = 'contain'; el.style.position = 'absolute'; el.style.top = '0'; el.style.left = '0'; }; if (pixelMode) { canvas.width = cols; canvas.height = rows; canvas.style.display = 'block'; canvas.style.imageRendering = 'pixelated'; dotImageData = ctx.createImageData(cols, rows); const d = dotImageData.data; for (let i = 3; i < d.length; i += 4) d[i] = 255; syncSize(canvas); asciiPlayer.style.display = 'none'; return; } canvas.style.imageRendering = ''; dotImageData = null; ctx.font = 'bold 8px Courier New'; charWidth = ctx.measureText('M').width; charHeight = 8; canvas.width = cols * charWidth; canvas.height = rows * charHeight; canvas.style.display = 'block'; selectionBuffer = new Uint8Array((cols + 1) * rows); for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; syncSize(canvas); const cw = playerContainer.clientWidth; const ch = playerContainer.clientHeight; const fs = Math.min(cw / canvas.width, ch / canvas.height); const ox = (cw - canvas.width * fs) / 2; const oy = (ch - canvas.height * fs) / 2; asciiPlayer.style.width = canvas.width + 'px'; asciiPlayer.style.height = canvas.height + 'px'; asciiPlayer.style.position = 'absolute'; asciiPlayer.style.top = '0'; asciiPlayer.style.left = '0'; asciiPlayer.style.transformOrigin = 'top left'; asciiPlayer.style.transform = `translate(${ox}px,${oy}px) scale(${fs})`; asciiPlayer.style.fontSize = '8px'; asciiPlayer.style.lineHeight = '8px'; ctx.font = 'bold 8px Courier New'; ctx.textBaseline = 'top'; xPos = new Float32Array(cols); yPos = new Float32Array(rows); for (let c = 0; c < cols; c++) xPos[c] = c * charWidth; for (let r = 0; r < rows; r++) yPos[r] = r * charHeight; } // ───────── Render loop ───────── function renderFrame(now) { if (appState !== 'playing' || !readyToRender) return; requestAnimationFrame(renderFrame); if (frameBuffer.length === 0) return; if (!clockAnchored) { streamStartTime = now - frameBuffer[0].time * 1000 - PRESENT_LEAD_MS; clockAnchored = true; } const masterClock = (now - streamStartTime) / 1000.0; // Drop frames that fell visibly behind (>100 ms) so a tab that was // backgrounded for a moment doesn't replay a backlog on resume. while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) { frameBuffer.shift(); } if (frameBuffer[0].time > masterClock + 0.05) return; const frameObj = frameBuffer.shift(); const frame = frameObj.data; frameCount++; if (lastPaintTime > 0) { const gap = now - lastPaintTime; if (gap > maxPaintInterval) maxPaintInterval = gap; } lastPaintTime = now; if (now - lastFpsUpdate >= 1000) { measuredFps = frameCount; frameCount = 0; lastFpsUpdate = now; const jitter = Math.round(maxPaintInterval); maxPaintInterval = 0; statusFps.textContent = `FPS ${measuredFps}/${Math.round(targetFps)} ` + `JIT ${jitter}ms BUF ${frameBuffer.length}`; } fpsWindowFrames++; if (now - fpsWindowStart >= 5000) { const windowFps = fpsWindowFrames / 5; fpsBelowThreshold = windowFps < targetFps * 0.70; statusPerf.classList.toggle('hidden', !fpsBelowThreshold); fpsWindowFrames = 0; fpsWindowStart = now; } if (renderMode === 1) { asciiPlayer.style.display = 'block'; asciiPlayer.style.color = '#fff'; asciiPlayer.textContent = frame; return; } if (pixelMode) { const view = frame; const data = dotImageData.data; for (let src = 0, dst = 0; src < view.length; src += 3, dst += 4) { data[dst] = view[src + 2]; data[dst + 1] = view[src + 1]; data[dst + 2] = view[src]; } ctx.putImageData(dotImageData, 0, 0); return; } // Color ASCII path: per-cell fillText with a same-color run optim. // Selection buffer is kept in memory every frame; the transparent
's
// textContent is built on demand in the selectstart/copy handlers so the
// paint loop never triggers a layout reflow.
const view = frame;
const buf = selectionBuffer;
const stride = gridCols + 1;
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
let col = 0, row = 0, prevPacked = -1;
for (let idx = 0; idx < view.length; idx += 4) {
const ch = view[idx];
const r = view[idx + 1];
const g = view[idx + 2];
const b = view[idx + 3];
const packed = (r << 16) | (g << 8) | b;
if (packed !== prevPacked) {
ctx.fillStyle = `rgb(${r},${g},${b})`;
prevPacked = packed;
}
ctx.fillText(CHAR_LUT[ch], xPos[col], yPos[row]);
buf[row * stride + col] = ch;
col++;
if (col >= gridCols) { col = 0; row++; }
}
asciiPlayer.style.display = 'block';
asciiPlayer.style.color = 'transparent';
}
// ───────── WebSocket ─────────
function connectWs(sid) {
frameBuffer.length = 0;
frameCount = 0;
measuredFps = 0;
fpsWindowFrames = 0;
fpsWindowStart = 0;
fpsBelowThreshold = false;
readyToRender = false;
clockAnchored = false;
lastPaintTime = 0;
maxPaintInterval = 0;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/video?session=${encodeURIComponent(sid)}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => setStatus('BUFFERING', 'resolving');
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) {
setError(event.data.replace(/^Error:\s*/, ''));
closeWs();
return;
}
if (event.data.startsWith('INIT:')) {
wsRetryCount = 0;
const p = event.data.split(':');
targetFps = parseFloat(p[1]);
renderMode = parseInt(p[2]);
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
buildCanvas(parseInt(p[3]), parseInt(p[4]));
setStatus('PLAYING', 'playing');
idleOverlay.classList.add('hidden');
if (audioEl) {
audioEl.pause();
audioEl.src = `/audio?session=${encodeURIComponent(sid)}&_=${Date.now()}`;
audioEl.muted = isMuted;
audioEl.volume = parseFloat(volumeSlider.value);
audioEl.load();
audioEl.play().catch((err) => {
// Surface failures rather than swallow them — a
// Secure-cookie-on-http misconfig used to 403 /audio
// silently.
statusPerf.textContent = `audio: ${err && err.name || 'error'}`;
statusPerf.classList.remove('hidden');
});
}
readyToRender = true;
lastFpsUpdate = performance.now();
fpsWindowStart = lastFpsUpdate;
requestAnimationFrame(renderFrame);
return;
}
// Mode-1 text frame: "\n".
const nl = event.data.indexOf('\n');
const idx = parseInt(event.data.substring(0, nl));
frameBuffer.push({ data: event.data.substring(nl + 1), time: idx / targetFps });
} else {
const view = new DataView(event.data);
const idx = view.getUint32(0, false);
frameBuffer.push({ data: new Uint8Array(event.data, 4), time: idx / targetFps });
}
while (frameBuffer.length > FRAME_BUFFER_MAX) frameBuffer.shift();
};
ws.onclose = (evt) => {
// 1000 = normal close from our own stop/replace; 4003 = auth rejected.
if (appState !== 'playing' || evt.code === 1000 || evt.code === 4003) {
if (appState === 'playing') {
setStatus('ENDED', 'idle');
finishPlayback();
}
return;
}
wsRetryCount++;
if (wsRetryCount > WS_MAX_RETRIES) {
setError(`Stream disconnected — reconnect failed after ${WS_MAX_RETRIES} attempts`);
finishPlayback();
return;
}
const delay = wsRetryCount * 1000;
setStatus(`RECONNECTING (${wsRetryCount}/${WS_MAX_RETRIES})…`, 'resolving');
wsRetryTimer = setTimeout(() => {
wsRetryTimer = null;
if (appState === 'playing' && sessionId === sid) connectWs(sid);
}, delay);
};
ws.onerror = () => { /* onclose drives reconnect */ };
}
// ───────── Control API ─────────
async function doPlay() {
const query = queryInput.value.trim();
if (!query) { queryInput.focus(); return; }
presetHint.classList.add('hidden');
stopPoll();
closeWs();
clearCanvas();
wsRetryCount = 0;
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
appState = 'resolving';
currentPreset = selectedPreset;
setUiState('resolving');
setStatus('RESOLVING…', 'resolving');
statusTitle.textContent = query;
statusLive.classList.add('hidden');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
let resp, data;
try {
resp = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, preset: currentPreset }),
});
data = await resp.json();
} catch (err) {
setError('Network error — ' + err.message);
setUiState('idle');
return;
}
if (!resp.ok) {
setError(data.error || `Server error ${resp.status}`);
setUiState('idle');
return;
}
sessionId = data.session_id;
appState = 'playing';
setUiState('playing');
updateTitle(data.title, data.is_live);
connectWs(sessionId);
startPoll();
}
async function doStop() {
stopPoll();
wsRetryCount = 0;
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
try { await fetch('/api/stop', { method: 'POST' }); } catch (_) {}
closeWs();
finishPlayback();
setStatus('IDLE', 'idle');
statusTitle.textContent = '';
statusLive.classList.add('hidden');
presetHint.classList.add('hidden');
}
// ───────── Status polling ─────────
function startPoll() {
stopPoll();
pollTimer = setInterval(async () => {
if (appState !== 'playing') { stopPoll(); return; }
let data;
try {
const r = await fetch('/api/status');
data = await r.json();
} catch (_) { return; }
if (data.title) updateTitle(data.title, data.is_live);
if (data.state === 'error') {
setError(data.error || 'Stream error');
closeWs();
finishPlayback();
stopPoll();
}
}, 2000);
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// ───────── UI helpers ─────────
function setUiState(state) {
btnPlay.disabled = false;
btnStop.disabled = !(state === 'resolving' || state === 'playing');
}
function setStatus(label, stateClass) {
statusState.textContent = label;
statusState.className = 'status-segment status-state';
if (stateClass) statusState.classList.add('state-' + stateClass);
}
function setError(msg) {
appState = 'error';
statusTitle.textContent = '⚠ ' + msg;
setStatus('ERROR', 'error');
statusLive.classList.add('hidden');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
setUiState('idle');
idleOverlay.classList.remove('hidden');
}
function updateTitle(title, isLive) {
if (title) statusTitle.textContent = title;
statusLive.classList.toggle('hidden', !isLive);
}
function finishPlayback() {
appState = 'idle';
readyToRender = false;
frameBuffer.length = 0;
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
clearCanvas();
idleOverlay.classList.remove('hidden');
setUiState('idle');
statusFps.textContent = '';
statusPerf.classList.add('hidden');
fpsBelowThreshold = false;
}
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = 'none';
asciiPlayer.textContent = '';
asciiPlayer.style.display = 'none';
}
function closeWs() {
if (!ws) return;
ws.onclose = null;
ws.onerror = null;
try { ws.close(); } catch (_) {}
ws = null;
}
// ───────── Selection / copy ─────────
// The transparent exists so users can drag-select and copy the ASCII
// frame. Materializing its textContent on every paint forces a layout
// reflow per frame; instead, we materialize only when the user actually
// starts a selection or fires a copy.
const flushSelectionLayer = () => {
if (!selectionBuffer || appState !== 'playing' || renderMode === 1) return;
asciiPlayer.textContent = textDecoder.decode(selectionBuffer);
};
asciiPlayer.addEventListener('selectstart', flushSelectionLayer);
document.addEventListener('copy', flushSelectionLayer);
// ───────── Wiring ─────────
btnPlay.addEventListener('click', doPlay);
btnStop.addEventListener('click', doStop);
queryInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doPlay(); });
window.addEventListener('resize', () => {
if (gridCols > 0 && appState === 'playing') buildCanvas(gridCols, gridRows);
});