Files
overlord/Overlord-Server/public/assets/remotedesktop.js
T
2026-03-06 14:09:03 -06:00

1023 lines
31 KiB
JavaScript

import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
(function () {
const clientId = new URLSearchParams(location.search).get("clientId");
if (!clientId) {
alert("Missing clientId");
return;
}
const clientLabel = document.getElementById("clientLabel");
clientLabel.textContent = clientId;
const ws = new WebSocket(
(location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
"/api/clients/" +
clientId +
"/rd/ws",
);
const displaySelect = document.getElementById("displaySelect");
const refreshBtn = document.getElementById("refreshDisplays");
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
const fullscreenBtn = document.getElementById("fullscreenBtn");
const mouseCtrl = document.getElementById("mouseCtrl");
const kbdCtrl = document.getElementById("kbdCtrl");
const cursorCtrl = document.getElementById("cursorCtrl");
const duplicationCtrl = document.getElementById("duplicationCtrl");
const smoothingSlider = document.getElementById("smoothingSlider");
const smoothingValue = document.getElementById("smoothingValue");
const qualitySlider = document.getElementById("qualitySlider");
const qualityValue = document.getElementById("qualityValue");
const codecH264 = document.getElementById("codecH264");
const codecMode = document.getElementById("codecMode");
const canvas = document.getElementById("frameCanvas");
const canvasContainer = document.getElementById("canvasContainer");
const ctx = canvas.getContext("2d");
const agentFps = document.getElementById("agentFps");
const viewerFps = document.getElementById("viewerFps");
const inputLatency = document.getElementById("inputLatency");
const statusEl = document.getElementById("streamStatus");
ws.binaryType = "arraybuffer";
let activeClientId = clientId;
let renderCount = 0;
let renderWindowStart = performance.now();
let lastFrameAt = 0;
let desiredStreaming = false;
let streamState = "connecting";
let frameWatchTimer = null;
let offlineTimer = null;
let frameWidth = 0;
let frameHeight = 0;
let latencyAvg = null;
let smoothingPct = 20;
let smoothPoint = null;
let pendingMove = null;
let moveTimer = null;
let frameDecodeBusy = false;
let pendingFrame = null;
let videoDecoder = null;
let h264TimestampUs = 0;
const codecPrefKey = "rdCodecPreferH264";
let prefersH264 = typeof VideoDecoder === "function";
let h264LowFpsStreak = 0;
let h264FirstFrameAt = 0;
let h264FramesSeen = 0;
let h264KeyframeErrorStreak = 0;
let h264RecoveryAttempts = 0;
let h264LastDecodeWarnAt = 0;
const H264_LOW_FPS_THRESHOLD = 6;
const H264_FALLBACK_WARMUP_MS = 10000;
const H264_MIN_FRAMES_BEFORE_FALLBACK = 120;
const H264_LOW_FPS_STREAK_LIMIT = 120;
const H264_KEYFRAME_ERROR_RESTART_THRESHOLD = 24;
const H264_MAX_RECOVERY_ATTEMPTS = 1;
const H264_DECODE_WARN_THROTTLE_MS = 2000;
const mouseMoveIntervalMs = 33;
const inputBackpressureBytes = 256 * 1024;
let lastMoveSentAt = 0;
function resetH264RuntimeState() {
h264TimestampUs = 0;
h264LowFpsStreak = 0;
h264FirstFrameAt = 0;
h264FramesSeen = 0;
h264KeyframeErrorStreak = 0;
}
function resetH264SessionState() {
resetH264RuntimeState();
h264RecoveryAttempts = 0;
h264LastDecodeWarnAt = 0;
}
const storedCodecPref = localStorage.getItem(codecPrefKey);
if (storedCodecPref === "0") {
prefersH264 = false;
} else if (storedCodecPref === "1") {
prefersH264 = typeof VideoDecoder === "function";
}
if (codecH264) {
codecH264.checked = prefersH264;
codecH264.disabled = typeof VideoDecoder !== "function";
}
function setCodecModeLabel(mode, detail) {
if (!codecMode) return;
const suffix = detail ? ` (${detail})` : "";
codecMode.textContent = `Codec: ${String(mode || "auto").toUpperCase()}${suffix}`;
}
setCodecModeLabel(prefersH264 ? "h264" : "jpeg", "preferred");
setStreamState("connecting", "Connecting");
function updateFpsDisplay(agentValue) {
if (agentValue !== undefined && agentValue !== null && agentFps) {
agentFps.textContent = String(agentValue);
}
const now = performance.now();
renderCount += 1;
const elapsed = now - renderWindowStart;
if (elapsed >= 1000 && viewerFps) {
const fps = Math.round((renderCount * 1000) / elapsed);
viewerFps.textContent = String(fps);
renderCount = 0;
renderWindowStart = now;
}
}
function setStreamState(state, text) {
streamState = state;
if (statusEl) {
const icons = {
connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
starting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
stopping: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
streaming: '<i class="fa-solid fa-circle text-emerald-400"></i>',
idle: '<i class="fa-solid fa-circle text-slate-400"></i>',
stalled: '<i class="fa-solid fa-triangle-exclamation text-amber-400"></i>',
offline: '<i class="fa-solid fa-plug-circle-xmark text-rose-400"></i>',
disconnected: '<i class="fa-solid fa-link-slash text-slate-400"></i>',
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
};
const label = text ||
(state === "streaming" ? "Streaming" :
state === "starting" ? "Starting" :
state === "stopping" ? "Stopping" :
state === "offline" ? "Client offline" :
state === "disconnected" ? "Disconnected" :
state === "stalled" ? "No frames" :
state === "idle" ? "Stopped" :
"Connecting");
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
const styles = {
streaming: "bg-emerald-900/40 text-emerald-100 border-emerald-700/70",
starting: "bg-sky-900/40 text-sky-100 border-sky-700/70",
stopping: "bg-amber-900/40 text-amber-100 border-amber-700/70",
stalled: "bg-amber-900/40 text-amber-100 border-amber-700/70",
offline: "bg-rose-900/40 text-rose-100 border-rose-700/70",
error: "bg-rose-900/40 text-rose-100 border-rose-700/70",
disconnected: "bg-slate-800 text-slate-300 border-slate-700",
idle: "bg-slate-800 text-slate-300 border-slate-700",
connecting: "bg-slate-800 text-slate-300 border-slate-700",
};
statusEl.className = `${base} ${styles[state] || styles.idle}`;
}
if (canvasContainer) {
canvasContainer.dataset.streamState = state;
}
if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
if (agentFps) agentFps.textContent = "--";
if (viewerFps) viewerFps.textContent = "--";
renderCount = 0;
renderWindowStart = performance.now();
}
updateControls();
}
function updateControls() {
const wsOpen = ws.readyState === WebSocket.OPEN;
const isStarting = streamState === "starting";
const isStreaming = streamState === "streaming";
const isStopping = streamState === "stopping";
const isStalled = streamState === "stalled";
const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
if (startBtn) {
startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
}
if (stopBtn) {
stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming && !isStopping && !isStalled);
}
}
function updateLatency(ms) {
if (ms < 0 || !Number.isFinite(ms)) return;
latencyAvg = latencyAvg == null ? ms : latencyAvg * 0.8 + ms * 0.2;
if (inputLatency) {
inputLatency.textContent = `${Math.round(latencyAvg)} ms`;
}
}
function clearOfflineTimer() {
if (offlineTimer) {
clearTimeout(offlineTimer);
offlineTimer = null;
}
}
function scheduleOffline(reason) {
clearOfflineTimer();
setStreamState("connecting", "Reconnecting");
offlineTimer = setTimeout(() => {
const now = performance.now();
if (!lastFrameAt || now - lastFrameAt > 3000) {
desiredStreaming = false;
setStreamState("offline", reason || "Client offline");
}
}, 3000);
}
function handleStatus(msg) {
if (!msg || msg.type !== "status" || !msg.status) return;
if (msg.status === "offline") {
scheduleOffline(msg.reason);
return;
}
if (msg.status === "connecting") {
clearOfflineTimer();
setStreamState("connecting", "Connecting");
return;
}
if (msg.status === "online") {
clearOfflineTimer();
if (desiredStreaming) {
setStreamState("starting", "Reconnecting");
if (displaySelect && displaySelect.value !== undefined) {
sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10) || 0,
});
}
sendCmd("desktop_start", {});
} else {
setStreamState("idle", "Stopped");
}
}
}
function sendCmd(type, payload) {
if (!activeClientId) {
console.warn("No active client selected");
return;
}
if (ws.readyState !== WebSocket.OPEN) {
return;
}
const msg = { type, ...payload };
console.debug("rd: send", msg);
ws.send(encodeMsgpack(msg));
}
let monitors = 1;
function populateDisplays(count, monitorInfo) {
displaySelect.innerHTML = "";
const infoList = Array.isArray(monitorInfo) ? monitorInfo : null;
monitors = (infoList && infoList.length) ? infoList.length : (count || 1);
for (let i = 0; i < monitors; i++) {
const opt = document.createElement("option");
opt.value = i;
const info = infoList && infoList[i];
const w = info && Number(info.width);
const h = info && Number(info.height);
const sizeLabel = w > 0 && h > 0 ? ` (${w}x${h})` : "";
opt.textContent = "Display " + (i + 1) + sizeLabel;
displaySelect.appendChild(opt);
}
if (displaySelect.options.length) {
displaySelect.value = displaySelect.options[0].value;
}
}
async function fetchClientInfo() {
try {
const res = await fetch("/api/clients");
const data = await res.json();
const client = data.items.find((c) => c.id === activeClientId);
if (client) {
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
}
if (client) {
populateDisplays(client.monitors, client.monitorInfo);
}
if (duplicationCtrl) {
const os = (client?.os || "").toLowerCase();
const isWindows = os.includes("windows") || os.includes("win");
duplicationCtrl.disabled = !isWindows;
if (!isWindows) {
duplicationCtrl.checked = false;
}
}
} catch (e) {
console.warn("failed to fetch client info", e);
}
}
refreshBtn.addEventListener("click", fetchClientInfo);
function updateQualityLabel(val) {
if (qualityValue) {
qualityValue.textContent = `${val}%`;
}
}
function updateSmoothingLabel(val) {
if (smoothingValue) {
smoothingValue.textContent = `${val}%`;
}
}
function pushQuality(val) {
const q = Number(val) || 90;
const codec = q >= 100 ? "raw" : (prefersH264 ? "h264" : "jpeg");
console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec);
setCodecModeLabel(codec, "requested");
sendCmd("desktop_set_quality", { quality: q, codec });
}
if (codecH264) {
codecH264.addEventListener("change", function () {
resetH264SessionState();
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
if (!prefersH264) {
destroyVideoDecoder();
h264LowFpsStreak = 0;
}
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
});
}
displaySelect.addEventListener("change", function () {
console.debug("rd: select display", displaySelect.value);
sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10),
});
});
startBtn.addEventListener("click", function () {
if (displaySelect && displaySelect.value !== undefined) {
sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10) || 0,
});
}
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
desiredStreaming = true;
lastFrameAt = 0;
resetH264SessionState();
setStreamState("starting", "Starting stream");
sendCmd("desktop_start", {});
});
stopBtn.addEventListener("click", function () {
desiredStreaming = false;
setStreamState("stopping", "Stopping stream");
sendCmd("desktop_stop", {});
});
fullscreenBtn.addEventListener("click", function () {
if (canvasContainer.requestFullscreen) {
canvasContainer.requestFullscreen();
} else if (canvasContainer.webkitRequestFullscreen) {
canvasContainer.webkitRequestFullscreen();
} else if (canvasContainer.mozRequestFullScreen) {
canvasContainer.mozRequestFullScreen();
}
});
if (mouseCtrl) {
mouseCtrl.checked = false;
}
if (kbdCtrl) {
kbdCtrl.checked = false;
}
if (duplicationCtrl) {
duplicationCtrl.checked = false;
}
function pushInputToggles() {
if (mouseCtrl) {
sendCmd("desktop_enable_mouse", { enabled: !!mouseCtrl.checked });
}
if (kbdCtrl) {
sendCmd("desktop_enable_keyboard", { enabled: !!kbdCtrl.checked });
}
}
function pushCaptureToggles() {
if (cursorCtrl) {
sendCmd("desktop_enable_cursor", { enabled: cursorCtrl.checked });
}
if (duplicationCtrl && !duplicationCtrl.disabled) {
sendCmd("desktop_set_duplication", { enabled: !!duplicationCtrl.checked });
}
}
mouseCtrl.addEventListener("change", function () {
pushInputToggles();
});
kbdCtrl.addEventListener("change", function () {
if (kbdCtrl.checked) {
canvas.focus();
}
pushInputToggles();
});
cursorCtrl.addEventListener("change", function () {
pushCaptureToggles();
});
if (duplicationCtrl) {
duplicationCtrl.addEventListener("change", function () {
pushCaptureToggles();
});
}
if (qualitySlider) {
updateQualityLabel(qualitySlider.value);
qualitySlider.addEventListener("input", function () {
updateQualityLabel(qualitySlider.value);
pushQuality(qualitySlider.value);
});
}
if (smoothingSlider) {
updateSmoothingLabel(smoothingSlider.value);
smoothingSlider.addEventListener("input", function () {
smoothingPct = Number(smoothingSlider.value) || 0;
updateSmoothingLabel(smoothingSlider.value);
});
}
function isFramePacket(buf) {
return buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d;
}
function markFrameReceived() {
lastFrameAt = performance.now();
clearOfflineTimer();
if (streamState !== "streaming" && desiredStreaming) {
setStreamState("streaming", "Streaming");
}
}
function drawJpegFallback(blob, target) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = function () {
if (target) {
ctx.drawImage(img, target.x, target.y, target.w, target.h);
} else {
frameWidth = img.width || frameWidth;
frameHeight = img.height || frameHeight;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
}
URL.revokeObjectURL(url);
resolve(true);
};
img.onerror = function () {
URL.revokeObjectURL(url);
resolve(false);
};
img.src = url;
});
}
async function drawJpegSlice(slice, target) {
const blob = new Blob([slice], { type: "image/jpeg" });
try {
const bitmap = await createImageBitmap(blob);
if (target) {
ctx.drawImage(bitmap, target.x, target.y, target.w, target.h);
} else {
frameWidth = bitmap.width || frameWidth;
frameHeight = bitmap.height || frameHeight;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
}
bitmap.close();
return true;
} catch {
return drawJpegFallback(blob, target);
}
}
function destroyVideoDecoder() {
if (!videoDecoder) return;
try {
videoDecoder.close();
} catch {
// Ignore close errors when decoder is already shutting down.
}
videoDecoder = null;
resetH264RuntimeState();
}
function normalizeFallbackReason(reason) {
if (!reason) return "unspecified";
if (typeof reason === "string") return reason;
if (reason instanceof Error) return reason.message || String(reason);
if (typeof reason === "object") {
if (typeof reason.message === "string" && reason.message) {
return reason.message;
}
if (typeof reason.name === "string" && reason.name) {
return reason.name;
}
try {
return JSON.stringify(reason);
} catch {
return String(reason);
}
}
return String(reason);
}
function fallbackToJpegCodec(reason) {
if (!prefersH264) return;
const reasonText = normalizeFallbackReason(reason);
prefersH264 = false;
destroyVideoDecoder();
if (codecH264) codecH264.checked = false;
localStorage.setItem(codecPrefKey, "0");
console.warn("rd: falling back to jpeg codec", reasonText);
const q = Number(qualitySlider?.value) || 90;
setCodecModeLabel("jpeg", "fallback");
if (ws.readyState === WebSocket.OPEN) {
sendCmd("desktop_set_quality", {
quality: q,
codec: "jpeg",
source: "viewer_fallback",
reason: reasonText,
});
}
}
function tryRecoverH264Stream(reason = "h264_decode_error") {
if (!prefersH264 || !activeClientId) return false;
if (streamState !== "streaming" && streamState !== "stalled" && streamState !== "starting") {
return false;
}
if (ws.readyState !== WebSocket.OPEN) return false;
if (h264RecoveryAttempts >= H264_MAX_RECOVERY_ATTEMPTS) return false;
h264RecoveryAttempts += 1;
h264KeyframeErrorStreak = 0;
console.warn("rd: h264 decode stuck waiting for keyframe; auto-restarting stream once", {
reason,
attempt: h264RecoveryAttempts
});
sendCmd("desktop_stop", {
source: "rd_viewer",
reason: "h264_recovery_stop",
});
setTimeout(() => {
if (!prefersH264 || !activeClientId || ws.readyState !== WebSocket.OPEN) {
return;
}
resetH264RuntimeState();
sendCmd("desktop_start", {
source: "rd_viewer",
reason: "h264_recovery_restart",
});
const q = Number(qualitySlider?.value) || 90;
sendCmd("desktop_set_quality", {
quality: q,
codec: "h264",
source: "rd_viewer",
reason: "h264_recovery_quality_push",
});
}, 450);
return true;
}
function isKeyframeRequiredError(reason) {
const text = normalizeFallbackReason(reason).toLowerCase();
return text.includes("key frame is required") || text.includes("keyframe is required");
}
function isH264KeyFrame(data) {
for (let i = 0; i + 4 < data.length; i++) {
let startCodeLen = 0;
if (data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x01) {
startCodeLen = 3;
} else if (
i + 4 < data.length &&
data[i] === 0x00 &&
data[i + 1] === 0x00 &&
data[i + 2] === 0x00 &&
data[i + 3] === 0x01
) {
startCodeLen = 4;
}
if (!startCodeLen) continue;
const nalIndex = i + startCodeLen;
if (nalIndex >= data.length) break;
const nalType = data[nalIndex] & 0x1f;
if (nalType === 5) {
return true;
}
i = nalIndex;
}
return false;
}
function ensureVideoDecoder() {
if (videoDecoder) {
return true;
}
if (typeof VideoDecoder !== "function") {
return false;
}
try {
videoDecoder = new VideoDecoder({
output: (frame) => {
const width = frame.displayWidth || frame.codedWidth || frameWidth;
const height = frame.displayHeight || frame.codedHeight || frameHeight;
if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
canvas.width = width;
canvas.height = height;
frameWidth = width;
frameHeight = height;
}
try {
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
} finally {
frame.close();
}
},
error: (err) => {
console.warn("rd: h264 decoder error", err);
},
});
videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true });
return true;
} catch (err) {
console.warn("rd: h264 decoder unavailable", err);
fallbackToJpegCodec(err);
destroyVideoDecoder();
return false;
}
}
async function processFrameBuffer(buf) {
const fps = buf[5];
const format = buf[6];
if (format === 1) {
const jpegBytes = buf.slice(8);
setCodecModeLabel("jpeg", "active");
await drawJpegSlice(jpegBytes, null);
updateFpsDisplay(fps);
return;
}
if (format === 2 || format === 3) {
setCodecModeLabel(format === 3 ? "raw" : "jpeg", format === 3 ? "blocks" : "blocks");
if (buf.length < 16) return;
const dv = new DataView(buf.buffer, 8);
let pos = 0;
const width = dv.getUint16(pos, true);
pos += 2;
const height = dv.getUint16(pos, true);
pos += 2;
if (width > 0 && height > 0) {
frameWidth = width;
frameHeight = height;
}
const blockCount = dv.getUint16(pos, true);
pos += 2;
pos += 2;
if (
width > 0 &&
height > 0 &&
(canvas.width !== width || canvas.height !== height)
) {
canvas.width = width;
canvas.height = height;
}
for (let i = 0; i < blockCount; i++) {
if (pos + 12 > dv.byteLength) break;
const x = dv.getUint16(pos, true);
pos += 2;
const y = dv.getUint16(pos, true);
pos += 2;
const w = dv.getUint16(pos, true);
pos += 2;
const h = dv.getUint16(pos, true);
pos += 2;
const len = dv.getUint32(pos, true);
pos += 4;
const start = 8 + pos;
const end = start + len;
if (end > buf.length) break;
const slice = buf.subarray(start, end);
pos += len;
if (format === 2) {
await drawJpegSlice(slice, { x, y, w, h });
} else if (slice.length === w * h * 4) {
const imgData = new ImageData(new Uint8ClampedArray(slice), w, h);
ctx.putImageData(imgData, x, y);
}
}
updateFpsDisplay(fps);
return;
}
if (format === 4) {
setCodecModeLabel("h264", "active");
const h264Bytes = buf.slice(8);
if (!h264Bytes.length) return;
if (!ensureVideoDecoder()) {
fallbackToJpegCodec("WebCodecs decoder unavailable");
return;
}
if (!h264FirstFrameAt) {
h264FirstFrameAt = performance.now();
}
h264FramesSeen += 1;
const isKey = isH264KeyFrame(h264Bytes);
// If software H264 encode on the agent cannot keep up, automatically
// fall back to JPEG blocks for a smoother interactive stream.
const h264ElapsedMs = performance.now() - h264FirstFrameAt;
if ((fps || 0) <= H264_LOW_FPS_THRESHOLD) {
h264LowFpsStreak += 1;
} else {
h264LowFpsStreak = 0;
}
if (
h264ElapsedMs >= H264_FALLBACK_WARMUP_MS &&
h264FramesSeen >= H264_MIN_FRAMES_BEFORE_FALLBACK &&
h264LowFpsStreak >= H264_LOW_FPS_STREAK_LIMIT
) {
fallbackToJpegCodec(`low h264 fps (${fps})`);
return;
}
const frameIntervalUs = Math.floor(1_000_000 / Math.max(1, fps || 25));
const chunk = new EncodedVideoChunk({
type: isKey ? "key" : "delta",
timestamp: h264TimestampUs,
data: h264Bytes,
});
h264TimestampUs += frameIntervalUs;
try {
videoDecoder.decode(chunk);
h264KeyframeErrorStreak = 0;
updateFpsDisplay(fps);
} catch (err) {
if (isKeyframeRequiredError(err)) {
h264KeyframeErrorStreak += 1;
const now = Date.now();
if (now - h264LastDecodeWarnAt >= H264_DECODE_WARN_THROTTLE_MS) {
h264LastDecodeWarnAt = now;
console.warn("rd: h264 decode waiting for keyframe", {
streak: h264KeyframeErrorStreak,
recoveries: h264RecoveryAttempts,
});
}
if (h264KeyframeErrorStreak >= H264_KEYFRAME_ERROR_RESTART_THRESHOLD) {
const restarted = tryRecoverH264Stream("h264_keyframe_required");
if (!restarted) {
fallbackToJpegCodec("h264_keyframe_required_loop");
}
}
return;
}
console.warn("rd: h264 decode failed", err);
fallbackToJpegCodec(err);
}
}
}
function flushPendingFrame() {
if (frameDecodeBusy || !pendingFrame) {
return;
}
const next = pendingFrame;
pendingFrame = null;
frameDecodeBusy = true;
processFrameBuffer(next).finally(() => {
frameDecodeBusy = false;
if (pendingFrame) {
flushPendingFrame();
}
});
}
ws.addEventListener("message", function (ev) {
if (ev.data instanceof ArrayBuffer) {
const buf = new Uint8Array(ev.data);
if (isFramePacket(buf)) {
markFrameReceived();
// Coalesce bursty arrivals so the renderer catches up to the newest frame.
pendingFrame = buf;
flushPendingFrame();
return;
}
const msg = decodeMsgpack(buf);
if (msg && msg.type === "status" && msg.status) {
handleStatus(msg);
return;
}
if (msg && msg.type === "input_latency") {
updateLatency(Number(msg.ms) || 0);
return;
}
return;
}
const msg = decodeMsgpack(ev.data);
if (msg && msg.type === "status" && msg.status) {
handleStatus(msg);
return;
}
if (msg && msg.type === "input_latency") {
updateLatency(Number(msg.ms) || 0);
return;
}
});
ws.addEventListener("open", function () {
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
pushInputToggles();
pushCaptureToggles();
clearOfflineTimer();
setStreamState("idle", "Stopped");
fetchClientInfo().then(() => {
if (displaySelect && displaySelect.value) {
console.debug("rd: initial select display", displaySelect.value);
sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10),
});
}
});
});
ws.addEventListener("close", function () {
desiredStreaming = false;
destroyVideoDecoder();
setStreamState("disconnected", "Disconnected");
});
ws.addEventListener("error", function () {
destroyVideoDecoder();
setStreamState("error", "WebSocket error");
});
if (!frameWatchTimer) {
frameWatchTimer = setInterval(() => {
const now = performance.now();
if (desiredStreaming) {
if (lastFrameAt && now - lastFrameAt > 2000) {
setStreamState("stalled", "No frames");
} else if (!lastFrameAt && streamState === "starting") {
setStreamState("starting", "Starting stream");
}
} else if (streamState !== "offline" && streamState !== "disconnected" && streamState !== "error") {
if (lastFrameAt && now - lastFrameAt < 2000) {
if (streamState !== "stopping") {
setStreamState("stopping", "Stopping stream");
}
} else if (streamState !== "idle") {
setStreamState("idle", "Stopped");
}
}
}, 1000);
}
function getCanvasPoint(e) {
let rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) {
rect = canvasContainer?.getBoundingClientRect() || rect;
}
const targetW = canvas.width || frameWidth;
const targetH = canvas.height || frameHeight;
if (!rect.width || !rect.height || !targetW || !targetH) return null;
let x = ((e.clientX - rect.left) / rect.width) * targetW;
let y = ((e.clientY - rect.top) / rect.height) * targetH;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
x = Math.max(0, Math.min(targetW - 1, Math.floor(x)));
y = Math.max(0, Math.min(targetH - 1, Math.floor(y)));
return { x, y };
}
function flushMouseMove() {
moveTimer = null;
if (!pendingMove || !mouseCtrl.checked) return;
const now = performance.now();
if (!smoothPoint) {
smoothPoint = { x: pendingMove.x, y: pendingMove.y };
}
const factor = Math.max(0, Math.min(0.8, smoothingPct / 100));
const alpha = 1 - factor;
smoothPoint.x += (pendingMove.x - smoothPoint.x) * alpha;
smoothPoint.y += (pendingMove.y - smoothPoint.y) * alpha;
const sendPoint = {
x: Math.round(smoothPoint.x),
y: Math.round(smoothPoint.y),
};
if (now - lastMoveSentAt < mouseMoveIntervalMs) {
if (!moveTimer) {
moveTimer = setTimeout(flushMouseMove, mouseMoveIntervalMs);
}
return;
}
lastMoveSentAt = now;
if (ws.bufferedAmount <= inputBackpressureBytes) {
sendCmd("mouse_move", sendPoint);
}
}
canvas.addEventListener("mousemove", function (e) {
if (!mouseCtrl.checked) return;
const pt = getCanvasPoint(e);
if (!pt) return;
pendingMove = pt;
if (!moveTimer) {
flushMouseMove();
}
});
canvas.addEventListener("mousedown", function (e) {
if (!mouseCtrl.checked) return;
canvas.focus();
const pt = getCanvasPoint(e);
if (pt) {
pendingMove = pt;
smoothPoint = { x: pt.x, y: pt.y };
sendCmd("mouse_move", pt);
}
sendCmd("mouse_down", { button: e.button, ...(pt || {}) });
e.preventDefault();
});
canvas.addEventListener("mouseup", function (e) {
if (!mouseCtrl.checked) return;
const pt = getCanvasPoint(e);
if (pt) {
pendingMove = pt;
smoothPoint = { x: pt.x, y: pt.y };
sendCmd("mouse_move", pt);
}
sendCmd("mouse_up", { button: e.button, ...(pt || {}) });
e.preventDefault();
});
canvas.addEventListener("contextmenu", function (e) {
e.preventDefault();
});
canvas.setAttribute("tabindex", "0");
canvas.addEventListener("click", function () {
canvas.focus();
});
canvas.addEventListener("keydown", function (e) {
if (!kbdCtrl.checked) return;
if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof e.key === "string" && e.key.length === 1) {
sendCmd("text_input", { text: e.key });
e.preventDefault();
return;
}
sendCmd("key_down", { key: e.key, code: e.code });
e.preventDefault();
});
canvas.addEventListener("keyup", function (e) {
if (!kbdCtrl.checked) return;
if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof e.key === "string" && e.key.length === 1) {
e.preventDefault();
return;
}
sendCmd("key_up", { key: e.key, code: e.code });
e.preventDefault();
});
function stopOnExit() {
if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
desiredStreaming = false;
sendCmd("desktop_stop", {});
}
destroyVideoDecoder();
}
window.addEventListener("beforeunload", stopOnExit);
window.addEventListener("pagehide", stopOnExit);
fetchClientInfo();
})();