Update multiCameraViewer.htm

master
Alan 2025-05-23 12:05:24 +01:00 zatwierdzone przez GitHub
rodzic c12e87219f
commit 14efc61b59
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
1 zmienionych plików z 446 dodań i 238 usunięć

Wyświetl plik

@ -1,276 +1,484 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- <!--
Displays camera feeds in resizable and zoomable windows.
Display all cameras with resize and zoom Hold Shift to zoom/pan.
Hold shift to zoom/pan the image Add camera IPs under "const cameras".
23May25
enter camera IP details under "const cameras"
16May25
--> -->
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Camera Viewer Shift-Flipped</title> <title>Camera Viewer Shift-Flipped</title>
<style> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
body { <meta http-equiv="Pragma" content="no-cache">
margin: 0; <meta http-equiv="Expires" content="0">
background: #111; <style>
overflow: hidden; /* Basic styles for the entire page */
font-family: sans-serif; body {
} margin: 0; /* Removes default body margins */
#toolbar { background: #111; /* Sets a dark background color */
position: fixed; overflow: hidden; /* Prevents scrollbars on the main page */
top: 0; font-family: sans-serif; /* Sets a standard, easy-to-read font */
left: 0; }
z-index: 10000;
background: #222; /* Styles for the toolbar at the top */
color: #fff; #toolbar {
padding: 5px; position: fixed; /* Keeps the toolbar in place when scrolling (though scrolling is disabled) */
} top: 0; /* Positions it at the top */
#toolbar button { left: 0; /* Positions it at the left */
margin-right: 5px; z-index: 10000; /* Ensures the toolbar is always on top of other elements */
padding: 5px 10px; background: #222; /* Dark background for the toolbar */
background: #333; color: #fff; /* White text color */
color: white; padding: 5px; /* Adds some space inside the toolbar */
border: none; }
cursor: pointer;
} /* Styles for buttons within the toolbar */
.img-container { #toolbar button {
position: absolute; margin-right: 5px; /* Adds space between buttons */
border: 2px solid #444; padding: 5px 10px; /* Adds space inside buttons */
overflow: hidden; background: #333; /* Dark background for buttons */
user-select: none; color: white; /* White text on buttons */
z-index: 1; border: none; /* Removes default button borders */
background: #000; cursor: pointer; /* Changes the mouse cursor to a pointer when hovering */
} }
.img-container.active {
z-index: 1000; /* Styles for the containers holding each camera image */
} .img-container {
.img-inner { position: absolute; /* Allows positioning relative to the body */
width: 100%; border: 2px solid #444; /* Adds a border around each camera window */
height: 100%; overflow: hidden; /* Hides parts of the image that go outside the container (needed for zoom/pan) */
position: relative; user-select: none; /* Prevents text selection within the container */
cursor: grab; z-index: 1; /* Default stacking order */
overflow: hidden; background: #000; /* Black background for the container itself */
} }
.img-inner img {
position: absolute; /* Style for the 'active' camera container (the one last clicked) */
top: 0; .img-container.active {
left: 0; z-index: 1000; /* Brings the active container to the front */
transform-origin: top left; }
user-select: none;
pointer-events: none; /* Styles for the inner div used for zoom/pan */
} .img-inner {
.close-btn { width: 100%; /* Fills the parent container */
position: absolute; height: 100%; /* Fills the parent container */
top: 4px; position: relative; /* Needed for positioning the image inside */
right: 4px; cursor: grab; /* Shows a 'grab' cursor, indicating it can be moved (for panning) */
background: red; overflow: hidden; /* Hides parts of the image that go outside this inner div */
color: white; }
border: none;
font-size: 16px; /* Styles for the camera image itself */
cursor: pointer; .img-inner img {
z-index: 10; position: absolute; /* Allows precise positioning for panning */
} top: 0; /* Default top position */
</style> left: 0; /* Default left position */
transform-origin: top left; /* Sets the point around which scaling (zoom) happens */
user-select: none; /* Prevents image selection */
pointer-events: none; /* Prevents the image from capturing mouse events (they go to the parent) */
}
/* Styles for the close button on each camera window */
.close-btn {
position: absolute; /* Positions it within the camera container */
top: 4px; /* Space from the top */
right: 4px; /* Space from the right */
background: red; /* Red background for visibility */
color: white; /* White 'X' */
border: none; /* No border */
font-size: 16px; /* Size of the 'X' */
cursor: pointer; /* Pointer cursor */
z-index: 10; /* Ensures it's above the image but below the toolbar if overlapped */
}
/* Styles for the camera title displayed on each window */
.cam-title {
position: absolute; /* Positions it within the camera container */
top: 4px; /* Space from the top */
left: 8px; /* Space from the left */
color: #fff; /* White text */
background: rgba(0,0,0,0.5); /* Semi-transparent black background */
padding: 2px 6px; /* Space inside the title box */
font-size: 14px; /* Text size */
z-index: 5; /* Above the image */
pointer-events: none; /* Allows clicks to pass through to the container */
}
</style>
</head> </head>
<body> <body>
<div id="toolbar"></div> <div id="toolbar"></div>
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
<script> <script>
let isShiftPressed = false; // Global variable to track if the Shift key is currently pressed.
window.addEventListener('keydown', e => { if (e.key === 'Shift') isShiftPressed = true; }); let isShiftPressed = false;
window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; });
window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false });
// Camera details // Listens for key presses.
// Camera should supply a jpg image - for esp32cam see: https://github.com/alanesq/esp32cam-demo window.addEventListener('keydown', e => {
const cameras = [ // If Shift is pressed, set the variable to true.
{ src: "http://192.168.1.100/jpg", id: "Front" }, if (e.key === 'Shift') isShiftPressed = true;
{ src: "http://192.168.1.110/jpg", id: "Side" }, // If 'b' is pressed, toggle the visibility of the toolbar.
{ src: "http://192.168.1.120/jpg", id: "Back" } if (e.key === 'b') {
]; const tb = document.getElementById('toolbar');
tb.style.display = tb.style.display === 'none' ? 'block' : 'none';
}
});
const toolbar = document.getElementById('toolbar'); // Listens for key releases. If Shift is released, set the variable to false.
cameras.forEach(cam => { window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; });
const btn = document.createElement('button');
btn.textContent = cam.id;
btn.onclick = () => createCameraWindow(cam);
toolbar.appendChild(btn);
});
function createCameraWindow(cam) { // Listens for mouse wheel events. Prevents default browser zoom when Ctrl is held.
if (document.getElementById(cam.id)) return; window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false });
const div = document.createElement('div'); // Array containing camera information (URL and a friendly ID).
div.className = 'img-container'; // ADD YOUR CAMERA IPs HERE.
div.id = cam.id; const cameras = [
div.style = 'left:10px; top:60px; width:320px; height:240px;'; { src: "http://192.168.1.100/jpg", id: "Front" },
div.innerHTML = ` { src: "http://192.168.1.101/jpg", id: "Side" },
<button class="close-btn" onclick="this.parentElement.remove()">×</button> { src: "http://192.168.1.102/jpg", id: "Back" }
<div class="img-inner"><img /></div> ];
`;
document.body.appendChild(div);
initContainer(div, cam.src);
}
function initContainer(container, src) { // Get a reference to the toolbar div.
const inner = container.querySelector('.img-inner'); const toolbar = document.getElementById('toolbar');
const img = inner.querySelector('img'); // Create a container for the buttons, to be placed at the bottom-left.
const camButtonContainer = document.createElement('span');
camButtonContainer.style.position = 'fixed';
camButtonContainer.style.bottom = '10px';
camButtonContainer.style.left = '10px';
toolbar.appendChild(camButtonContainer); // Add the button container to the (invisible) toolbar.
const state = { // Create a button for each camera in the 'cameras' array.
scale: 1, cameras.forEach(cam => {
panX: 0, const btn = document.createElement('button');
panY: 0, btn.textContent = cam.id; // Set button text to camera ID.
isPanning: false, btn.onclick = () => createCameraWindow(cam); // When clicked, create a window for this camera.
startX: 0, camButtonContainer.appendChild(btn); // Add the button to the container.
startY: 0, });
imgNaturalWidth: 0,
imgNaturalHeight: 0, // Create a 'Save Config' button.
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save Config';
saveBtn.onclick = () => {
const name = prompt('Enter config name:'); // Ask for a name.
// If a name is given, save the current state (from 'cameraState') under that name.
if (name) localStorage.setItem('cameraState_' + name, localStorage.getItem('cameraState'));
}; };
container._zoomPanState = state; camButtonContainer.appendChild(saveBtn);
interact(container) // Create a 'Load Config' button.
.draggable({ const loadBtn = document.createElement('button');
listeners: { loadBtn.textContent = 'Load Config';
start(event) { if (isShiftPressed) event.interaction.stop(); }, loadBtn.onclick = () => {
move(event) { const name = prompt('Enter config name:'); // Ask for the name to load.
if (isShiftPressed) return; const data = localStorage.getItem('cameraState_' + name); // Get the saved data.
container.style.left = (parseFloat(container.style.left) || 0) + event.dx + 'px'; if (data) {
container.style.top = (parseFloat(container.style.top) || 0) + event.dy + 'px'; // If data exists, remove all current camera windows.
} document.querySelectorAll('.img-container').forEach(el => el.remove());
// Set the loaded data as the current 'cameraState'.
localStorage.setItem('cameraState', data);
// Load the state, which will recreate the windows.
loadState();
} }
}) };
.resizable({ camButtonContainer.appendChild(loadBtn);
edges: { left: true, right: true, bottom: true, top: true },
modifiers: [
interact.modifiers.aspectRatio({ ratio: 4 / 3 }),
interact.modifiers.restrictSize({ min: { width: 100, height: 75 } })
],
listeners: {
move(event) {
container.style.width = event.rect.width + 'px';
container.style.height = event.rect.height + 'px';
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY);
state.panX = 0;
state.panY = 0;
updateImageTransform(img, state);
}
}
});
inner.addEventListener('wheel', e => { // Create a 'Reset' button.
if (!isShiftPressed) return; const resetBtn = document.createElement('button');
e.preventDefault(); resetBtn.textContent = 'Reset';
const rect = inner.getBoundingClientRect(); resetBtn.onclick = resetCameras; // Calls the reset function when clicked.
const mouseX = e.clientX - rect.left; camButtonContainer.appendChild(resetBtn);
const mouseY = e.clientY - rect.top;
const prevScale = state.scale;
state.scale += -e.deltaY * 0.001;
state.scale = Math.min(Math.max(0.5, state.scale), 5);
state.panX = mouseX - ((mouseX - state.panX) * (state.scale / prevScale));
state.panY = mouseY - ((mouseY - state.panY) * (state.scale / prevScale));
clampPan();
updateImageTransform(img, state);
});
inner.addEventListener('mousedown', e => { // Create an 'About' button.
if (!isShiftPressed) return; const infoBtn = document.createElement('button');
e.preventDefault(); infoBtn.textContent = 'About';
state.isPanning = true; infoBtn.onclick = showInstructions; // Calls the show instructions function when clicked.
state.startX = e.clientX - state.panX; camButtonContainer.appendChild(infoBtn);
state.startY = e.clientY - state.panY;
inner.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => { // Function to display an alert with instructions.
if (!state.isPanning) return; function showInstructions() {
state.panX = e.clientX - state.startX; alert(`CCTV Camera Viewer with zoom/pan
state.panY = e.clientY - state.startY; - Click a camera button to open its feed.
clampPan(); - Multiple instances allowed.
updateImageTransform(img, state); - Save/load configurations with custom names.
}); - Drag/resize windows.
- SHIFT + Scroll to zoom, SHIFT + drag to pan.
window.addEventListener('mouseup', () => { - Press 'b' to hide/show the toolbar.
if (state.isPanning) { `);
state.isPanning = false;
inner.style.cursor = 'grab';
}
});
img.ondragstart = () => false;
function clampPan() {
const cw = container.clientWidth, ch = container.clientHeight;
const iw = state.imgNaturalWidth * state.scale;
const ih = state.imgNaturalHeight * state.scale;
state.panX = Math.max(Math.min(0, cw - iw), state.panX);
state.panY = Math.max(Math.min(0, ch - ih), state.panY);
} }
function updateImageTransform(image, s) { // Function to remove all camera windows and clear the saved state.
image.style.left = s.panX + 'px'; function resetCameras() {
image.style.top = s.panY + 'px'; document.querySelectorAll('.img-container').forEach(el => el.remove());
image.style.transform = `scale(${s.scale})`; localStorage.removeItem('cameraState');
} }
container.addEventListener('mousedown', () => { // Function to save the current state of all camera windows to localStorage.
document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active')); function saveState() {
container.classList.add('active'); const state = {}; // Create an object to hold the state.
}); // Go through each camera container.
document.querySelectorAll('.img-container').forEach(c => {
const s = c._zoomPanState; // Get its zoom/pan state (attached in initContainer).
const id = c.getAttribute('data-cam-id'); // Get its camera ID.
state[id] = state[id] || []; // Ensure an array exists for this camera ID.
// Add the window's properties (position, size, zoom, pan, unique ID) to the array.
state[id].push({
left: c.style.left,
top: c.style.top,
width: c.style.width,
height: c.style.height,
scale: s.scale,
panX: s.panX,
panY: s.panY,
uniqueId: c.id
});
});
// Store the state object as a JSON string in localStorage under 'cameraState'.
localStorage.setItem('cameraState', JSON.stringify(state));
}
function fetchNextFrame() { // Function to load the state from localStorage and create windows.
const imgUrl = `${src}?t=${Date.now()}`; function loadState() {
let timedOut = false; // Get the state from localStorage, or an empty object if none exists.
const state = JSON.parse(localStorage.getItem('cameraState') || '{}');
// Loop through each camera defined in the 'cameras' array.
for (const cam of cameras) {
// If there are saved states for this camera, create a window for each saved state.
(state[cam.id] || []).forEach(cfg => createCameraWindow(cam, cfg));
}
}
const timeoutId = setTimeout(() => { // Function to create a new camera window.
timedOut = true; // 'cam' is the camera object, 'saved' is an optional saved state object.
function createCameraWindow(cam, saved = null) {
// Generate a unique ID for this window, either from saved state or using the current time.
const uniqueId = saved?.uniqueId || `${cam.id}_${Date.now()}`;
// Create the main div for the window.
const div = document.createElement('div');
div.className = 'img-container';
div.id = uniqueId;
div.setAttribute('data-cam-id', cam.id); // Store the camera ID.
// If a saved state is provided, set the window's position and size.
if (saved) {
div.style = `left:${saved.left}; top:${saved.top}; width:${saved.width}; height:${saved.height};`;
} else {
// Otherwise, set a default random position and size.
const x = Math.random() * (300 - 10) + 50;
const y = Math.random() * (200 - 50) + 50;
div.style = `left:${x}px; top:${y}px; width:320px; height:240px;`;
}
// Set the inner HTML of the window (close button, title, image container).
div.innerHTML = `
<button class="close-btn" onclick="this.parentElement.remove(); saveState();">×</button> <div class="cam-title">${cam.id}</div> <div class="img-inner"><img /></div> `;
// Add the new window to the page body.
document.body.appendChild(div);
// Initialize the window's interactivity (drag, resize, zoom, pan, image loading).
initContainer(div, cam.src, saved);
}
// Function to initialize interactivity and image loading for a camera window.
function initContainer(container, src, saved) {
const inner = container.querySelector('.img-inner'); // Get the inner div.
const img = inner.querySelector('img'); // Get the image element.
// Create a state object to hold zoom, pan, and image size info.
const state = {
scale: saved?.scale || 1, // Start with saved scale or 1.
panX: saved?.panX || 0, // Start with saved panX or 0.
panY: saved?.panY || 0, // Start with saved panY or 0.
isPanning: false, // Flag for when panning is active.
startX: 0, // Starting mouse X for panning.
startY: 0, // Starting mouse Y for panning.
imgNaturalWidth: 0, // Original width of the camera image.
imgNaturalHeight: 0, // Original height of the camera image.
initialized: !!saved // Flag to check if it's the first load or a restore.
};
// Attach this state object to the container element itself for easy access.
container._zoomPanState = state;
// Use interact.js to make the container draggable and resizable.
interact(container)
.draggable({
listeners: {
// Stop dragging if Shift is pressed (allows panning instead).
start(event) { if (isShiftPressed) event.interaction.stop(); },
// When moving...
move(event) {
// ...only move if Shift is NOT pressed.
if (isShiftPressed) return;
// Update the container's left and top styles based on how much it was dragged.
container.style.left = (parseFloat(container.style.left) || 0) + event.dx + 'px';
container.style.top = (parseFloat(container.style.top) || 0) + event.dy + 'px';
saveState(); // Save the new position.
}
}
})
.resizable({
edges: { left: true, right: true, bottom: true, top: true }, // Allow resizing from all edges.
modifiers: [
// Keep the aspect ratio at 4:3.
interact.modifiers.aspectRatio({ ratio: 4 / 3 }),
// Set a minimum size.
interact.modifiers.restrictSize({ min: { width: 100, height: 75 } })
],
listeners: {
// When resizing...
move(event) {
// Update the container's width and height.
container.style.width = event.rect.width + 'px';
container.style.height = event.rect.height + 'px';
// When resizing, reset zoom/pan to fit the image to the new size.
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY);
state.panX = 0;
state.panY = 0;
updateImageTransform(img, state); // Apply the new transform.
saveState(); // Save the new size.
}
}
});
// Add a wheel event listener to the inner container for zooming.
inner.addEventListener('wheel', e => {
// Only zoom if Shift is pressed.
if (!isShiftPressed) return;
e.preventDefault(); // Prevent page scrolling.
const rect = inner.getBoundingClientRect(); // Get container position.
// Calculate mouse position relative to the container.
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const prevScale = state.scale; // Store old scale.
// Adjust scale based on wheel direction (up/down).
state.scale += -e.deltaY * 0.0005;
// Clamp the scale
state.scale = Math.min(Math.max(0.1, state.scale), 10);
// Adjust pan to keep the point under the mouse in the same place (zoom towards mouse).
state.panX = mouseX - ((mouseX - state.panX) * (state.scale / prevScale));
state.panY = mouseY - ((mouseY - state.panY) * (state.scale / prevScale));
clampPan(); // Ensure pan stays within bounds.
updateImageTransform(img, state); // Apply new transform.
// Note: saveState() is called in updateImageTransform.
});
// Add a mousedown event listener for starting panning.
inner.addEventListener('mousedown', e => {
// Only pan if Shift is pressed.
if (!isShiftPressed) return;
e.preventDefault(); // Prevent default drag behavior.
state.isPanning = true; // Set panning flag.
// Record starting mouse position and current pan.
state.startX = e.clientX - state.panX;
state.startY = e.clientY - state.panY;
inner.style.cursor = 'grabbing'; // Change cursor to 'grabbing'.
});
// Add a mousemove listener to the whole window for panning.
window.addEventListener('mousemove', e => {
// If currently panning...
if (state.isPanning) {
// Update pan based on mouse movement.
state.panX = e.clientX - state.startX;
state.panY = e.clientY - state.startY;
clampPan(); // Keep pan within bounds.
updateImageTransform(img, state); // Apply new transform.
}
});
// Add a mouseup listener to the whole window to stop panning.
window.addEventListener('mouseup', () => {
// If panning was active...
if (state.isPanning) {
state.isPanning = false; // Reset flag.
inner.style.cursor = 'grab'; // Reset cursor.
saveState(); // Save the final pan position.
}
});
// Function to prevent the image from being panned too far.
function clampPan() {
const cw = container.clientWidth, ch = container.clientHeight; // Container width/height.
const iw = state.imgNaturalWidth * state.scale; // Scaled image width.
const ih = state.imgNaturalHeight * state.scale; // Scaled image height.
// Ensure panX is between (container width - image width) and 0.
state.panX = Math.max(Math.min(0, cw - iw), state.panX);
// Ensure panY is between (container height - image height) and 0.
state.panY = Math.max(Math.min(0, ch - ih), state.panY);
}
// Function to apply the current scale and pan to the image element.
function updateImageTransform(image, s) {
image.style.left = s.panX + 'px'; // Set left offset.
image.style.top = s.panY + 'px'; // Set top offset.
image.style.transform = `scale(${s.scale})`; // Set scale.
saveState(); // Save the state whenever the transform changes.
}
// Add a mousedown listener to the container to bring it to the front.
container.addEventListener('mousedown', () => {
// Remove 'active' class from all containers.
document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active'));
// Add 'active' class to this container.
container.classList.add('active');
});
// Function to fetch the next camera image frame.
function fetchNextFrame() {
// Add a timestamp to the URL to bypass browser cache and get a fresh image.
const imgUrl = `${src}?t=${Date.now()}`;
let timedOut = false;
// Set a timeout. If the image doesn't load within 4 seconds, assume it's stuck and try again.
const timeoutId = setTimeout(() => {
timedOut = true;
fetchNextFrame(); // Try fetching again.
}, 4000);
// Create a temporary image object to load the new frame.
const tempImg = new Image();
// When the image loads successfully...
tempImg.onload = () => {
if (timedOut) return; // If we already timed out, ignore this load.
clearTimeout(timeoutId); // Clear the timeout.
img.src = tempImg.src; // Set the actual image's source to the loaded one.
// Store the original image dimensions.
state.imgNaturalWidth = tempImg.naturalWidth;
state.imgNaturalHeight = tempImg.naturalHeight;
// If this is the first time loading (not restoring from save)...
if (!state.initialized) {
// Calculate the initial scale to fit the image within the container.
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY); // Use the smaller scale to fit.
state.panX = 0; // Start with no pan.
state.panY = 0;
state.initialized = true; // Mark as initialized.
}
updateImageTransform(img, state); // Apply the transform.
// Schedule the next fetch after 600ms (adjust for desired frame rate).
setTimeout(fetchNextFrame, 600);
};
// If the image fails to load...
tempImg.onerror = () => {
// If it hasn't timed out yet...
if (!timedOut) {
clearTimeout(timeoutId); // Clear the timeout.
// Schedule the next fetch after 600ms (retry).
setTimeout(fetchNextFrame, 600);
}
};
// Start loading the image.
tempImg.src = imgUrl;
}
// Start the image fetching loop.
fetchNextFrame(); fetchNextFrame();
}, 4000);
const tempImg = new Image();
tempImg.onload = () => {
if (timedOut) return;
clearTimeout(timeoutId);
img.src = tempImg.src;
state.imgNaturalWidth = tempImg.naturalWidth;
state.imgNaturalHeight = tempImg.naturalHeight;
if (!state.initialized) {
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY);
state.panX = 0;
state.panY = 0;
state.initialized = true;
}
updateImageTransform(img, state);
setTimeout(fetchNextFrame, 600);
};
tempImg.onerror = () => {
if (!timedOut) {
clearTimeout(timeoutId);
setTimeout(fetchNextFrame, 600);
}
};
tempImg.src = imgUrl;
} }
fetchNextFrame(); // Load any previously saved state when the page first loads.
} loadState();
// cameras.forEach(cam => createCameraWindow(cam)); // display cameras at startup
</script> </script>
</body> </body>
</html> </html>
```