2026-03-08 22:31:11 -05:00
|
|
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
2026-04-13 17:15:06 -05:00
|
|
|
import { checkFeatureAccess } from "./feature-gate.js";
|
2026-05-13 20:19:21 -05:00
|
|
|
import { WhepClient } from "./whep.js";
|
|
|
|
|
import { P2PClient } from "./webrtc-p2p.js";
|
2026-08-01 01:54:07 -05:00
|
|
|
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/shared-ui-settings.js";
|
2026-03-08 22:31:11 -05:00
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
(async function () {
|
2026-03-08 22:31:11 -05:00
|
|
|
const clientId = new URLSearchParams(location.search).get("clientId");
|
|
|
|
|
if (!clientId) {
|
|
|
|
|
alert("Missing clientId");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
const allowed = await checkFeatureAccess("webcam", clientId);
|
|
|
|
|
if (!allowed) return;
|
|
|
|
|
|
2026-03-08 22:31:11 -05:00
|
|
|
const clientLabel = document.getElementById("clientLabel");
|
|
|
|
|
const startBtn = document.getElementById("startBtn");
|
|
|
|
|
const stopBtn = document.getElementById("stopBtn");
|
2026-03-09 00:30:52 -05:00
|
|
|
const screenshotBtn = document.getElementById("screenshotBtn");
|
2026-03-08 22:31:11 -05:00
|
|
|
const cameraSelect = document.getElementById("cameraSelect");
|
|
|
|
|
const refreshCameras = document.getElementById("refreshCameras");
|
|
|
|
|
const fpsInput = document.getElementById("fpsInput");
|
|
|
|
|
const applyFps = document.getElementById("applyFps");
|
2026-04-03 10:21:26 -05:00
|
|
|
const qualitySlider = document.getElementById("qualitySlider");
|
|
|
|
|
const qualityValue = document.getElementById("qualityValue");
|
|
|
|
|
const codecH264 = document.getElementById("codecH264");
|
|
|
|
|
const codecMode = document.getElementById("codecMode");
|
2026-03-08 22:31:11 -05:00
|
|
|
const viewerFps = document.getElementById("viewerFps");
|
|
|
|
|
const statusEl = document.getElementById("streamStatus");
|
|
|
|
|
const canvas = document.getElementById("frameCanvas");
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
2026-05-13 20:19:21 -05:00
|
|
|
const webrtcMode = document.getElementById("webrtcMode");
|
|
|
|
|
const webrtcVideo = document.getElementById("webrtcVideo");
|
2026-06-12 15:02:09 -05:00
|
|
|
const audioCtrl = document.getElementById("audioCtrl");
|
|
|
|
|
const audioTransport = document.getElementById("audioTransport");
|
|
|
|
|
const webrtcAudio = document.getElementById("webrtcAudio");
|
2026-05-13 20:19:21 -05:00
|
|
|
let whepClient = null;
|
|
|
|
|
let p2pClient = null;
|
|
|
|
|
function getWebrtcMode() {
|
|
|
|
|
return webrtcMode ? String(webrtcMode.value || "off") : "off";
|
|
|
|
|
}
|
|
|
|
|
function setWebrtcViewActive(active) {
|
|
|
|
|
if (canvas) canvas.style.display = active ? "none" : "block";
|
|
|
|
|
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
|
|
|
|
|
clientLabel.textContent = clientId;
|
|
|
|
|
|
|
|
|
|
let ws = null;
|
|
|
|
|
let desiredStreaming = false;
|
|
|
|
|
let streamState = "connecting";
|
|
|
|
|
let renderCount = 0;
|
|
|
|
|
let renderWindowStart = performance.now();
|
|
|
|
|
let videoDecoder = null;
|
|
|
|
|
let h264TimestampUs = 0;
|
|
|
|
|
let availableDevices = [];
|
|
|
|
|
let selectedDeviceIndex = 0;
|
2026-03-09 00:30:52 -05:00
|
|
|
let hasRenderedFrame = false;
|
2026-03-13 09:28:16 -05:00
|
|
|
let drawPending = false;
|
2026-05-18 11:01:42 -05:00
|
|
|
let clientOs = "";
|
|
|
|
|
let clientIsAdmin = false;
|
|
|
|
|
let firewallWarningAcked = false;
|
2026-03-09 00:30:52 -05:00
|
|
|
|
2026-04-03 10:21:26 -05:00
|
|
|
let prefersH264 = typeof VideoDecoder === "function";
|
2026-06-12 10:20:02 -05:00
|
|
|
let savedCameraIndex = null;
|
2026-04-03 10:21:26 -05:00
|
|
|
|
2026-06-12 15:02:09 -05:00
|
|
|
/* ── Remote system audio while viewing webcam ── */
|
|
|
|
|
const AUDIO_SAMPLE_RATE = 16000;
|
|
|
|
|
const AUDIO_PLAYBACK_FRAME = 512;
|
|
|
|
|
const AUDIO_MAX_BUFFER_MS = 120;
|
|
|
|
|
let audioWs = null;
|
|
|
|
|
let audioPlayCtx = null;
|
|
|
|
|
let audioProcessorNode = null;
|
|
|
|
|
let audioChunks = [];
|
|
|
|
|
let audioChunkOffset = 0;
|
|
|
|
|
let audioWhep = null;
|
|
|
|
|
let audioP2P = null;
|
|
|
|
|
|
|
|
|
|
function getAudioTransport() {
|
|
|
|
|
return audioTransport ? String(audioTransport.value || "off") : "off";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function audioResampleInt16ToFloat32(srcInt16, srcRate, dstRate) {
|
|
|
|
|
if (!srcInt16 || srcInt16.length === 0) return new Float32Array(0);
|
|
|
|
|
if (srcRate === dstRate) {
|
|
|
|
|
const out = new Float32Array(srcInt16.length);
|
|
|
|
|
for (let i = 0; i < srcInt16.length; i++) out[i] = srcInt16[i] / 0x8000;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
const outLength = Math.max(1, Math.round((srcInt16.length * dstRate) / srcRate));
|
|
|
|
|
const out = new Float32Array(outLength);
|
|
|
|
|
const step = srcRate / dstRate;
|
|
|
|
|
for (let i = 0; i < outLength; i++) {
|
|
|
|
|
const srcPos = i * step;
|
|
|
|
|
const i0 = Math.floor(srcPos);
|
|
|
|
|
const i1 = Math.min(i0 + 1, srcInt16.length - 1);
|
|
|
|
|
const frac = srcPos - i0;
|
|
|
|
|
out[i] = (srcInt16[i0] * (1 - frac) + srcInt16[i1] * frac) / 0x8000;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initAudioPlayback() {
|
|
|
|
|
if (!audioPlayCtx) {
|
|
|
|
|
audioPlayCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE, latencyHint: "interactive" });
|
|
|
|
|
}
|
|
|
|
|
if (!audioProcessorNode) {
|
|
|
|
|
audioProcessorNode = audioPlayCtx.createScriptProcessor(AUDIO_PLAYBACK_FRAME, 1, 1);
|
|
|
|
|
audioProcessorNode.onaudioprocess = function (event) {
|
|
|
|
|
const out = event.outputBuffer.getChannelData(0);
|
|
|
|
|
out.fill(0);
|
|
|
|
|
let writeIndex = 0;
|
|
|
|
|
while (writeIndex < out.length && audioChunks.length > 0) {
|
|
|
|
|
const head = audioChunks[0];
|
|
|
|
|
const remaining = head.length - audioChunkOffset;
|
|
|
|
|
if (remaining <= 0) { audioChunks.shift(); audioChunkOffset = 0; continue; }
|
|
|
|
|
const take = Math.min(out.length - writeIndex, remaining);
|
|
|
|
|
out.set(head.subarray(audioChunkOffset, audioChunkOffset + take), writeIndex);
|
|
|
|
|
writeIndex += take;
|
|
|
|
|
audioChunkOffset += take;
|
|
|
|
|
if (audioChunkOffset >= head.length) { audioChunks.shift(); audioChunkOffset = 0; }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
audioProcessorNode.connect(audioPlayCtx.destination);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendAudioPcm(binary) {
|
|
|
|
|
if (!audioPlayCtx) initAudioPlayback();
|
|
|
|
|
const samples = Math.floor(binary.byteLength / 2);
|
|
|
|
|
if (samples <= 0) return;
|
|
|
|
|
const src = new Int16Array(binary.buffer, binary.byteOffset, samples);
|
|
|
|
|
const chunk = audioResampleInt16ToFloat32(src, AUDIO_SAMPLE_RATE, audioPlayCtx?.sampleRate || AUDIO_SAMPLE_RATE);
|
|
|
|
|
if (chunk.length === 0) return;
|
|
|
|
|
audioChunks.push(chunk);
|
|
|
|
|
let buffered = -audioChunkOffset;
|
|
|
|
|
for (const c of audioChunks) buffered += c.length;
|
|
|
|
|
const rate = audioPlayCtx?.sampleRate || AUDIO_SAMPLE_RATE;
|
|
|
|
|
const max = Math.max(AUDIO_PLAYBACK_FRAME, Math.round(rate * (AUDIO_MAX_BUFFER_MS / 1000)));
|
|
|
|
|
while (buffered > max && audioChunks.length > 0) {
|
|
|
|
|
const dropped = audioChunks.shift();
|
|
|
|
|
buffered -= dropped?.length || 0;
|
|
|
|
|
audioChunkOffset = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function stopAudioWebrtc() {
|
|
|
|
|
const w = audioWhep;
|
|
|
|
|
audioWhep = null;
|
|
|
|
|
if (w) { try { await w.stop(); } catch {} }
|
|
|
|
|
const p = audioP2P;
|
|
|
|
|
audioP2P = null;
|
|
|
|
|
if (p) { try { await p.stop(); } catch {} }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startAudioWhep(whepPath) {
|
|
|
|
|
await stopAudioWebrtc();
|
|
|
|
|
audioWhep = new WhepClient({
|
|
|
|
|
whepPath,
|
|
|
|
|
audioEl: webrtcAudio,
|
|
|
|
|
onState: (s) => console.debug("webcam-audio-webrtc[Relayed]: state", s),
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await audioWhep.start();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("webcam audio: WHEP start failed", err);
|
|
|
|
|
audioWhep = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startAudioP2P() {
|
|
|
|
|
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
audioP2P = new P2PClient({
|
|
|
|
|
audioEl: webrtcAudio,
|
|
|
|
|
send: (msg) => {
|
|
|
|
|
if (audioWs && audioWs.readyState === WebSocket.OPEN) {
|
|
|
|
|
audioWs.send(JSON.stringify(msg));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onState: (s) => console.debug("webcam-audio-webrtc[P2P]: state", s),
|
|
|
|
|
});
|
|
|
|
|
audioP2P.start().catch((err) => {
|
|
|
|
|
console.warn("webcam audio: P2P start failed", err);
|
|
|
|
|
audioP2P = null;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanupAudio(uncheckBox) {
|
|
|
|
|
if (audioProcessorNode) {
|
|
|
|
|
audioProcessorNode.disconnect();
|
|
|
|
|
audioProcessorNode.onaudioprocess = null;
|
|
|
|
|
audioProcessorNode = null;
|
|
|
|
|
}
|
|
|
|
|
if (audioPlayCtx) {
|
|
|
|
|
audioPlayCtx.close().catch(function () {});
|
|
|
|
|
audioPlayCtx = null;
|
|
|
|
|
}
|
|
|
|
|
audioChunks = [];
|
|
|
|
|
audioChunkOffset = 0;
|
|
|
|
|
audioWs = null;
|
|
|
|
|
if (uncheckBox && audioCtrl) audioCtrl.checked = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function connectAudio() {
|
|
|
|
|
if (audioWs && audioWs.readyState === WebSocket.OPEN) return;
|
|
|
|
|
const mode = getAudioTransport();
|
|
|
|
|
if (mode === "off") {
|
|
|
|
|
initAudioPlayback();
|
|
|
|
|
if (audioPlayCtx?.state === "suspended") audioPlayCtx.resume();
|
|
|
|
|
}
|
|
|
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
|
|
|
const wsAudio = new WebSocket(`${proto}//${location.host}/api/clients/${encodeURIComponent(clientId)}/desktop-audio/ws`);
|
|
|
|
|
audioWs = wsAudio;
|
|
|
|
|
wsAudio.binaryType = "arraybuffer";
|
|
|
|
|
wsAudio.onopen = function () {
|
|
|
|
|
const start = { type: "start", source: "system" };
|
|
|
|
|
if (mode === "relayed") start.webrtc = true;
|
|
|
|
|
try { wsAudio.send(JSON.stringify(start)); } catch (err) {
|
|
|
|
|
console.warn("webcam audio: send start failed", err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (mode === "p2p") startAudioP2P();
|
|
|
|
|
};
|
|
|
|
|
wsAudio.onmessage = function (ev) {
|
|
|
|
|
if (typeof ev.data === "string") {
|
|
|
|
|
let msg;
|
|
|
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
|
|
|
if (!msg || typeof msg.type !== "string") return;
|
|
|
|
|
if (msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
|
|
|
|
|
startAudioWhep(msg.whepPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
|
|
|
|
|
if (audioP2P) audioP2P.onAnswer(msg.sdp);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "webrtc_p2p_ice") {
|
|
|
|
|
if (audioP2P) audioP2P.onRemoteCandidate(msg);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const bytes = new Uint8Array(ev.data);
|
|
|
|
|
if (mode === "off" && bytes.byteLength > 1) appendAudioPcm(bytes);
|
|
|
|
|
};
|
|
|
|
|
wsAudio.onclose = function () {
|
|
|
|
|
if (audioWs === wsAudio) {
|
|
|
|
|
stopAudioWebrtc();
|
|
|
|
|
cleanupAudio(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
wsAudio.onerror = function () {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disconnectAudio(uncheckBox = true) {
|
|
|
|
|
if (audioWs) {
|
|
|
|
|
try { audioWs.send(JSON.stringify({ type: "stop" })); } catch {}
|
|
|
|
|
try { audioWs.close(); } catch {}
|
|
|
|
|
audioWs = null;
|
|
|
|
|
}
|
|
|
|
|
stopAudioWebrtc();
|
|
|
|
|
cleanupAudio(uncheckBox);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 10:20:02 -05:00
|
|
|
function setSelectValue(select, value) {
|
|
|
|
|
if (!select || value === undefined || value === null) return false;
|
|
|
|
|
const normalized = String(value);
|
|
|
|
|
const exists = Array.from(select.options || []).some((opt) => opt.value === normalized);
|
|
|
|
|
if (!exists) return false;
|
|
|
|
|
select.value = normalized;
|
|
|
|
|
return true;
|
2026-04-03 10:21:26 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 10:20:02 -05:00
|
|
|
function applySavedCamera() {
|
|
|
|
|
if (savedCameraIndex === null || savedCameraIndex === undefined) return false;
|
|
|
|
|
if (!setSelectValue(cameraSelect, savedCameraIndex)) return false;
|
|
|
|
|
selectedDeviceIndex = Number(cameraSelect.value) || 0;
|
|
|
|
|
applyFpsInputLimits();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySharedSettings(settings) {
|
|
|
|
|
if (!settings || typeof settings !== "object") return;
|
|
|
|
|
if (Number.isFinite(Number(settings.camera))) {
|
|
|
|
|
savedCameraIndex = Number(settings.camera);
|
|
|
|
|
}
|
|
|
|
|
if (fpsInput && settings.fps !== undefined) fpsInput.value = String(settings.fps);
|
|
|
|
|
if (qualitySlider && settings.quality !== undefined) qualitySlider.value = String(settings.quality);
|
|
|
|
|
setSelectValue(webrtcMode, settings.webrtcMode);
|
2026-06-12 15:02:09 -05:00
|
|
|
setSelectValue(audioTransport, settings.audioTransport);
|
|
|
|
|
if (audioCtrl && typeof settings.audio === "boolean") audioCtrl.checked = settings.audio;
|
2026-06-12 10:20:02 -05:00
|
|
|
if (typeof settings.preferH264 === "boolean") {
|
|
|
|
|
prefersH264 = settings.preferH264 && typeof VideoDecoder === "function";
|
|
|
|
|
}
|
|
|
|
|
applySavedCamera();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readSharedSettings() {
|
|
|
|
|
return {
|
|
|
|
|
camera: Number(cameraSelect?.value ?? savedCameraIndex ?? 0),
|
|
|
|
|
fps: Number(fpsInput?.value || 30),
|
|
|
|
|
quality: Number(qualitySlider?.value || 90),
|
|
|
|
|
preferH264: !!prefersH264,
|
|
|
|
|
webrtcMode: getWebrtcMode(),
|
2026-06-12 15:02:09 -05:00
|
|
|
audio: !!audioCtrl?.checked,
|
|
|
|
|
audioTransport: getAudioTransport(),
|
2026-06-12 10:20:02 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applySharedSettings(await loadSharedUiSettings("webcam"));
|
|
|
|
|
const sharedSettingsSaver = createSharedUiSettingsSaver("webcam", readSharedSettings);
|
|
|
|
|
|
2026-04-03 10:21:26 -05:00
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
function updateQualityLabel(val) {
|
|
|
|
|
if (qualityValue) {
|
|
|
|
|
qualityValue.textContent = `${val}%`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushQuality(val) {
|
|
|
|
|
const q = Number(val) || 90;
|
|
|
|
|
const codec = prefersH264 ? "h264" : "jpeg";
|
|
|
|
|
console.debug("webcam: pushQuality val=", val, "q=", q, "codec=", codec);
|
|
|
|
|
setCodecModeLabel(codec, "requested");
|
|
|
|
|
send("webcam_set_quality", { quality: q, codec });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:30:52 -05:00
|
|
|
function buildScreenshotFilename() {
|
|
|
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
|
|
|
return `webcam-${clientId}-${ts}.jpg`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadScreenshot() {
|
|
|
|
|
if (!hasRenderedFrame) {
|
|
|
|
|
setStreamState("error", "No frame available for screenshot");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
canvas.toBlob((blob) => {
|
|
|
|
|
if (!blob) {
|
|
|
|
|
setStreamState("error", "Failed to encode screenshot");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = buildScreenshotFilename();
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
a.remove();
|
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
|
|
|
}, "image/jpeg", 0.92);
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
|
|
|
|
|
function selectedDeviceMaxFps() {
|
|
|
|
|
const selected = availableDevices.find((dev) => (Number(dev.index) || 0) === selectedDeviceIndex);
|
|
|
|
|
const max = Number(selected?.maxFps) || 0;
|
|
|
|
|
return max > 0 ? Math.min(120, max) : 120;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyFpsInputLimits() {
|
|
|
|
|
const maxFps = selectedDeviceMaxFps();
|
|
|
|
|
fpsInput.max = String(maxFps);
|
|
|
|
|
const current = Number(fpsInput.value) || 30;
|
|
|
|
|
if (current > maxFps) {
|
|
|
|
|
fpsInput.value = String(maxFps);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyFpsSettings() {
|
|
|
|
|
if (streamState === "streaming" || streamState === "starting" || streamState === "stopping") {
|
|
|
|
|
setStreamState("error", "Stop stream before changing FPS");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const maxFps = selectedDeviceMaxFps();
|
|
|
|
|
const fps = Math.max(1, Math.min(maxFps, Number(fpsInput.value) || 30));
|
|
|
|
|
if ((Number(fpsInput.value) || 30) > maxFps) {
|
|
|
|
|
fpsInput.value = String(maxFps);
|
|
|
|
|
setStreamState("idle", `FPS capped to camera max (${maxFps})`);
|
|
|
|
|
}
|
|
|
|
|
send("webcam_set_fps", { fps, useMax: false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requestCameraList() {
|
|
|
|
|
send("webcam_list");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCameraList(devices, selected) {
|
|
|
|
|
availableDevices = Array.isArray(devices) ? devices : [];
|
|
|
|
|
cameraSelect.innerHTML = "";
|
|
|
|
|
if (!availableDevices.length) {
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
opt.value = "0";
|
|
|
|
|
opt.textContent = "No cameras detected";
|
|
|
|
|
cameraSelect.appendChild(opt);
|
|
|
|
|
cameraSelect.disabled = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
cameraSelect.disabled = false;
|
|
|
|
|
for (const dev of availableDevices) {
|
|
|
|
|
const idx = Number(dev.index) || 0;
|
|
|
|
|
const maxFps = Number(dev.maxFps) || 0;
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
opt.value = String(idx);
|
|
|
|
|
opt.textContent = maxFps > 0
|
|
|
|
|
? `${dev.name || `Camera ${idx + 1}`} (max ${maxFps} FPS)`
|
|
|
|
|
: (dev.name || `Camera ${idx + 1}`);
|
|
|
|
|
cameraSelect.appendChild(opt);
|
|
|
|
|
}
|
2026-06-12 10:20:02 -05:00
|
|
|
selectedDeviceIndex = savedCameraIndex !== null && savedCameraIndex !== undefined
|
|
|
|
|
? Number(savedCameraIndex) || 0
|
|
|
|
|
: Number(selected) || 0;
|
2026-03-08 22:31:11 -05:00
|
|
|
const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex);
|
|
|
|
|
if (selectedOpt) {
|
|
|
|
|
cameraSelect.value = selectedOpt.value;
|
|
|
|
|
} else if (cameraSelect.options.length) {
|
|
|
|
|
cameraSelect.value = cameraSelect.options[0].value;
|
|
|
|
|
}
|
|
|
|
|
selectedDeviceIndex = Number(cameraSelect.value) || 0;
|
|
|
|
|
applyFpsInputLimits();
|
2026-06-12 10:20:02 -05:00
|
|
|
if (selectedDeviceIndex !== Number(selected || 0) && ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
send("webcam_select", { index: selectedDeviceIndex });
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 destroyVideoDecoder() {
|
|
|
|
|
if (!videoDecoder) return;
|
|
|
|
|
try {
|
|
|
|
|
videoDecoder.close();
|
|
|
|
|
} catch {}
|
|
|
|
|
videoDecoder = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureVideoDecoder() {
|
|
|
|
|
if (videoDecoder) return true;
|
|
|
|
|
if (typeof VideoDecoder !== "function") return false;
|
|
|
|
|
try {
|
|
|
|
|
videoDecoder = new VideoDecoder({
|
|
|
|
|
output: (frame) => {
|
2026-03-09 00:30:52 -05:00
|
|
|
hasRenderedFrame = true;
|
2026-03-08 22:31:11 -05:00
|
|
|
const width = frame.displayWidth || frame.codedWidth || canvas.width;
|
|
|
|
|
const height = frame.displayHeight || frame.codedHeight || canvas.height;
|
|
|
|
|
if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
} finally {
|
|
|
|
|
frame.close();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: (err) => {
|
|
|
|
|
console.warn("webcam h264 decoder error", err);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true });
|
|
|
|
|
h264TimestampUs = 0;
|
|
|
|
|
return true;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("webcam h264 decoder unavailable", err);
|
|
|
|
|
destroyVideoDecoder();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setStreamState(state, text) {
|
|
|
|
|
streamState = state;
|
|
|
|
|
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>',
|
|
|
|
|
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 === "error" ? "Error" :
|
|
|
|
|
"Stopped");
|
|
|
|
|
|
2026-08-24 09:35:03 -05:00
|
|
|
statusEl.innerHTML = icons[state] || icons.idle;
|
|
|
|
|
const labelEl = document.createElement("span");
|
|
|
|
|
labelEl.textContent = String(label || "");
|
|
|
|
|
statusEl.appendChild(labelEl);
|
2026-03-08 22:31:11 -05:00
|
|
|
|
|
|
|
|
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
|
|
|
|
|
const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping";
|
|
|
|
|
startBtn.disabled = !wsOpen || streamState === "starting" || streamState === "streaming";
|
|
|
|
|
stopBtn.disabled = !wsOpen || (streamState !== "starting" && streamState !== "streaming");
|
2026-03-09 00:30:52 -05:00
|
|
|
screenshotBtn.disabled = !hasRenderedFrame;
|
2026-03-08 22:31:11 -05:00
|
|
|
refreshCameras.disabled = !wsOpen;
|
|
|
|
|
applyFps.disabled = !wsOpen || streamLocked;
|
|
|
|
|
fpsInput.disabled = !wsOpen || streamLocked;
|
|
|
|
|
|
|
|
|
|
if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
|
|
|
|
|
viewerFps.textContent = "--";
|
|
|
|
|
renderCount = 0;
|
|
|
|
|
renderWindowStart = performance.now();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateViewerFps() {
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
renderCount += 1;
|
|
|
|
|
const elapsed = now - renderWindowStart;
|
|
|
|
|
if (elapsed >= 1000) {
|
|
|
|
|
viewerFps.textContent = String(Math.round((renderCount * 1000) / elapsed));
|
|
|
|
|
renderCount = 0;
|
|
|
|
|
renderWindowStart = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function drawJpeg(bytes) {
|
2026-03-13 09:28:16 -05:00
|
|
|
if (drawPending) return;
|
|
|
|
|
drawPending = true;
|
|
|
|
|
try {
|
|
|
|
|
const blob = new Blob([bytes], { type: "image/jpeg" });
|
|
|
|
|
const bitmap = await createImageBitmap(blob);
|
|
|
|
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
|
|
|
|
canvas.width = bitmap.width;
|
|
|
|
|
canvas.height = bitmap.height;
|
|
|
|
|
}
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
hasRenderedFrame = true;
|
|
|
|
|
bitmap.close();
|
|
|
|
|
updateViewerFps();
|
2026-06-16 17:51:41 -05:00
|
|
|
if (desiredStreaming) setStreamState("streaming", "Streaming");
|
2026-03-13 09:28:16 -05:00
|
|
|
} finally {
|
|
|
|
|
drawPending = false;
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFrame(data) {
|
|
|
|
|
const bytes = new Uint8Array(data);
|
|
|
|
|
if (bytes.length < 8) return;
|
|
|
|
|
if (bytes[0] !== 0x46 || bytes[1] !== 0x52 || bytes[2] !== 0x4d) return;
|
|
|
|
|
const format = bytes[6];
|
|
|
|
|
const payload = bytes.slice(8);
|
|
|
|
|
if (!payload.length) return;
|
|
|
|
|
if (format === 1) {
|
|
|
|
|
drawJpeg(payload).catch((err) => {
|
|
|
|
|
console.warn("webcam draw failed", err, "payloadBytes=", payload.length);
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (format === 4) {
|
|
|
|
|
if (!ensureVideoDecoder()) {
|
|
|
|
|
setStreamState("error", "H264 decoder unavailable in browser");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const isKey = isH264KeyFrame(payload);
|
|
|
|
|
const chunk = new EncodedVideoChunk({
|
|
|
|
|
type: isKey ? "key" : "delta",
|
|
|
|
|
timestamp: h264TimestampUs,
|
|
|
|
|
data: payload,
|
|
|
|
|
});
|
|
|
|
|
h264TimestampUs += 66_666;
|
|
|
|
|
videoDecoder.decode(chunk);
|
|
|
|
|
updateViewerFps();
|
2026-06-16 17:51:41 -05:00
|
|
|
if (desiredStreaming) setStreamState("streaming", "Streaming");
|
2026-03-08 22:31:11 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("webcam h264 decode failed", err, "payloadBytes=", payload.length);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.warn("webcam unsupported frame format", format, "payloadBytes=", payload.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function send(type, payload = {}) {
|
|
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
ws.send(encodeMsgpack({ type, ...payload }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
async function startWhep(whepPath) {
|
|
|
|
|
await stopAllWebrtc();
|
|
|
|
|
if (!webrtcVideo) return;
|
|
|
|
|
whepClient = new WhepClient({
|
|
|
|
|
whepPath,
|
|
|
|
|
videoEl: webrtcVideo,
|
|
|
|
|
onState: (s) => {
|
|
|
|
|
if (s === "connected") setStreamState("streaming", "Streaming (WebRTC Relayed)");
|
|
|
|
|
else if (s === "failed" || s === "disconnected") setWebrtcViewActive(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await whepClient.start();
|
|
|
|
|
setWebrtcViewActive(true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("webcam: WHEP start failed, falling back to canvas", err);
|
|
|
|
|
setWebrtcViewActive(false);
|
|
|
|
|
whepClient = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startP2P() {
|
|
|
|
|
await stopAllWebrtc();
|
|
|
|
|
if (!webrtcVideo) return;
|
|
|
|
|
p2pClient = new P2PClient({
|
|
|
|
|
videoEl: webrtcVideo,
|
|
|
|
|
send: (msg) => {
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encodeMsgpack(msg));
|
|
|
|
|
},
|
|
|
|
|
onState: (s) => {
|
|
|
|
|
if (s === "connected") setStreamState("streaming", "Streaming (WebRTC P2P)");
|
|
|
|
|
else if (s === "failed" || s === "disconnected") setWebrtcViewActive(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await p2pClient.start();
|
|
|
|
|
setWebrtcViewActive(true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("webcam: P2P start failed, falling back to canvas", err);
|
|
|
|
|
setWebrtcViewActive(false);
|
|
|
|
|
const c = p2pClient;
|
|
|
|
|
p2pClient = null;
|
|
|
|
|
if (c) { try { await c.stop(); } catch {} }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function stopAllWebrtc() {
|
|
|
|
|
setWebrtcViewActive(false);
|
|
|
|
|
const w = whepClient;
|
|
|
|
|
whepClient = null;
|
|
|
|
|
if (w) { try { await w.stop(); } catch {} }
|
|
|
|
|
const p = p2pClient;
|
|
|
|
|
p2pClient = null;
|
|
|
|
|
if (p) { try { await p.stop(); } catch {} }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 22:31:11 -05:00
|
|
|
function handleControlMessage(msg) {
|
|
|
|
|
if (!msg || typeof msg !== "object") return;
|
2026-05-13 20:19:21 -05:00
|
|
|
if (msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
|
|
|
|
|
startWhep(msg.whepPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
|
|
|
|
|
if (p2pClient) p2pClient.onAnswer(msg.sdp);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "webrtc_p2p_ice") {
|
|
|
|
|
if (p2pClient) p2pClient.onRemoteCandidate(msg);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
if (msg.type === "webcam_devices") {
|
|
|
|
|
renderCameraList(msg.devices, msg.selected);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "ready") {
|
2026-05-18 11:01:42 -05:00
|
|
|
if (msg.os) clientOs = String(msg.os).toLowerCase();
|
|
|
|
|
if (msg.isAdmin !== undefined) clientIsAdmin = !!msg.isAdmin;
|
2026-03-08 22:31:11 -05:00
|
|
|
setStreamState("idle", "Ready");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.type === "status") {
|
|
|
|
|
if (msg.status === "offline") {
|
|
|
|
|
desiredStreaming = false;
|
|
|
|
|
setStreamState("offline", msg.reason || "Client offline");
|
2026-06-16 17:51:41 -05:00
|
|
|
} else if (msg.status === "starting") {
|
|
|
|
|
if (desiredStreaming && streamState !== "streaming") {
|
|
|
|
|
setStreamState("starting", "Starting");
|
|
|
|
|
}
|
|
|
|
|
} else if (msg.status === "stopped") {
|
|
|
|
|
desiredStreaming = false;
|
|
|
|
|
setStreamState("idle", "Stopped");
|
2026-03-08 22:31:11 -05:00
|
|
|
} else if (msg.status === "connecting") {
|
|
|
|
|
setStreamState("idle", "Ready");
|
|
|
|
|
} else if (msg.status === "online") {
|
|
|
|
|
setStreamState("idle", "Ready");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function connect() {
|
|
|
|
|
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
|
|
|
|
|
ws = new WebSocket(`${protocol}${location.host}/api/clients/${clientId}/webcam/ws`);
|
|
|
|
|
ws.binaryType = "arraybuffer";
|
|
|
|
|
setStreamState("connecting", "Connecting");
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
requestCameraList();
|
2026-06-12 10:20:02 -05:00
|
|
|
if (savedCameraIndex !== null && savedCameraIndex !== undefined) {
|
|
|
|
|
selectedDeviceIndex = Number(savedCameraIndex) || 0;
|
|
|
|
|
send("webcam_select", { index: selectedDeviceIndex });
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
applyFpsSettings();
|
2026-04-03 10:21:26 -05:00
|
|
|
pushQuality(qualitySlider ? qualitySlider.value : 90);
|
2026-03-08 22:31:11 -05:00
|
|
|
if (desiredStreaming) {
|
|
|
|
|
send("webcam_start");
|
|
|
|
|
setStreamState("starting", "Starting");
|
|
|
|
|
} else {
|
|
|
|
|
setStreamState("idle", "Stopped");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
if (event.data instanceof ArrayBuffer) {
|
|
|
|
|
const bytes = new Uint8Array(event.data);
|
|
|
|
|
if (bytes.length >= 4 && bytes[0] === 0x46 && bytes[1] === 0x52 && bytes[2] === 0x4d) {
|
|
|
|
|
handleFrame(event.data);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const msg = decodeMsgpack(event.data);
|
|
|
|
|
handleControlMessage(msg);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
2026-05-13 20:19:21 -05:00
|
|
|
stopAllWebrtc();
|
2026-03-08 22:31:11 -05:00
|
|
|
setStreamState("disconnected", "Disconnected");
|
|
|
|
|
setTimeout(connect, 3000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onerror = () => {
|
2026-05-13 20:19:21 -05:00
|
|
|
stopAllWebrtc();
|
2026-03-08 22:31:11 -05:00
|
|
|
setStreamState("error", "Connection error");
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 11:01:42 -05:00
|
|
|
function needsWebrtcFirewallWarning(mode) {
|
|
|
|
|
if (firewallWarningAcked) return false;
|
|
|
|
|
if (mode !== "relayed" && mode !== "p2p") return false;
|
|
|
|
|
const isWindows = clientOs.includes("windows") || clientOs.includes("win");
|
|
|
|
|
return isWindows && !clientIsAdmin;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 22:31:11 -05:00
|
|
|
startBtn.addEventListener("click", () => {
|
2026-05-18 11:01:42 -05:00
|
|
|
const mode = getWebrtcMode();
|
|
|
|
|
if (needsWebrtcFirewallWarning(mode)) {
|
|
|
|
|
if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
firewallWarningAcked = true;
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
desiredStreaming = true;
|
|
|
|
|
applyFpsSettings();
|
2026-05-13 20:19:21 -05:00
|
|
|
if (mode === "relayed") {
|
|
|
|
|
// Server replies with webrtc_ready; startWhep happens then.
|
|
|
|
|
send("webcam_start", { webrtc: true });
|
|
|
|
|
} else if (mode === "p2p") {
|
|
|
|
|
send("webcam_start");
|
|
|
|
|
startP2P();
|
|
|
|
|
} else {
|
|
|
|
|
send("webcam_start");
|
|
|
|
|
}
|
2026-06-12 15:02:09 -05:00
|
|
|
if (audioCtrl && audioCtrl.checked) {
|
|
|
|
|
connectAudio();
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
setStreamState("starting", "Starting");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
stopBtn.addEventListener("click", () => {
|
|
|
|
|
desiredStreaming = false;
|
|
|
|
|
send("webcam_stop");
|
2026-05-13 20:19:21 -05:00
|
|
|
stopAllWebrtc();
|
2026-06-12 15:02:09 -05:00
|
|
|
disconnectAudio();
|
2026-06-16 17:51:41 -05:00
|
|
|
setStreamState("idle", "Stopped");
|
2026-03-08 22:31:11 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-12 15:02:09 -05:00
|
|
|
function stopOnExit() {
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.saveNow();
|
2026-03-08 22:31:11 -05:00
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
send("webcam_stop");
|
|
|
|
|
}
|
2026-06-12 15:02:09 -05:00
|
|
|
disconnectAudio();
|
2026-03-08 22:31:11 -05:00
|
|
|
destroyVideoDecoder();
|
2026-06-12 15:02:09 -05:00
|
|
|
}
|
|
|
|
|
window.addEventListener("beforeunload", stopOnExit);
|
|
|
|
|
window.addEventListener("pagehide", stopOnExit);
|
2026-03-08 22:31:11 -05:00
|
|
|
|
|
|
|
|
refreshCameras.addEventListener("click", () => {
|
|
|
|
|
requestCameraList();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cameraSelect.addEventListener("change", () => {
|
|
|
|
|
const index = Number(cameraSelect.value) || 0;
|
2026-06-12 10:20:02 -05:00
|
|
|
savedCameraIndex = index;
|
2026-03-08 22:31:11 -05:00
|
|
|
selectedDeviceIndex = index;
|
|
|
|
|
applyFpsInputLimits();
|
|
|
|
|
send("webcam_select", { index });
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.scheduleSave();
|
2026-03-08 22:31:11 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fpsInput.addEventListener("input", () => {
|
|
|
|
|
const maxFps = selectedDeviceMaxFps();
|
|
|
|
|
const val = Number(fpsInput.value);
|
|
|
|
|
if (Number.isFinite(val) && val > maxFps) {
|
|
|
|
|
fpsInput.value = String(maxFps);
|
|
|
|
|
}
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.scheduleSave();
|
2026-03-08 22:31:11 -05:00
|
|
|
});
|
|
|
|
|
|
2026-04-03 10:21:26 -05:00
|
|
|
if (codecH264) {
|
|
|
|
|
codecH264.addEventListener("change", function () {
|
|
|
|
|
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
|
|
|
|
|
if (!prefersH264) {
|
|
|
|
|
destroyVideoDecoder();
|
|
|
|
|
}
|
|
|
|
|
if (qualitySlider) {
|
|
|
|
|
pushQuality(qualitySlider.value);
|
|
|
|
|
}
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.scheduleSave();
|
2026-04-03 10:21:26 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (qualitySlider) {
|
|
|
|
|
updateQualityLabel(qualitySlider.value);
|
|
|
|
|
qualitySlider.addEventListener("input", function () {
|
|
|
|
|
updateQualityLabel(qualitySlider.value);
|
|
|
|
|
pushQuality(qualitySlider.value);
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.scheduleSave();
|
2026-04-03 10:21:26 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 22:31:11 -05:00
|
|
|
applyFps.addEventListener("click", () => {
|
|
|
|
|
applyFpsSettings();
|
2026-06-12 10:20:02 -05:00
|
|
|
sharedSettingsSaver.scheduleSave();
|
2026-03-08 22:31:11 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-12 10:20:02 -05:00
|
|
|
if (webrtcMode) {
|
|
|
|
|
webrtcMode.addEventListener("change", () => {
|
|
|
|
|
sharedSettingsSaver.scheduleSave();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 15:02:09 -05:00
|
|
|
if (audioCtrl) {
|
|
|
|
|
audioCtrl.addEventListener("change", () => {
|
|
|
|
|
if (audioCtrl.checked) {
|
|
|
|
|
connectAudio();
|
|
|
|
|
} else {
|
|
|
|
|
disconnectAudio();
|
|
|
|
|
}
|
|
|
|
|
sharedSettingsSaver.scheduleSave();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (audioTransport) {
|
|
|
|
|
audioTransport.addEventListener("change", () => {
|
|
|
|
|
if (audioCtrl && audioCtrl.checked) {
|
|
|
|
|
disconnectAudio(false);
|
|
|
|
|
connectAudio();
|
|
|
|
|
}
|
|
|
|
|
sharedSettingsSaver.scheduleSave();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:30:52 -05:00
|
|
|
screenshotBtn.addEventListener("click", () => {
|
|
|
|
|
downloadScreenshot();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-08 22:31:11 -05:00
|
|
|
connect();
|
|
|
|
|
})();
|