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 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; const mouseMoveIntervalMs = 33; const inputBackpressureBytes = 256 * 1024; let lastMoveSentAt = 0; 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: '', starting: '', stopping: '', streaming: '', idle: '', stalled: '', offline: '', disconnected: '', error: '', }; 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} ${label}`; 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" : "jpeg"; console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec); sendCmd("desktop_set_quality", { quality: q, codec }); } 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; 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); }); } ws.addEventListener("message", async function (ev) { if (ev.data instanceof ArrayBuffer) { const buf = new Uint8Array(ev.data); if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) { const fps = buf[5]; const format = buf[6]; lastFrameAt = performance.now(); clearOfflineTimer(); if (streamState !== "streaming") { if (desiredStreaming) { setStreamState("streaming", "Streaming"); } } if (format === 1) { const jpegBytes = buf.slice(8); const blob = new Blob([jpegBytes], { type: "image/jpeg" }); try { const bitmap = await createImageBitmap(blob); frameWidth = bitmap.width || frameWidth; frameHeight = bitmap.height || frameHeight; canvas.width = bitmap.width; canvas.height = bitmap.height; ctx.drawImage(bitmap, 0, 0); bitmap.close(); updateFpsDisplay(fps); } catch { const img = new Image(); const url = URL.createObjectURL(blob); img.onload = function () { frameWidth = img.width || frameWidth; frameHeight = img.height || frameHeight; canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); updateFpsDisplay(fps); }; img.src = url; } return; } if (format === 2 || format === 3) { if (buf.length < 8 + 8) 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) { try { const bitmap = await createImageBitmap( new Blob([slice], { type: "image/jpeg" }), ); ctx.drawImage(bitmap, x, y, w, h); bitmap.close(); } catch {} } else { if (slice.length === w * h * 4) { const imgData = new ImageData(new Uint8ClampedArray(slice), w, h); ctx.putImageData(imgData, x, y); } } } updateFpsDisplay(fps); 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; setStreamState("disconnected", "Disconnected"); }); ws.addEventListener("error", function () { 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", {}); } } window.addEventListener("beforeunload", stopOnExit); window.addEventListener("pagehide", stopOnExit); fetchClientInfo(); })();