Files
Renderive/webapp/app.js
T
2026-08-11 08:32:25 +08:00

373 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();