Initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user