/** * 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 | PAUSED let ws = null; const frameBuffer = []; const BUFFER_SIZE = 4; let codecDecoder = null; // Adaptive codec decoder (codec.js) let targetFps = 24; let frameInterval = 1000 / targetFps; let renderMode = 1; let pixelMode = false; let readyToRender = false; let pauseStartTime = 0; // 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; let streamStartTime = 0; let audioClockOffset = null; // anchors the audio clock so it never jumps backward (issue #7) 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; // Audio is loaded later in INIT handler (Audio Ready Gate). // Don't preload here — causes race conditions with vol=0 (204 response). const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${location.host}/ws?codec=adaptive`); 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); const currentQueueIndex = (p.length > 6) ? parseInt(p[6]) : null; buildCanvas(parseInt(p[3]), parseInt(p[4])); // Initialize adaptive codec decoder (pixel=3 bytes, ASCII color=4 bytes) if (typeof AscilineCodec !== 'undefined' && renderMode > 1) { codecDecoder = AscilineCodec.makeDecoder(pixelMode ? 3 : 4); } else { codecDecoder = null; } // ── AUDIO READY GATE ── // Buffer video frames but don't render until audio is ready. // This prevents the 0.5s initial stutter. readyToRender = false; state = 'PLAYING'; const beginRendering = () => { readyToRender = true; streamStartTime = performance.now(); lastRenderTime = performance.now(); lastFpsUpdate = lastRenderTime; audioClockOffset = null; // re-anchor for this stream requestAnimationFrame(renderFrame); }; if (audioEl) { audioEl.pause(); const qs = currentQueueIndex !== null ? `?v=${currentQueueIndex}&` : '?'; audioEl.src = `/audio${qs}t=${Date.now()}`; audioEl.volume = volumeSlider ? volumeSlider.value : 1.0; audioEl.load(); audioEl.play().catch(() => {}); // Wait for audio to actually start playing if (audioEl.readyState >= 3) { beginRendering(); } else { audioEl.addEventListener('playing', beginRendering, { once: true }); // Fallback: if audio fails to load (vol=0 / 204), start after 500ms setTimeout(() => { if (!readyToRender) beginRendering(); }, 500); } } else { // No audio element at all → start immediately beginRendering(); } return; } // Mode 1: Text Frame with Timestamp const text = event.data; const newlineIdx = text.indexOf('\n'); const frameIndex = parseInt(text.substring(0, newlineIdx)); const frameTime = frameIndex / targetFps; const frameData = text.substring(newlineIdx + 1); frameBuffer.push({ data: frameData, time: frameTime }); } else { // Binary Frames — decoded via adaptive codec (raw/zlib/delta) if (codecDecoder) { codecDecoder.decode(event.data).then(({ frameIndex, frame }) => { const frameTime = frameIndex / targetFps; frameBuffer.push({ data: frame, time: frameTime }); }); } else { // Fallback: legacy 4-byte header const buffer = event.data; const view = new DataView(buffer); const frameIndex = view.getUint32(0, false); const frameTime = frameIndex / targetFps; const frameData = new Uint8Array(buffer, 4); frameBuffer.push({ data: frameData, time: frameTime }); } } while (frameBuffer.length > BUFFER_SIZE * 5) frameBuffer.shift(); }; ws.onopen = () => { statusEl.textContent = 'Buffering...'; }; ws.onclose = () => { if (state === 'PLAYING' || state === 'PAUSED') { 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' || !readyToRender) return; requestAnimationFrame(renderFrame); // ── MASTER CLOCK LOGIC ── // The audio track is the master clock, BUT it must never jump backward // (issue #7, the cold-start freeze). On a cold load the