373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
"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();
|