- 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_*
715 lines
No EOL
29 KiB
HTML
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> |