初版网页

This commit is contained in:
2026-08-10 21:05:36 +08:00
parent cfc0849081
commit f50e9f7c1e
19 changed files with 1697 additions and 16 deletions
+335
View File
@@ -0,0 +1,335 @@
"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,
framePending: false,
requestStarted: 0,
lastFrameAt: 0,
smoothedFps: 0,
frameCount: 0,
paused: false,
manuallyReconnecting: 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 scheduleFrame(delay = 42) {
window.clearTimeout(state.frameTimer);
if (!state.paused)
state.frameTimer = window.setTimeout(requestFrame, delay);
}
function requestFrame() {
if (state.paused || state.framePending)
return;
state.requestStarted = performance.now();
state.framePending = sendEvent("frame");
}
function drawPixelFrame(buffer) {
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;
state.framePending = false;
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);
window.clearTimeout(state.frameTimer);
state.framePending = false;
if (state.socket && state.socket.readyState < WebSocket.CLOSING) {
state.manuallyReconnecting = true;
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;
state.manuallyReconnecting = false;
setConnectionState("online", "通道在线", "等待首个 RGBA 像素帧");
elements.connectionLabel.textContent = "通道在线";
sendEvent("show");
syncViewport();
sendEvent("resize", {width: state.requestedWidth, height: state.requestedHeight});
sendControlSnapshot();
requestFrame();
});
socket.addEventListener("message", event => {
if (socket !== state.socket)
return;
try {
drawPixelFrame(event.data);
} catch (error) {
state.framePending = false;
setConnectionState("offline", "帧协议错误", error.message);
socket.close(1003, "invalid pixel frame");
}
});
socket.addEventListener("close", event => {
if (socket !== state.socket)
return;
state.framePending = false;
setConnectionState("offline", "通道离线", event.reason || `WebSocket 已关闭 (${event.code})`);
if (!state.manuallyReconnecting)
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";
sendEvent(state.paused ? "hide" : "show");
if (!state.paused)
requestFrame();
});
elements.reconnect.addEventListener("click", connect);
let resizeFrame = 0;
new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(syncViewport);
}).observe(elements.stage);
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
sendEvent("hide");
} else if (!state.paused) {
sendEvent("show");
requestFrame();
}
});
window.addEventListener("beforeunload", () => sendEvent("hide"));
syncViewport();
connect();