const state = document.querySelector("[data-state]"); const video = document.querySelector("[data-video]"); const image = document.querySelector("[data-mjpeg]"); const logs = document.querySelector("[data-logs]"); const modeSelect = document.querySelector("[data-mode]"); const startButton = document.querySelector("[data-start]"); const stopButton = document.querySelector("[data-stop]"); let ws = null; let pc = null; let statsTimer = null; function setState(message) { if (state) state.textContent = message; } function renderLogs(items = []) { if (!logs) return; logs.textContent = items.slice(-30).join("\n"); logs.scrollTop = logs.scrollHeight; } async function getStatus() { const response = await fetch("/api/camera/status"); const status = await response.json(); renderLogs(status.logs); return status; } function normalizeOfferDescription(description) { if (description.type !== "offer" || !description.sdp || description.sdp.includes("a=msid:")) return description; const lines = description.sdp.replace(/\r\n/g, "\n").split("\n"); const normalized = []; let hasMsidSemantic = false; let insertedVideoMsid = false; for (const line of lines) { if (line.startsWith("a=msid-semantic:")) hasMsidSemantic = true; if (line.startsWith("m=video") && !hasMsidSemantic) { normalized.push("a=msid-semantic: WMS jetson-camera"); hasMsidSemantic = true; } normalized.push(line); if (!insertedVideoMsid && line.startsWith("a=mid:")) { normalized.push("a=msid:jetson-camera video0"); insertedVideoMsid = true; } } return { ...description, sdp: normalized.join("\r\n") }; } function stopViewerOnly() { if (statsTimer) window.clearInterval(statsTimer); statsTimer = null; if (image) image.removeAttribute("src"); if (video) video.srcObject = null; if (ws && ws.readyState === WebSocket.OPEN) ws.close(); if (ws && ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close(); if (pc) pc.close(); ws = null; pc = null; } function showMjpeg(status) { if (!image || !video) return; video.style.display = "none"; image.style.display = "block"; const params = new URLSearchParams({ device: status.videoDevice || "/dev/video0", width: String(status.width || 1920), height: String(status.height || 1080), framerate: String(status.framerate || 30), t: String(Date.now()), }); image.src = `/api/camera/mjpeg?${params.toString()}`; image.onload = () => setState("MJPEG streaming"); image.onerror = () => setState("MJPEG stream failed"); } async function startWebRtc(status) { if (!video || !image) return; image.style.display = "none"; video.style.display = "block"; pc = new RTCPeerConnection(); ws = new WebSocket(status.signalingUrl); pc.ontrack = (event) => { const stream = event.streams[0]; console.debug("[camera-web] track received", event.track.kind, event.track.readyState, event.track.muted); if (!stream) return; video.srcObject = stream; video.play().catch((error) => console.error("[camera-web] video play failed", error)); setState("WebRTC streaming"); }; pc.onicecandidate = (event) => { if (!event.candidate || !ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: "candidate", roomId: status.roomId || "jetson-1", role: "viewer", candidate: event.candidate, })); }; pc.onconnectionstatechange = () => { console.debug("[camera-web] connection state", pc.connectionState); if (pc.connectionState === "connected") setState("WebRTC connected"); if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setState("WebRTC failed"); }; ws.onmessage = async (event) => { const data = JSON.parse(event.data); if (data.type === "offer") { console.debug("[camera-web] offer received"); await pc.setRemoteDescription(normalizeOfferDescription(data.offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "answer", roomId: status.roomId || "jetson-1", role: "viewer", answer, })); } } if (data.type === "candidate" && data.candidate) { await pc.addIceCandidate(data.candidate); } }; await new Promise((resolve, reject) => { ws.onopen = () => { ws.send(JSON.stringify({ type: "join", roomId: status.roomId || "jetson-1", role: "viewer" })); resolve(); }; ws.onerror = () => reject(new Error("signaling connection failed")); }); setState("starting WebRTC sender"); await fetch("/api/camera/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: status.roomId || "jetson-1" }), }); setState("waiting for WebRTC offer"); } async function startCamera() { stopViewerOnly(); setState("starting"); await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined); const status = await getStatus(); if (!status.enabled) { setState("camera disabled"); return; } const mode = modeSelect?.value || "mjpeg"; if (mode === "webrtc") { await startWebRtc(status); } else { setState("starting MJPEG stream"); showMjpeg(status); } } async function stopCamera() { stopViewerOnly(); await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined); setState("stopped"); await getStatus(); } startButton?.addEventListener("click", () => startCamera().catch((error) => setState(error.message))); stopButton?.addEventListener("click", () => stopCamera().catch((error) => setState(error.message))); modeSelect?.addEventListener("change", () => startCamera().catch((error) => setState(error.message))); getStatus().then((status) => { if (!status.enabled) setState("camera disabled"); else startCamera().catch((error) => setState(error.message)); }); setInterval(() => { getStatus().catch(() => undefined); }, 3000);