esp32cam-demo/Misc/multiCameraViewer.htm

847 wiersze
34 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<!--
Displays camera feeds in resizable and zoomable windows
with motion detection.
This was an experiment to see what could be acheived using
A.I. to write all the code.
Add camera IPs under "ADD YOUR CAMERA IPs HERE"
Modify load settings keys under 'load settings with keypress'
Specify config on url: http://x.x.x.x?config=xxx
03Jun25
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Camera Viewer</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<style>
/* Basic styles for the entire page */
body {
margin: 0;
background: #111;
overflow: hidden;
font-family: sans-serif;
}
/* Styles for the toolbar at the top */
#toolbar {
position: relative;
background: transparent;
width: 100%;
height: 0;
overflow: visible;
}
/* Styles for buttons within the toolbar */
#toolbar button {
margin-right: 5px;
padding: 5px 10px;
background: #333;
color: white;
border: none;
cursor: pointer;
}
/* Styles for the containers holding each camera image */
.img-container {
position: absolute;
border: 2px solid #444;
overflow: hidden;
user-select: none;
z-index: 1;
background: #000;
}
.img-container.active {
z-index: 1000;
}
/* Styles for the inner div used for zoom/pan */
.img-inner {
width: 100%;
height: 100%;
position: relative;
cursor: grab;
overflow: hidden;
}
.img-inner img {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
user-select: none;
pointer-events: none;
}
/* Styles for the close button */
.close-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0);
color: #00A;
border: none;
font-size: 16px;
cursor: pointer;
z-index: 10;
}
/* Styles for the camera title */
.cam-title {
position: absolute;
top: 4px;
left: 5px;
color: #00A;
background: rgba(0,0,0,0);
padding: 2px 6px;
font-size: 16px;
z-index: 5;
pointer-events: none;
}
/* Styles for motion detection controls */
.motion-controls {
position: absolute;
top: 26px;
left: 5px;
background: rgba(0,0,0,0);
padding: 0px 0px;
border-radius: 0px;
z-index: 10;
color: #00A;
font-size: 8px;
display: flex;
align-items: center;
}
.motion-controls label {
margin-right: 0px;
cursor: pointer;
}
.motion-controls input[type="checkbox"] {
margin-right: 0px;
cursor: pointer;
}
.motion-controls input[type="range"] {
width: 120px;
height: 8px;
cursor: pointer;
display: none; /* Hidden by default */
margin-left: 5px;
vertical-align: middle;
}
.motion-controls input[type="range"].visible {
display: inline-block; /* Show when checkbox is checked */
}
.motion-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
border-radius: 50%;
background-color: rgba(255, 0, 0, 0.7);
display: none; /* Hidden by default */
z-index: 100;
pointer-events: none; /* Allow clicks through */
animation: pulse 0.5s infinite alternate;
}
@keyframes pulse {
from { transform: translate(-50%, -50%) scale(1); opacity: 0.7; }
to { transform: translate(-50%, -50%) scale(1.2); opacity: 0.3; }
}
</style>
</head>
<body>
<div id="toolbar"></div>
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
<script>
let isShiftPressed = false;
let audioContext = null; // Global AudioContext
let lastBeepTime = 0;
const beepCooldown = 1000; // 1 second cooldown
// --- DEBUGGING ---
const DEBUG_MOTION = true; // Set to true to enable detailed motion logs
function debugLog(message) {
if (DEBUG_MOTION) {
console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
}
}
// --- END DEBUGGING ---
// load settings with keypress ('r' and 'b' unavailable)
const keyConfigMap = [
{ key: 'm', config: 'main' },
{ key: 's', config: 'small' },
{ key: 'f', config: 'front' },
];
window.addEventListener('keydown', e => {
if (e.key === 'Shift') isShiftPressed = true;
if (e.key === 'b') {
const tb = document.getElementById('toolbar');
// Correctly toggle display for toolbar, which might not have explicit display style initially
const currentDisplay = window.getComputedStyle(tb).display;
tb.style.display = (currentDisplay === 'none' || tb.style.display === 'none') ? 'block' : 'none';
}
if (e.key === 'r') resetCameras();
const match = keyConfigMap.find(item => e.key === item.key);
if (match) {
const data = localStorage.getItem('cameraState_' + match.config);
if (data) {
// resetCameras(); // loadState will handle resetting
localStorage.setItem('cameraState', data); // Set the active state
loadState();
}
}
});
window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; });
window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false });
// ADD YOUR CAMERA IPs HERE
const cameras = [
{ src: "http://192.168.1.100/jpg", id: "Front" },
{ src: "http://192.168.1.101/jpg", id: "Side" },
{ src: "http://192.168.1.102/jpg", id: "Back" }
];
const toolbar = document.getElementById('toolbar');
const camButtonContainer = document.createElement('span');
camButtonContainer.style.position = 'fixed';
camButtonContainer.style.bottom = '10px';
camButtonContainer.style.left = '10px';
camButtonContainer.style.zIndex = '2000'; // Ensure toolbar buttons are above camera windows
toolbar.appendChild(camButtonContainer);
cameras.forEach(cam => {
const btn = document.createElement('button');
btn.textContent = cam.id;
btn.onclick = () => createCameraWindow(cam);
camButtonContainer.appendChild(btn);
});
function createButton(text, onClick, marginLeft = '5px') {
const btn = document.createElement('button');
btn.textContent = text;
btn.onclick = onClick;
btn.style.marginLeft = marginLeft;
camButtonContainer.appendChild(btn);
return btn;
}
createButton('Save', () => {
const name = prompt('Enter config name:');
if (name) localStorage.setItem('cameraState_' + name, localStorage.getItem('cameraState'));
}, '20px');
createButton('Load', () => {
const name = prompt('Enter config name:');
if (!name) return;
const data = localStorage.getItem('cameraState_' + name);
if (data) {
localStorage.setItem('cameraState', data); // Set the active state to be loaded
loadState();
} else {
alert(`Config "${name}" not found.`);
}
});
createButton('Export', () => {
const data = localStorage.getItem('cameraState');
if (data) {
const blob = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'camera_config.json';
a.click();
URL.revokeObjectURL(a.href); // Clean up
} else {
alert('No current state to export.');
}
});
createButton('Import', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
JSON.parse(e.target.result); // Validate JSON
localStorage.setItem('cameraState', e.target.result);
loadState();
} catch (err) {
alert('Error parsing JSON file: ' + err.message);
}
};
reader.onerror = () => alert('Error reading file.');
reader.readAsText(file);
};
input.click();
});
createButton('Reset', resetCameras, '20px');
createButton('About', showInstructions, '20px');
function showInstructions() {
alert(`CCTV Camera Viewer with Motion Detection
- Click camera button to open its feed.
- Save/Load/Import/Export configuration.
- Drag/Resize windows.
- SHIFT + Scroll to zoom, SHIFT + drag to pan.
- Motion Detection: Check box to enable;
use slider for sensitivity.
- 'b' hide/show the toolbar.
- Quick load configs:
'm' Main, 's' Side, 'f' Front.
- 'r'/Reset close all feeds.
- Specify config on URL: ?config=
`);
}
function closeCameraWindow(buttonElement) {
const container = buttonElement.parentElement;
if (container && container._zoomPanState) {
debugLog(`Closing camera ${container.id}. Setting isActive=false.`);
container._zoomPanState.isActive = false; // Signal to stop fetching
}
if (container) container.remove();
saveState();
}
function resetCameras() {
debugLog('Resetting all cameras.');
document.querySelectorAll('.img-container').forEach(el => {
if (el._zoomPanState) {
debugLog(`Reset: Setting isActive=false for ${el.id}`);
el._zoomPanState.isActive = false;
}
el.remove();
});
localStorage.removeItem('cameraState'); // Clears the *active* saved state
// Also clear any named configs if desired, or just the active one
// For instance, to clear all 'cameraState_...'
// Object.keys(localStorage).forEach(key => {
// if (key.startsWith('cameraState_')) localStorage.removeItem(key);
// });
}
function saveState() {
const stateToSave = {};
document.querySelectorAll('.img-container').forEach(c => {
if (!c._zoomPanState) return; // Should not happen if initialized
const s = c._zoomPanState;
const id = c.getAttribute('data-cam-id');
stateToSave[id] = stateToSave[id] || [];
stateToSave[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,
motionEnabled: s.motionEnabled,
motionSensitivity: s.motionSensitivity
});
});
localStorage.setItem('cameraState', JSON.stringify(stateToSave));
debugLog('State saved.');
}
function getQueryParam(name) {
const params = new URLSearchParams(window.location.search);
return params.get(name);
}
function loadState(configName) {
debugLog('Loading state...');
// Clear existing cameras
document.querySelectorAll('.img-container').forEach(el => {
if (el._zoomPanState) el._zoomPanState.isActive = false;
el.remove();
});
// Get state from URL config or fallback to default
const stateString = configName
? localStorage.getItem('cameraState_' + configName)
: localStorage.getItem('cameraState');
if (!stateString) {
debugLog('No state found to load.');
return;
}
let stateToLoad;
try {
stateToLoad = JSON.parse(stateString);
} catch (e) {
console.error("Error parsing saved state:", e);
alert("Error loading saved state.");
return;
}
for (const camDef of cameras) {
(stateToLoad[camDef.id] || []).forEach(cfg => {
createCameraWindow(camDef, cfg);
});
}
debugLog('State loaded.');
}
function initAudio() {
if (!audioContext) {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
debugLog("AudioContext initialized.");
} catch (e) {
console.error("Web Audio API is not supported in this browser");
return; // Important to return if context fails
}
}
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().then(() => debugLog("AudioContext resumed."))
.catch(e => console.error("Error resuming AudioContext:", e));
}
}
function playBeep(cameraIdentifier = "unknown") {
const now = Date.now();
if (!audioContext || audioContext.state !== 'running') {
debugLog(`playBeep (${cameraIdentifier}): AudioContext not ready or not running. State: ${audioContext ? audioContext.state : 'null'}`);
return;
}
if (now - lastBeepTime < beepCooldown) {
// debugLog(`playBeep (${cameraIdentifier}): Beep cooldown active.`); // Can be spammy
return;
}
lastBeepTime = now;
debugLog(`playBeep triggered by ${cameraIdentifier}`);
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // Reduced volume slightly
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
}
document.body.addEventListener('click', initAudio, { once: true });
document.body.addEventListener('keydown', initAudio, { once: true });
function createCameraWindow(cam, saved = null) {
const uniqueId = saved?.uniqueId || `${cam.id}_${Date.now()}`;
// Check if a window with this uniqueId already exists (e.g., from a partial loadState)
if (document.getElementById(uniqueId)) {
debugLog(`Window with ID ${uniqueId} already exists. Skipping creation.`);
return;
}
debugLog(`Creating window for ${cam.id} (unique: ${uniqueId}). Saved: ${!!saved}`);
const div = document.createElement('div');
div.className = 'img-container';
div.id = uniqueId;
div.setAttribute('data-cam-id', cam.id);
if (saved) {
div.style.left = saved.left;
div.style.top = saved.top;
div.style.width = saved.width;
div.style.height = saved.height;
} else {
const x = Math.random() * 200 + 20; // Keep initial windows more on screen
const y = Math.random() * 150 + 20;
div.style = `left:${x}px; top:${y}px; width:320px; height:240px;`;
}
div.innerHTML = `
<button class="close-btn" onclick="closeCameraWindow(this)">×</button>
<div class="cam-title">${cam.id}</div>
<div class="img-inner"><img crossorigin="anonymous" /></div>
<div class="motion-indicator"></div>
<div class="motion-controls">
<label for="motion_check_${uniqueId}"></label>
<input type="checkbox" id="motion_check_${uniqueId}" class="motion-checkbox">
<input type="range" id="motion_slider_${uniqueId}" class="motion-slider" min="1" max="100" value="50">
</div>
`;
document.body.appendChild(div);
initContainer(div, cam.src, saved);
}
function compareFrames(ctx1, ctx2, width, height, sensitivity, containerId = "unknown_cam") { // Added containerId for logging
const imgData1 = ctx1.getImageData(0, 0, width, height).data;
const imgData2 = ctx2.getImageData(0, 0, width, height).data;
let diffCount = 0;
// --- MODIFIED PARAMETERS ---
// Original: const threshold = 10;
const threshold = 8; // Lowered from 10. Defines how much a single pixel's grayscale value needs to change.
// This makes individual pixel changes easier to detect.
// Original calculation was: (101 - sensitivity) * (width * height / 400)
// New calculation aims for a less steep sensitivity curve and generally more sensitivity.
// N_scaler: Compresses the effect of the sensitivity slider.
// A higher N_scaler means the slider has a less dramatic effect (curve is flatter).
// Original formula implicitly had N_scaler around 1.0 relative to its 1-100 range.
const N_scaler = 2.0;
// baseDivisor: Affects overall sensitivity. Higher baseDivisor = more sensitive (fewer pixels needed).
// Original was 400.0.
const baseDivisor = 800.0;
// 'sensitivity' is the slider value from 1 (low sensitivity) to 100 (high sensitivity).
// We want 'effectiveSensitivityFactor' to be low for high slider sensitivity, and high for low slider sensitivity.
// When slider sensitivity = 100 (max), effectiveSensitivityFactor = ((100-100)/2.0) + 1.0 = 1.0 (most sensitive factor part).
// When slider sensitivity = 1 (min), effectiveSensitivityFactor = ((100-1)/2.0) + 1.0 = 49.5 + 1.0 = 50.5 (least sensitive factor part).
const effectiveSensitivityFactor = ((100.0 - parseFloat(sensitivity)) / N_scaler) + 1.0;
const requiredDiffPixels = effectiveSensitivityFactor * (width * height / baseDivisor);
// --- END MODIFIED PARAMETERS ---
for (let i = 0; i < imgData1.length; i += 4) {
const gray1 = (imgData1[i] + imgData1[i + 1] + imgData1[i + 2]) / 3;
const gray2 = (imgData2[i] + imgData2[i + 1] + imgData2[i + 2]) / 3;
if (Math.abs(gray1 - gray2) > threshold) diffCount++;
}
const motionDetected = diffCount > requiredDiffPixels;
// For debugging the new values:
// Only log ~10% of the calls to reduce console spam, if DEBUG_MOTION is true.
if (DEBUG_MOTION && Math.random() < 0.1) { // Adjust 0.1 (10%) as needed for logging frequency
debugLog(`[${containerId}] compareFrames (slider: ${sensitivity}): diffCount=${diffCount}, reqDiffPixels=${requiredDiffPixels.toFixed(2)}, effectiveSensFactor=${effectiveSensitivityFactor.toFixed(2)}, threshold=${threshold}, detected=${motionDetected}`);
}
return motionDetected;
}
function initContainer(container, src, saved) {
const inner = container.querySelector('.img-inner');
const img = inner.querySelector('img');
const motionCheckbox = container.querySelector('.motion-checkbox');
const motionSlider = container.querySelector('.motion-slider');
const motionIndicator = container.querySelector('.motion-indicator');
const motionControlsDiv = container.querySelector('.motion-controls');
motionControlsDiv.addEventListener('mousedown', (event) => event.stopPropagation());
motionControlsDiv.addEventListener('pointerdown', (event) => event.stopPropagation());
const canvas1 = document.createElement('canvas');
const canvas2 = document.createElement('canvas');
const ctx1 = canvas1.getContext('2d', { willReadFrequently: true });
const ctx2 = canvas2.getContext('2d', { willReadFrequently: true });
const motionWidth = 64;
const motionHeight = 48;
canvas1.width = canvas2.width = motionWidth;
canvas1.height = canvas2.height = motionHeight;
let prevImageLoaded = false;
const state = {
scale: saved?.scale || 1, panX: saved?.panX || 0, panY: saved?.panY || 0,
isPanning: false, startX: 0, startY: 0,
imgNaturalWidth: 0, imgNaturalHeight: 0,
initialized: !!saved,
motionEnabled: saved?.motionEnabled || false,
motionSensitivity: saved?.motionSensitivity || 50,
lastMotionTime: 0,
isActive: true // NEW: Flag to control the fetch loop
};
container._zoomPanState = state; // Attach state to the DOM element
debugLog(`[${container.id}] Initializing. MotionEnabled: ${state.motionEnabled}, Sensitivity: ${state.motionSensitivity}, IsActive: ${state.isActive}`);
motionCheckbox.checked = state.motionEnabled;
motionSlider.value = state.motionSensitivity;
if (state.motionEnabled) motionSlider.classList.add('visible');
motionCheckbox.addEventListener('change', () => {
initAudio(); // Ensure audio is ready
const newMotionEnabledState = motionCheckbox.checked;
debugLog(`[${container.id}] Checkbox changed. Old motionEnabled: ${state.motionEnabled}, New: ${newMotionEnabledState}`);
state.motionEnabled = newMotionEnabledState;
motionSlider.classList.toggle('visible', state.motionEnabled);
// Reset prevImageLoaded whenever motion is toggled on OR off
// This ensures a clean start when re-enabled and stops lingering comparisons
debugLog(`[${container.id}] Resetting prevImageLoaded to false due to checkbox change.`);
prevImageLoaded = false;
if (!state.motionEnabled) {
motionIndicator.style.display = 'none';
}
saveState();
});
motionSlider.addEventListener('input', () => {
state.motionSensitivity = parseInt(motionSlider.value, 10);
// No saveState() on input for performance, only on change
});
motionSlider.addEventListener('change', () => {
state.motionSensitivity = parseInt(motionSlider.value, 10);
debugLog(`[${container.id}] Sensitivity changed to ${state.motionSensitivity}`);
saveState();
});
interact(container)
.draggable({ /* ... listeners ... */ }) // Keep existing interactjs setup
.resizable({ /* ... listeners ... */ }); // Keep existing interactjs setup
// ... (Keep existing interact, draggable, resizable, wheel, mousedown, mousemove, mouseup, clampPan, updateImageTransform, container mousedown listeners) ...
// The following are copy-pasted but should be verified if you made changes there not shown in the original problem
interact(container)
.draggable({
listeners: {
start(event) { if (isShiftPressed) event.interaction.stop(); },
move(event) {
if (isShiftPressed || !state.isActive) return; // Added !state.isActive check
container.style.left = (parseFloat(container.style.left) || 0) + event.dx + 'px';
container.style.top = (parseFloat(container.style.top) || 0) + event.dy + 'px';
},
end() { if (!isShiftPressed && state.isActive) saveState(); }
}
})
.resizable({
edges: { left: true, right: true, bottom: true, top: true },
modifiers: [
interact.modifiers.aspectRatio({ ratio: 'preserve' }), // Use 'preserve' for natural aspect ratio
interact.modifiers.restrictSize({ min: { width: 100, height: 75 } })
],
listeners: {
move(event) {
if (!state.isActive) return;
container.style.width = event.rect.width + 'px';
container.style.height = event.rect.height + 'px';
// Recalculate scale to fit, reset pan, only if natural dimensions are known
if (state.imgNaturalWidth > 0 && state.imgNaturalHeight > 0) {
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY); // Fit mode
state.panX = 0; state.panY = 0;
updateImageTransform(img, state);
}
},
end() { if (state.isActive) saveState(); }
}
});
inner.addEventListener('wheel', e => {
if (!isShiftPressed || !state.isActive) return;
e.preventDefault();
const rect = inner.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const prevScale = state.scale;
state.scale += -e.deltaY * 0.0005 * state.scale; // Scale relative to current scale
state.scale = Math.min(Math.max(0.1, state.scale), 10);
state.panX = mouseX - ((mouseX - state.panX) * (state.scale / prevScale));
state.panY = mouseY - ((mouseY - state.panY) * (state.scale / prevScale));
clampPan();
updateImageTransform(img, state);
// No saveState() on wheel, usually saved on mouseup after pan or resize end
});
inner.addEventListener('mousedown', e => {
if (!isShiftPressed || !state.isActive) return;
e.preventDefault();
state.isPanning = true;
state.startX = e.clientX - state.panX;
state.startY = e.clientY - state.panY;
inner.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => { // Global listener
if (state.isPanning && state.isActive) { // Check state.isActive here too
state.panX = e.clientX - state.startX;
state.panY = e.clientY - state.startY;
clampPan();
updateImageTransform(img, state);
}
});
window.addEventListener('mouseup', () => { // Global listener
if (state.isPanning && state.isActive) { // Check state.isActive
state.isPanning = false;
inner.style.cursor = 'grab';
saveState();
}
});
function clampPan() {
if (!state.imgNaturalWidth || !state.imgNaturalHeight) return; // Avoid division by zero if image not loaded
const cw = container.clientWidth, ch = container.clientHeight;
const iw = state.imgNaturalWidth * state.scale;
const ih = state.imgNaturalHeight * state.scale;
// Allow image to be smaller than container if zoomed out
const maxX = Math.max(0, iw - cw);
const maxY = Math.max(0, ih - ch);
state.panX = Math.max(Math.min(0, state.panX), cw - iw);
state.panY = Math.max(Math.min(0, state.panY), ch - ih);
// If image is smaller than container, center it or keep at 0,0
if (iw < cw) state.panX = (cw - iw) / 2; else state.panX = Math.max(cw - iw, Math.min(0, state.panX));
if (ih < ch) state.panY = (ch - ih) / 2; else state.panY = Math.max(ch - ih, Math.min(0, state.panY));
}
function updateImageTransform(image, s) {
image.style.left = s.panX + 'px';
image.style.top = s.panY + 'px';
image.style.transform = `scale(${s.scale})`;
image.style.width = s.imgNaturalWidth ? `${s.imgNaturalWidth}px` : 'auto'; // Set base size for scale
image.style.height = s.imgNaturalHeight ? `${s.imgNaturalHeight}px` : 'auto';
}
container.addEventListener('mousedown', () => {
if (!state.isActive) return;
document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active'));
container.classList.add('active');
});
let fetchTimeoutId = null; // To clear timeout if container becomes inactive
function fetchNextFrame() {
if (!state.isActive) {
debugLog(`[${container.id}] fetchNextFrame: state.isActive is false. Stopping loop.`);
if (fetchTimeoutId) clearTimeout(fetchTimeoutId);
return;
}
const imgUrl = `${src}?t=${Date.now()}`;
let requestTimedOut = false; // Renamed from timedOut to avoid conflict
const requestTimeoutId = setTimeout(() => {
requestTimedOut = true;
debugLog(`[${container.id}] Image request timed out for ${imgUrl}. Retrying.`);
if (state.isActive) fetchNextFrame(); // Retry only if still active
}, 4000);
const tempImg = new Image();
tempImg.crossOrigin = "Anonymous";
tempImg.onload = () => {
if (requestTimedOut || !state.isActive) {
if (!state.isActive) debugLog(`[${container.id}] tempImg.onload: state became inactive during load.`);
if (requestTimedOut) debugLog(`[${container.id}] tempImg.onload: request had already timed out.`);
clearTimeout(requestTimeoutId); // Clear this specific request's timeout
return;
}
clearTimeout(requestTimeoutId);
img.src = tempImg.src; // Update the visible image
const existingOverlay = container.querySelector('.error-overlay');
if (existingOverlay) existingOverlay.remove();
state.imgNaturalWidth = tempImg.naturalWidth;
state.imgNaturalHeight = tempImg.naturalHeight;
if (state.motionEnabled && state.imgNaturalWidth > 0 && state.isActive) {
// debugLog(`[${container.id}] Motion detection ACTIVE. prevImageLoaded=${prevImageLoaded}.`); // Spammy
ctx1.drawImage(canvas2, 0, 0, motionWidth, motionHeight); // Old canvas2 -> canvas1
ctx2.drawImage(tempImg, 0, 0, motionWidth, motionHeight); // New image -> canvas2
if (prevImageLoaded) {
// debugLog(`[${container.id}] Comparing frames.`); // Spammy
if (compareFrames(ctx1, ctx2, motionWidth, motionHeight, state.motionSensitivity, container.id)) {
debugLog(`[${container.id}] Motion DETECTED! Calling playBeep.`);
playBeep(container.id);
motionIndicator.style.display = 'block';
state.lastMotionTime = Date.now();
}
} else {
debugLog(`[${container.id}] Priming motion detection. Setting prevImageLoaded = true.`);
prevImageLoaded = true;
}
}
if (motionIndicator.style.display === 'block' && (Date.now() - state.lastMotionTime > 500)) {
motionIndicator.style.display = 'none';
}
if (!state.initialized && state.imgNaturalWidth > 0) {
debugLog(`[${container.id}] First successful image load. Initializing scale and pan.`);
const scaleX = container.clientWidth / state.imgNaturalWidth;
const scaleY = container.clientHeight / state.imgNaturalHeight;
state.scale = Math.min(scaleX, scaleY); // Fit image
state.panX = (container.clientWidth - (state.imgNaturalWidth * state.scale)) / 2; // Center
state.panY = (container.clientHeight - (state.imgNaturalHeight * state.scale)) / 2; // Center
state.initialized = true;
updateImageTransform(img, state); // Apply transform
saveState(); // Save initial state once calculated
} else if (state.initialized) {
updateImageTransform(img, state); // Just update if already initialized
}
if (state.isActive) { // Schedule next fetch only if still active
fetchTimeoutId = setTimeout(fetchNextFrame, 600);
}
};
tempImg.onerror = () => {
if (requestTimedOut || !state.isActive) { // Check isActive here too
if (!state.isActive) debugLog(`[${container.id}] tempImg.onerror: state became inactive during load/error.`);
clearTimeout(requestTimeoutId);
return;
}
clearTimeout(requestTimeoutId);
debugLog(`[${container.id}] Error loading image ${imgUrl}.`);
let errorOverlay = container.querySelector('.error-overlay');
if (!errorOverlay) {
errorOverlay = document.createElement('div');
errorOverlay.className = 'error-overlay';
errorOverlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,0,0,0.5);color:white;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:18px;z-index:100;pointer-events:none;';
errorOverlay.textContent = 'Camera Error';
container.appendChild(errorOverlay);
}
if (state.isActive) { // Retry only if still active
fetchTimeoutId = setTimeout(fetchNextFrame, 2000); // Longer delay on error
}
};
tempImg.src = imgUrl;
}
fetchNextFrame(); // Start the loop
}
// Initial load of state from localStorage when the page loads
const startupConfig = getQueryParam('config');
loadState(startupConfig);
</script>
</body>
</html>