/** * ASCILINE ENGINE - Pure & Performant Logic * ========================================= * No decorative animations. Pure WebSocket streaming * and high-performance canvas rendering. * Includes an "Invisible Selection Layer" for text selection. */ const player = document.getElementById('ascii-player'); const canvas = document.getElementById('ascii-canvas'); const ctx = canvas.getContext('2d'); const statusEl = document.getElementById('status'); const container = document.getElementById('player-container'); const overlay = document.getElementById('play-overlay'); const audioEl = document.getElementById('ascii-audio'); const volumeSlider = document.getElementById('volume-slider'); // ── STATE ── let state = 'IDLE'; // IDLE | PLAYING let ws = null; const frameBuffer = []; const BUFFER_SIZE = 4; let targetFps = 24; let frameInterval = 1000 / targetFps; let renderMode = 1; let pixelMode = false; let readyToRender = false; // Grid & Dimensions let gridCols = 0, gridRows = 0; let charWidth = 0, charHeight = 0; let xPos = null, yPos = null; // Pixel Mode (--pixel) — ImageData pixel buffer let dotImageData = null; // Selection Layer optimization const textDecoder = new TextDecoder(); let selectionBuffer = null; // Timing & Metrics let lastRenderTime = 0; let frameCount = 0, currentFps = 0, lastFpsUpdate = 0; const CHAR_LUT = new Array(128); for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i); // ═══════════════════════════════════════ // CANVAS SETUP // ═══════════════════════════════════════ function buildCanvas(cols, rows) { gridCols = cols; gridRows = rows; // Sizing and positioning for both layers const syncSize = (el) => { el.style.width = container.clientWidth + 'px'; el.style.height = container.clientHeight + 'px'; el.style.objectFit = 'contain'; el.style.position = 'absolute'; el.style.top = '0'; el.style.left = '0'; }; if (pixelMode) { // ── DOT MODE: 1 canvas pixel = 1 grid cell ── canvas.width = cols; canvas.height = rows; canvas.style.display = 'block'; canvas.style.imageRendering = 'pixelated'; dotImageData = ctx.createImageData(cols, rows); // Pre-fill alpha channel to 255 (fully opaque) const d = dotImageData.data; for (let i = 3; i < d.length; i += 4) d[i] = 255; syncSize(canvas); // Hide selection layer — no text to select in dot mode player.style.display = 'none'; } else { // ── STANDARD ASCII MODES (1-5) ── 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'; // Selection Layer Buffer selectionBuffer = new Uint8Array((cols + 1) * rows); for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; syncSize(canvas); // Selection layer: match canvas object-fit:contain position exactly const containerW = container.clientWidth; const containerH = container.clientHeight; const fitScaleX = containerW / canvas.width; const fitScaleY = containerH / canvas.height; const fitScale = Math.min(fitScaleX, fitScaleY); const renderedW = canvas.width * fitScale; const renderedH = canvas.height * fitScale; const offsetX = (containerW - renderedW) / 2; const offsetY = (containerH - renderedH) / 2; player.style.width = canvas.width + 'px'; player.style.height = canvas.height + 'px'; player.style.position = 'absolute'; player.style.top = '0'; player.style.left = '0'; player.style.transformOrigin = 'top left'; player.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${fitScale})`; player.style.fontSize = '8px'; player.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; } } // ═══════════════════════════════════════ // STREAM CONTROL // ═══════════════════════════════════════ function startStream() { if (state !== 'IDLE') return; overlay.classList.add('hidden'); statusEl.textContent = 'Connecting...'; statusEl.style.color = 'var(--accent-color)'; connectWebSocket(); } function connectWebSocket() { frameBuffer.length = 0; frameCount = 0; currentFps = 0; if (audioEl) { audioEl.src = '/audio?' + Date.now(); audioEl.volume = volumeSlider ? volumeSlider.value : 1.0; } const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${location.host}/ws`); ws.binaryType = 'arraybuffer'; ws.onmessage = (event) => { if (typeof event.data === 'string') { if (event.data.startsWith('Error:')) { statusEl.textContent = event.data; statusEl.style.color = '#ff0000'; if (ws) ws.close(); setTimeout(() => finishStream(), 3000); return; } if (event.data.startsWith('INIT:')) { const p = event.data.split(':'); targetFps = parseFloat(p[1]); frameInterval = 1000 / targetFps; renderMode = parseInt(p[2]); pixelMode = (p.length > 5 && parseInt(p[5]) === 1); buildCanvas(parseInt(p[3]), parseInt(p[4])); // Reload audio on every INIT so each video's audio plays correctly. // The server updates current_index BEFORE sending INIT, so /audio // will already serve the new video's audio when we request it here. if (audioEl) { audioEl.pause(); audioEl.src = '/audio?' + Date.now(); audioEl.volume = volumeSlider ? volumeSlider.value : 1.0; audioEl.load(); audioEl.play().catch(() => {}); } readyToRender = true; state = 'PLAYING'; lastRenderTime = performance.now(); lastFpsUpdate = lastRenderTime; requestAnimationFrame(renderFrame); return; } frameBuffer.push(event.data); } else { frameBuffer.push(event.data); } while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift(); }; ws.onopen = () => { statusEl.textContent = 'Buffering...'; }; ws.onclose = () => { if (state === 'PLAYING') { statusEl.textContent = 'Stream Ended.'; statusEl.style.color = '#888'; if (audioEl) audioEl.pause(); setTimeout(() => finishStream(), 800); } }; ws.onerror = () => { statusEl.textContent = 'Connection Error!'; statusEl.style.color = '#ff0000'; setTimeout(() => finishStream(), 2000); }; } // ═══════════════════════════════════════ // RENDER LOOP // ═══════════════════════════════════════ function renderFrame(now) { if (state !== 'PLAYING') return; requestAnimationFrame(renderFrame); const elapsed = now - lastRenderTime; if (elapsed < frameInterval) return; frameCount++; if (now - lastFpsUpdate >= 1000) { currentFps = frameCount; frameCount = 0; lastFpsUpdate = now; const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' }; const label = (modes[renderMode] || 'B&W') + (pixelMode ? ' PIXEL' : ''); statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${label}`; } if (frameBuffer.length === 0) return; lastRenderTime = now; const frame = frameBuffer.shift(); if (renderMode === 1) { player.style.display = 'block'; player.style.color = '#fff'; player.textContent = frame; } else if (pixelMode) { // ── DOT MODE: ImageData pixel blast (1 draw call) ── const view = new Uint8Array(frame); const data = dotImageData.data; // view: [char,R,G,B, char,R,G,B, ...] → data: [R,G,B,A, R,G,B,A, ...] for (let src = 0, dst = 0; src < view.length; src += 4, dst += 4) { data[dst] = view[src + 1]; // R data[dst + 1] = view[src + 2]; // G data[dst + 2] = view[src + 3]; // B // Alpha already set to 255 in buildCanvas } ctx.putImageData(dotImageData, 0, 0); } else { // ── STANDARD COLOR MODES (2-5): fillText per character ── const view = new Uint8Array(frame); // 1. Draw Canvas (Background) 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 packed = (view[idx+1] << 16) | (view[idx+2] << 8) | view[idx+3]; if (packed !== prevPacked) { ctx.fillStyle = `rgb(${view[idx+1]},${view[idx+2]},${view[idx+3]})`; prevPacked = packed; } ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]); // Fill Selection Buffer (char code is at view[idx]) selectionBuffer[row * (gridCols + 1) + col] = view[idx]; col++; if (col >= gridCols) { col = 0; row++; } } // 2. Update Selection Layer (Foreground) player.style.display = 'block'; player.style.color = 'transparent'; player.textContent = textDecoder.decode(selectionBuffer); } } // ═══════════════════════════════════════ // CLEANUP // ═══════════════════════════════════════ function finishStream() { state = 'IDLE'; if (ws) { ws.onclose = null; ws.close(); ws = null; } if (audioEl) { audioEl.pause(); audioEl.src = ''; } ctx.clearRect(0, 0, canvas.width, canvas.height); player.textContent = ''; player.style.display = 'none'; overlay.classList.remove('hidden'); statusEl.textContent = 'Ready'; statusEl.style.color = 'rgba(255,255,255,0.6)'; readyToRender = false; frameBuffer.length = 0; } // ── EVENT LISTENERS ── overlay.addEventListener('click', (e) => { e.stopPropagation(); startStream(); }); if (volumeSlider) { volumeSlider.addEventListener('input', () => { if (audioEl) audioEl.volume = volumeSlider.value; }); } window.addEventListener('resize', () => { const syncSize = (el) => { if (!el) return; el.style.width = container.clientWidth + 'px'; el.style.height = container.clientHeight + 'px'; }; syncSize(canvas); syncSize(player); });