dreamstack/examples/stream-receiver.html
enzotar 968d62d0bb feat(demos): sync protocol with Rust codec, add touch/gamepad support
- RLE format: 2-byte LE count (matches Rust rle_encode/rle_decode)
- Delta frames: FLAG_COMPRESSED flag set correctly
- Signal sync: FLAG_KEYFRAME flag + periodic sync every ~5s for late-join
- Source: handle touch, gamepad axis/button, resize inputs from receiver
- Receiver: touch event capture for mobile, ping frame handling
- Protocol constants: added FRAME_PING, INPUT_TOUCH, INPUT_GAMEPAD_*
2026-02-25 11:06:42 -08:00

715 lines
No EOL
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack — Stream Receiver</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: #06060b;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
background: linear-gradient(135deg, #ec4899, #f472b6, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.3rem;
}
.subtitle {
color: rgba(255, 255, 255, 0.25);
font-size: 0.75rem;
margin-bottom: 1rem;
}
.layout {
display: flex;
gap: 1.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
}
.panel {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 1rem;
}
.panel h2 {
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.8rem;
}
canvas {
border-radius: 12px;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: crosshair;
}
.connection {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 1rem;
}
.connection.disconnected {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.connection.connected {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #86efac;
}
.connection .dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
.connection.disconnected .dot {
background: #ef4444;
}
.connection.connected .dot {
background: #22c55e;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.4rem;
min-width: 300px;
}
.stat {
background: rgba(236, 72, 153, 0.06);
border: 1px solid rgba(236, 72, 153, 0.1);
border-radius: 10px;
padding: 0.5rem 0.7rem;
}
.stat-label {
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.95rem;
font-weight: 600;
color: #f9a8d4;
margin-top: 1px;
}
.bus {
margin-top: 0.8rem;
padding-top: 0.6rem;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.bus h3 {
font-size: 0.6rem;
color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.4rem;
}
.bus-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 0.65rem;
}
.bus-arrow {
color: rgba(255, 255, 255, 0.1);
font-family: monospace;
}
.bus-label {
color: rgba(255, 255, 255, 0.3);
min-width: 70px;
}
.bus-bytes {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: rgba(139, 92, 246, 0.5);
background: rgba(139, 92, 246, 0.05);
padding: 1px 6px;
border-radius: 4px;
}
.bus-active {
font-size: 0.7rem;
transition: color 0.2s;
}
.powered {
margin-top: 1rem;
font-size: 0.6rem;
color: rgba(255, 255, 255, 0.08);
}
.powered span {
color: rgba(236, 72, 153, 0.25);
}
.badge {
position: fixed;
bottom: 1rem;
right: 1rem;
background: rgba(236, 72, 153, 0.08);
border: 1px solid rgba(236, 72, 153, 0.15);
padding: 0.4rem 0.8rem;
border-radius: 8px;
font-size: 0.6rem;
color: rgba(255, 255, 255, 0.25);
}
.badge strong {
color: #f9a8d4;
}
</style>
</head>
<body>
<h1>📡 Bitstream Receiver</h1>
<p class="subtitle">No framework. No runtime. Just bytes → pixels + audio + haptics.</p>
<div class="connection disconnected" id="connStatus">
<div class="dot"></div>
<span id="connText">Connecting to relay...</span>
</div>
<div class="layout">
<div class="panel">
<h2>Received Frame</h2>
<canvas id="display" width="600" height="400"></canvas>
</div>
<div class="panel">
<h2>Receiver Stats</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">FPS In</div>
<div class="stat-value" id="statFps">0</div>
</div>
<div class="stat">
<div class="stat-label">Latency</div>
<div class="stat-value" id="statLatency"></div>
</div>
<div class="stat">
<div class="stat-label">Mode</div>
<div class="stat-value" id="statMode"></div>
</div>
<div class="stat">
<div class="stat-label">Bandwidth</div>
<div class="stat-value" id="statBandwidth">0</div>
</div>
<div class="stat">
<div class="stat-label">Frames</div>
<div class="stat-value" id="statFrames">0</div>
</div>
<div class="stat">
<div class="stat-label">Inputs</div>
<div class="stat-value" id="statInputs">0</div>
</div>
<div class="stat">
<div class="stat-label">Delta Saved</div>
<div class="stat-value" id="statDelta"></div>
</div>
<div class="stat">
<div class="stat-label">Audio</div>
<div class="stat-value" id="statAudio"></div>
</div>
<div class="stat">
<div class="stat-label">Signals</div>
<div class="stat-value" id="statSignals"></div>
</div>
</div>
<div class="bus">
<h3>Universal Bitstream Bus</h3>
<div class="bus-row">
<span class="bus-label">pixels</span>
<span class="bus-arrow">◄──</span>
<span class="bus-bytes" id="busPixels"></span>
<span class="bus-active" id="busPixelsOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">delta</span>
<span class="bus-arrow">◄──</span>
<span class="bus-bytes" id="busDelta"></span>
<span class="bus-active" id="busDeltaOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">signals</span>
<span class="bus-arrow">◄──</span>
<span class="bus-bytes" id="busSignals"></span>
<span class="bus-active" id="busSignalsOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">neural</span>
<span class="bus-arrow">◄──</span>
<span class="bus-bytes" id="busNeural"></span>
<span class="bus-active" id="busNeuralOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">audio</span>
<span class="bus-arrow">◄──</span>
<span class="bus-bytes" id="busAudio"></span>
<span class="bus-active" id="busAudioOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row"
style="margin-top: 0.3rem; padding-top: 0.3rem; border-top: 1px dashed rgba(255,255,255,0.04);">
<span class="bus-label">pointer</span>
<span class="bus-arrow">──►</span>
<span class="bus-bytes" id="busPointer">0 B</span>
<span class="bus-active" id="busPointerOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">keyboard</span>
<span class="bus-arrow">──►</span>
<span class="bus-bytes" id="busKeyboard">0 B</span>
<span class="bus-active" id="busKeyOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row">
<span class="bus-label">scroll</span>
<span class="bus-arrow">──►</span>
<span class="bus-bytes" id="busScroll">0 B</span>
<span class="bus-active" id="busScrollOk" style="color:rgba(255,255,255,0.08)"></span>
</div>
<div class="bus-row" style="opacity: 0.3;">
<span class="bus-label">voice</span><span class="bus-arrow">──►</span><span
class="bus-bytes">future</span>
</div>
<div class="bus-row" style="opacity: 0.3;">
<span class="bus-label">camera</span><span class="bus-arrow">──►</span><span
class="bus-bytes">future</span>
</div>
<div class="bus-row" style="opacity: 0.3;">
<span class="bus-label">BCI</span><span class="bus-arrow">──►</span><span
class="bus-bytes">future</span>
</div>
</div>
</div>
</div>
<div class="powered">Built with <span>DreamStack</span> — zero-framework receiver</div>
<div class="badge">This client: <strong>~300 lines</strong> · No framework · Just bytes</div>
<script>
// ════════════════════════════════════════════════════════
// DreamStack Bitstream Receiver — All Features
// Zero framework. Renders bytes. Sends inputs.
// ════════════════════════════════════════════════════════
const HEADER_SIZE = 16;
const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10;
const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40;
const FRAME_PING = 0xFE;
const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11;
const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21;
const INPUT_SCROLL = 0x50;
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
const canvas = document.getElementById('display');
const ctx = canvas.getContext('2d');
const W = 600, H = 400;
// State
let framesRecv = 0, inputsSent = 0, lastSeq = 0;
let lastSecBytes = 0, lastSecFrames = 0, lastSecTime = performance.now();
let lastFrameTime = 0, currentMode = '—';
let prevFrameData = null; // for delta reconstruction
let audioChunksRecv = 0, signalFrames = 0;
// Per-bus byte counters (reset each second)
let busBytes = { pixel: 0, delta: 0, signal: 0, neural: 0, audio: 0, pointer: 0, key: 0, scroll: 0 };
// ── Feature 4: Audio playback ──
let audioCtx = null;
let audioNextTime = 0;
const AUDIO_SAMPLE_RATE = 22050;
function playAudioChunk(samples) {
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE });
const buffer = audioCtx.createBuffer(1, samples.length, AUDIO_SAMPLE_RATE);
buffer.copyToChannel(samples, 0);
const src = audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (audioNextTime < now) audioNextTime = now;
src.start(audioNextTime);
audioNextTime += buffer.duration;
}
// ── Feature 3: Signal diff renderer ──
function renderFromSignals(state) {
ctx.clearRect(0, 0, W, H);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1;
for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
const { bx, by, br, tx, ty } = state;
// Connection line
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(tx, ty);
ctx.strokeStyle = 'rgba(139,92,246,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
// Target crosshair
ctx.strokeStyle = 'rgba(99,102,241,0.08)'; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(tx, 0); ctx.lineTo(tx, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, ty); ctx.lineTo(W, ty); ctx.stroke(); ctx.setLineDash([]);
// Target dot
ctx.beginPath(); ctx.arc(tx, ty, 6, 0, Math.PI * 2); ctx.fillStyle = 'rgba(99,102,241,0.3)'; ctx.fill();
// Ball glow
ctx.beginPath(); ctx.arc(bx, by, br + 16, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.03)'; ctx.fill();
ctx.beginPath(); ctx.arc(bx, by, br + 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.08)'; ctx.fill();
// Ball
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(bx - br * 0.3, by - br * 0.3, br * 0.1, bx, by, br);
grad.addColorStop(0, '#c4b5fd'); grad.addColorStop(1, '#8b5cf6');
ctx.fillStyle = grad; ctx.shadowColor = '#8b5cf6'; ctx.shadowBlur = 25; ctx.fill(); ctx.shadowBlur = 0;
ctx.font = '10px Inter'; ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillText('📡 Signal-rendered on receiver', 10, H - 10);
}
// ── Feature 1: Delta decompression ──
// RLE decompression matching Rust codec: 0x00 + 2-byte LE count
function rleDecompress(compressed, expectedLen) {
const out = new Uint8Array(expectedLen);
let ci = 0, oi = 0;
while (ci < compressed.length && oi < expectedLen) {
if (compressed[ci] === 0 && ci + 2 < compressed.length) {
const run = compressed[ci + 1] | (compressed[ci + 2] << 8);
for (let j = 0; j < run && oi < expectedLen; j++) out[oi++] = 0;
ci += 3;
} else {
out[oi++] = compressed[ci++];
}
}
return out;
}
function applyDelta(prev, delta) {
const result = new Uint8Array(prev.length);
for (let i = 0; i < prev.length; i++) result[i] = prev[i] ^ delta[i];
return result;
}
// ── Protocol helpers ──
function decodeHeader(buf) {
const v = new DataView(buf.buffer || buf, buf.byteOffset || 0);
return {
type: v.getUint8(0), flags: v.getUint8(1), seq: v.getUint16(2, true), timestamp: v.getUint32(4, true),
width: v.getUint16(8, true), height: v.getUint16(10, true), length: v.getUint32(12, true)
};
}
function encodeInput(type, payload) {
const header = new ArrayBuffer(HEADER_SIZE);
const v = new DataView(header);
v.setUint8(0, type); v.setUint8(1, FLAG_INPUT);
v.setUint16(2, inputsSent & 0xFFFF, true);
v.setUint32(4, Math.round(performance.now()), true);
v.setUint32(12, payload.byteLength, true);
const msg = new Uint8Array(HEADER_SIZE + payload.byteLength);
msg.set(new Uint8Array(header), 0);
msg.set(new Uint8Array(payload), HEADER_SIZE);
return msg;
}
function pointerPayload(x, y, buttons) {
const b = new ArrayBuffer(5), v = new DataView(b);
v.setUint16(0, x, true); v.setUint16(2, y, true); v.setUint8(4, buttons);
return b;
}
function keyPayload(keycode, modifiers) {
const b = new ArrayBuffer(3), v = new DataView(b);
v.setUint16(0, keycode, true); v.setUint8(2, modifiers);
return b;
}
function scrollPayload(dx, dy) {
const b = new ArrayBuffer(4), v = new DataView(b);
v.setInt16(0, dx, true); v.setInt16(2, dy, true);
return b;
}
// ── WebSocket ──
let ws = null;
function connect() {
ws = new WebSocket('ws://localhost:9100');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('connStatus').className = 'connection connected';
document.getElementById('connText').textContent = 'Receiving bitstream';
};
ws.onmessage = e => {
if (!(e.data instanceof ArrayBuffer) || e.data.byteLength < HEADER_SIZE) return;
const bytes = new Uint8Array(e.data);
const header = decodeHeader(bytes);
const payload = bytes.subarray(HEADER_SIZE, HEADER_SIZE + header.length);
switch (header.type) {
case FRAME_PIXELS:
case FRAME_NEURAL: {
// Raw pixel frame or neural frame
if (payload.length === header.width * header.height * 4) {
const imgData = new ImageData(new Uint8ClampedArray(payload.buffer, payload.byteOffset, payload.length), header.width, header.height);
ctx.putImageData(imgData, 0, 0);
prevFrameData = new Uint8Array(payload);
}
currentMode = header.type === FRAME_NEURAL ? 'neural' : 'pixel';
busBytes[header.type === FRAME_NEURAL ? 'neural' : 'pixel'] += e.data.byteLength;
break;
}
case FRAME_DELTA: {
// Feature 1: Delta frame — decompress + apply XOR
if (prevFrameData) {
const expectedLen = header.width * header.height * 4;
const delta = rleDecompress(payload, expectedLen);
const fullFrame = applyDelta(prevFrameData, delta);
const imgData = new ImageData(new Uint8ClampedArray(fullFrame.buffer), header.width, header.height);
ctx.putImageData(imgData, 0, 0);
prevFrameData = fullFrame;
}
currentMode = 'delta';
busBytes.delta += e.data.byteLength;
break;
}
case FRAME_SIGNAL_SYNC:
case FRAME_SIGNAL_DIFF: {
// Feature 3: Signal diff — parse JSON, render locally
const json = new TextDecoder().decode(payload);
try {
const state = JSON.parse(json);
renderFromSignals(state);
signalFrames++;
} catch (err) { /* parse error */ }
currentMode = 'signal';
busBytes.signal += e.data.byteLength;
break;
}
case FRAME_AUDIO: {
// Feature 4: Audio playback
const samples = new Float32Array(payload.buffer, payload.byteOffset, payload.length / 4);
playAudioChunk(samples);
audioChunksRecv++;
busBytes.audio += e.data.byteLength;
break;
}
case FRAME_PING:
// Keepalive — ignore
return;
}
framesRecv++;
lastSeq = header.seq;
lastSecBytes += e.data.byteLength;
lastSecFrames++;
lastFrameTime = performance.now();
};
ws.onclose = () => {
document.getElementById('connStatus').className = 'connection disconnected';
document.getElementById('connText').textContent = 'Disconnected — reconnecting...';
setTimeout(connect, 2000);
};
ws.onerror = () => {
document.getElementById('connStatus').className = 'connection disconnected';
document.getElementById('connText').textContent = 'Relay not found — cargo run -p ds-stream';
};
}
// ── Feature 2: Full Input Capture ──
function sendInput(buf) {
if (ws && ws.readyState === WebSocket.OPEN) { ws.send(buf); inputsSent++; }
}
// Pointer: click = immediate action, drag = full down/move/up
let isDragging = false;
canvas.addEventListener('mousedown', e => {
const r = canvas.getBoundingClientRect();
const x = Math.round(e.clientX - r.left), y = Math.round(e.clientY - r.top);
isDragging = true;
sendInput(encodeInput(INPUT_PTR_DOWN, pointerPayload(x, y, e.buttons)));
busBytes.pointer += HEADER_SIZE + 5;
});
canvas.addEventListener('mousemove', e => {
if (!isDragging) return;
const r = canvas.getBoundingClientRect();
const x = Math.round(e.clientX - r.left), y = Math.round(e.clientY - r.top);
sendInput(encodeInput(INPUT_POINTER, pointerPayload(x, y, e.buttons)));
busBytes.pointer += HEADER_SIZE + 5;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
sendInput(encodeInput(INPUT_PTR_UP, pointerPayload(0, 0, 0)));
busBytes.pointer += HEADER_SIZE + 5;
}
});
// Keyboard
document.addEventListener('keydown', e => {
const mods = (e.shiftKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.altKey ? 4 : 0) | (e.metaKey ? 8 : 0);
sendInput(encodeInput(INPUT_KEY_DOWN, keyPayload(e.keyCode, mods)));
busBytes.key += HEADER_SIZE + 3;
});
document.addEventListener('keyup', e => {
const mods = (e.shiftKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.altKey ? 4 : 0) | (e.metaKey ? 8 : 0);
sendInput(encodeInput(INPUT_KEY_UP, keyPayload(e.keyCode, mods)));
busBytes.key += HEADER_SIZE + 3;
});
// Scroll — resize ball
canvas.addEventListener('wheel', e => {
e.preventDefault();
sendInput(encodeInput(INPUT_SCROLL, scrollPayload(Math.round(e.deltaX), Math.round(e.deltaY))));
busBytes.scroll += HEADER_SIZE + 4;
}, { passive: false });
// Touch — for mobile receivers
function touchPayload(id, x, y, phase) {
const b = new ArrayBuffer(6), v = new DataView(b);
v.setUint8(0, id); v.setUint16(1, x, true); v.setUint16(3, y, true); v.setUint8(5, phase);
return b;
}
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
for (const t of e.changedTouches) {
const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top);
sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0)));
busBytes.pointer += HEADER_SIZE + 6;
}
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
for (const t of e.changedTouches) {
const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top);
sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0)));
busBytes.pointer += HEADER_SIZE + 6;
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
for (const t of e.changedTouches) {
sendInput(encodeInput(INPUT_TOUCH_END, touchPayload(t.identifier & 0xFF, 0, 0, 1)));
busBytes.pointer += HEADER_SIZE + 6;
}
});
// ── Stats ──
setInterval(() => {
const now = performance.now(), elapsed = (now - lastSecTime) / 1000;
const fps = Math.round(lastSecFrames / elapsed);
const bw = lastSecBytes / 1024 / 1024 / elapsed;
document.getElementById('statFps').textContent = fps;
document.getElementById('statLatency').textContent = lastFrameTime ? Math.round(now - lastFrameTime) + 'ms' : '—';
document.getElementById('statMode').textContent = currentMode;
document.getElementById('statBandwidth').textContent = bw < 1 ? (bw * 1024).toFixed(0) + 'KB/s' : bw.toFixed(1) + 'MB/s';
document.getElementById('statFrames').textContent = framesRecv;
document.getElementById('statInputs').textContent = inputsSent;
document.getElementById('statDelta').textContent = currentMode === 'delta' ?
((1 - lastSecBytes / (W * H * 4 * lastSecFrames || 1)) * 100).toFixed(0) + '%' : '—';
document.getElementById('statAudio').textContent = audioChunksRecv > 0 ? audioChunksRecv : '—';
document.getElementById('statSignals').textContent = signalFrames > 0 ? signalFrames : '—';
// Bus indicators
function busUpdate(id, bytes) {
const el = document.getElementById(id);
const okEl = document.getElementById(id.replace('bus', 'bus') + 'Ok');
if (bytes > 0) {
el.textContent = bytes > 1024 ? (bytes / 1024).toFixed(0) + 'KB' : bytes + 'B';
if (okEl) okEl.style.color = 'rgba(34,197,94,0.8)';
} else {
if (okEl) okEl.style.color = 'rgba(255,255,255,0.08)';
}
}
busUpdate('busPixels', busBytes.pixel);
busUpdate('busDelta', busBytes.delta);
busUpdate('busSignals', busBytes.signal);
busUpdate('busNeural', busBytes.neural);
busUpdate('busAudio', busBytes.audio);
busUpdate('busPointer', busBytes.pointer);
busUpdate('busKeyboard', busBytes.key);
busUpdate('busScroll', busBytes.scroll);
lastSecBytes = 0; lastSecFrames = 0; lastSecTime = now;
busBytes = { pixel: 0, delta: 0, signal: 0, neural: 0, audio: 0, pointer: 0, key: 0, scroll: 0 };
}, 1000);
connect();
</script>
</body>
</html>