191 lines
5.9 KiB
JavaScript
191 lines
5.9 KiB
JavaScript
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);
|