"use strict"; const elements = { canvas: document.getElementById("render-canvas"), stage: document.getElementById("canvas-stage"), overlay: document.getElementById("stream-overlay"), overlayTitle: document.getElementById("overlay-title"), overlayDetail: document.getElementById("overlay-detail"), connectionDot: document.getElementById("connection-dot"), connectionLabel: document.getElementById("connection-label"), connectionEndpoint: document.getElementById("connection-endpoint"), reconnect: document.getElementById("reconnect-button"), fps: document.getElementById("fps-value"), latency: document.getElementById("latency-value"), resolution: document.getElementById("resolution-value"), throughput: document.getElementById("throughput-value"), frameCounter: document.getElementById("frame-counter"), frequency: document.getElementById("frequency-value"), tuneDown: document.getElementById("tune-down"), tuneUp: document.getElementById("tune-up"), modeLabel: document.getElementById("mode-label"), modeButtons: [...document.querySelectorAll(".mode-button")], bandwidth: document.getElementById("bandwidth"), bandwidthValue: document.getElementById("bandwidth-value"), gain: document.getElementById("gain"), gainValue: document.getElementById("gain-value"), maxHold: document.getElementById("max-hold"), smoothing: document.getElementById("smoothing"), clearSelection: document.getElementById("clear-selection"), pause: document.getElementById("pause-button"), pauseLabel: document.getElementById("pause-label") }; const state = { socket: null, reconnectTimer: 0, frameTimer: 0, frameTimeout: 0, framePending: false, requestStarted: 0, lastFrameAt: 0, smoothedFps: 0, frameCount: 0, paused: false, requestedWidth: 960, requestedHeight: 600 }; const context = elements.canvas.getContext("2d", {alpha: false, desynchronized: true}); const query = new URLSearchParams(window.location.search); const endpoint = query.get("ws") || "ws://127.0.0.1:8848/renderive"; elements.connectionEndpoint.textContent = endpoint; function setConnectionState(kind, title, detail) { elements.connectionDot.classList.remove("online", "offline"); if (kind) elements.connectionDot.classList.add(kind); elements.connectionLabel.textContent = title; elements.overlayTitle.textContent = title; elements.overlayDetail.textContent = detail; if (kind !== "online") elements.overlay.classList.remove("hidden"); } function sendEvent(type, payload = {}) { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return false; state.socket.send(JSON.stringify({category: "event", type, ...payload})); return true; } function currentFrequency() { return Number(elements.frequency.dataset.value); } function setFrequency(value, notify = true) { const frequency = Math.max(1, Math.min(6000, Number(value.toFixed(3)))); elements.frequency.dataset.value = String(frequency); elements.frequency.textContent = frequency.toFixed(3); if (notify) sendEvent("control", {control: "center_frequency_mhz", value: frequency}); } function activeMode() { return elements.modeButtons.find(button => button.classList.contains("active"))?.dataset.mode || "FM"; } function buttonEnabled(button) { return button.getAttribute("aria-pressed") === "true"; } function setToggle(button, enabled, notify = true) { button.setAttribute("aria-pressed", String(enabled)); button.classList.toggle("active", enabled); if (!notify) return; const control = button === elements.maxHold ? "max_hold" : "smoothing"; sendEvent("control", {control, value: enabled}); } function sendControlSnapshot() { sendEvent("control", {control: "mode", value: activeMode()}); sendEvent("control", {control: "center_frequency_mhz", value: currentFrequency()}); sendEvent("control", {control: "bandwidth_khz", value: Number(elements.bandwidth.value)}); sendEvent("control", {control: "gain_db", value: Number(elements.gain.value)}); sendEvent("control", {control: "max_hold", value: buttonEnabled(elements.maxHold)}); sendEvent("control", {control: "smoothing", value: buttonEnabled(elements.smoothing)}); } function syncViewport() { const bounds = elements.stage.getBoundingClientRect(); const width = Math.max(240, Math.min(1920, Math.round(bounds.width))); const height = Math.max(180, Math.min(1200, Math.round(bounds.height))); if (width === state.requestedWidth && height === state.requestedHeight) return; state.requestedWidth = width; state.requestedHeight = height; elements.canvas.width = width; elements.canvas.height = height; sendEvent("resize", {width, height}); } function streamActive() { return !state.paused && !document.hidden; } function stopFramePump() { window.clearTimeout(state.frameTimer); state.frameTimer = 0; } function clearFrameRequest() { window.clearTimeout(state.frameTimeout); state.frameTimeout = 0; state.framePending = false; } function scheduleFrame(delay = 42) { stopFramePump(); if (!streamActive()) return; state.frameTimer = window.setTimeout(() => { state.frameTimer = 0; requestFrame(); }, delay); } function requestFrame() { if (!streamActive() || state.framePending) return; if (!sendEvent("frame")) return; state.requestStarted = performance.now(); state.framePending = true; window.clearTimeout(state.frameTimeout); state.frameTimeout = window.setTimeout(() => { state.frameTimeout = 0; if (!state.framePending) return; state.framePending = false; if (!streamActive() || state.socket?.readyState !== WebSocket.OPEN) return; sendEvent("show"); scheduleFrame(42); }, 2000); } function syncStreamActivity() { stopFramePump(); const active = streamActive(); if (!active) { state.lastFrameAt = 0; state.smoothedFps = 0; elements.fps.textContent = "0.0"; } if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return; sendEvent(active ? "show" : "hide"); if (active) requestFrame(); } function drawPixelFrame(buffer) { clearFrameRequest(); if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) throw new Error("像素帧长度无效"); const bytes = new Uint8Array(buffer); if (bytes[0] !== 82 || bytes[1] !== 86 || bytes[2] !== 80 || bytes[3] !== 49) throw new Error("像素帧标识无效"); const view = new DataView(buffer); const width = view.getUint32(4, true); const height = view.getUint32(8, true); const stride = view.getUint32(12, true); if (!width || !height || stride !== width * 4 || buffer.byteLength !== 16 + stride * height) throw new Error("像素帧尺寸无效"); if (elements.canvas.width !== width || elements.canvas.height !== height) { elements.canvas.width = width; elements.canvas.height = height; } const pixels = new Uint8ClampedArray(buffer, 16, width * height * 4); context.putImageData(new ImageData(pixels, width, height), 0, 0); const now = performance.now(); const elapsed = state.lastFrameAt ? now - state.lastFrameAt : 0; if (elapsed > 0) { const instantaneous = 1000 / elapsed; state.smoothedFps = state.smoothedFps ? state.smoothedFps * 0.82 + instantaneous * 0.18 : instantaneous; } const roundTrip = Math.max(0, now - state.requestStarted); const megabitsPerSecond = roundTrip > 0 ? buffer.byteLength * 8 / roundTrip / 1000 : 0; state.lastFrameAt = now; state.frameCount += 1; elements.fps.textContent = state.smoothedFps.toFixed(1); elements.latency.textContent = roundTrip.toFixed(1); elements.resolution.textContent = `${width} × ${height}`; elements.throughput.textContent = megabitsPerSecond.toFixed(1); elements.frameCounter.textContent = state.frameCount.toLocaleString("zh-CN"); elements.overlay.classList.add("hidden"); scheduleFrame(); } function connect() { window.clearTimeout(state.reconnectTimer); stopFramePump(); clearFrameRequest(); if (state.socket && state.socket.readyState < WebSocket.CLOSING) state.socket.close(1000, "reconnect"); setConnectionState("", "正在连接", "建立纯 WebSocket 像素通道"); const socket = new WebSocket(endpoint); state.socket = socket; socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { if (socket !== state.socket) return; setConnectionState("online", "通道在线", "等待首个 RGBA 像素帧"); elements.connectionLabel.textContent = "通道在线"; syncViewport(); sendEvent("resize", {width: state.requestedWidth, height: state.requestedHeight}); sendControlSnapshot(); syncStreamActivity(); }); socket.addEventListener("message", event => { if (socket !== state.socket) return; try { drawPixelFrame(event.data); } catch (error) { clearFrameRequest(); setConnectionState("offline", "帧协议错误", error.message); socket.close(1003, "invalid pixel frame"); } }); socket.addEventListener("close", event => { if (socket !== state.socket) return; stopFramePump(); clearFrameRequest(); setConnectionState("offline", "通道离线", event.reason || `WebSocket 已关闭 (${event.code})`); state.reconnectTimer = window.setTimeout(connect, 1200); }); socket.addEventListener("error", () => { if (socket === state.socket) setConnectionState("offline", "连接失败", "确认 Renderive_Web_Server 正在监听 8848 端口"); }); } function canvasPosition(event) { const bounds = elements.canvas.getBoundingClientRect(); return { x: (event.clientX - bounds.left) * elements.canvas.width / bounds.width, y: (event.clientY - bounds.top) * elements.canvas.height / bounds.height }; } function modifiers(event) { return (event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); } function mouseButton(button) { return button === 0 ? "left" : button === 2 ? "right" : button === 1 ? "middle" : "none"; } function pointerPayload(event) { const position = canvasPosition(event); return {x: position.x, y: position.y, button: mouseButton(event.button), buttons: event.buttons & 7, modifiers: modifiers(event)}; } elements.canvas.addEventListener("pointerdown", event => { elements.canvas.focus({preventScroll: true}); elements.canvas.setPointerCapture(event.pointerId); sendEvent("pointer_press", pointerPayload(event)); }); elements.canvas.addEventListener("pointermove", event => sendEvent("pointer_move", pointerPayload(event))); elements.canvas.addEventListener("pointerup", event => { sendEvent("pointer_release", pointerPayload(event)); if (elements.canvas.hasPointerCapture(event.pointerId)) elements.canvas.releasePointerCapture(event.pointerId); }); elements.canvas.addEventListener("pointerleave", () => sendEvent("leave")); elements.canvas.addEventListener("contextmenu", event => event.preventDefault()); elements.canvas.addEventListener("wheel", event => { event.preventDefault(); const position = canvasPosition(event); sendEvent("wheel", {x: position.x, y: position.y, buttons: event.buttons & 7, modifiers: modifiers(event), pixelDeltaX: -event.deltaX, pixelDeltaY: -event.deltaY, angleDeltaX: -event.deltaX, angleDeltaY: -event.deltaY}); }, {passive: false}); function normalizedKey(key) { return key === " " ? "Space" : key; } elements.canvas.addEventListener("keydown", event => { sendEvent("key_press", {key: normalizedKey(event.key), nativeKey: event.keyCode || 0, modifiers: modifiers(event), repeat: event.repeat}); }); elements.canvas.addEventListener("keyup", event => { sendEvent("key_release", {key: normalizedKey(event.key), nativeKey: event.keyCode || 0, modifiers: modifiers(event), repeat: false}); }); elements.tuneDown.addEventListener("click", () => setFrequency(currentFrequency() - 0.1)); elements.tuneUp.addEventListener("click", () => setFrequency(currentFrequency() + 0.1)); elements.modeButtons.forEach(button => button.addEventListener("click", () => { elements.modeButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)); elements.modeLabel.textContent = button.dataset.mode === "FM" ? "FM WIDE" : `${button.dataset.mode} MODE`; sendEvent("control", {control: "mode", value: button.dataset.mode}); })); elements.bandwidth.addEventListener("input", () => { const value = Number(elements.bandwidth.value); elements.bandwidthValue.textContent = `${value} kHz`; sendEvent("control", {control: "bandwidth_khz", value}); }); elements.gain.addEventListener("input", () => { const value = Number(elements.gain.value); elements.gainValue.textContent = `${value >= 0 ? "+" : ""}${value} dB`; sendEvent("control", {control: "gain_db", value}); }); elements.maxHold.addEventListener("click", () => setToggle(elements.maxHold, !buttonEnabled(elements.maxHold))); elements.smoothing.addEventListener("click", () => setToggle(elements.smoothing, !buttonEnabled(elements.smoothing))); elements.clearSelection.addEventListener("click", () => sendEvent("control", {control: "clear_selection"})); elements.pause.addEventListener("click", () => { state.paused = !state.paused; elements.pause.setAttribute("aria-pressed", String(state.paused)); elements.pause.classList.toggle("paused", state.paused); elements.pauseLabel.textContent = state.paused ? "PAUSED" : "LIVE"; syncStreamActivity(); }); elements.reconnect.addEventListener("click", connect); let resizeFrame = 0; new ResizeObserver(() => { cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(syncViewport); }).observe(elements.stage); document.addEventListener("visibilitychange", syncStreamActivity); window.addEventListener("beforeunload", () => { stopFramePump(); sendEvent("hide"); }); syncViewport(); connect();