kopia lustrzana https://github.com/alanesq/esp32cam-demo
847 wiersze
34 KiB
HTML
847 wiersze
34 KiB
HTML
<!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>
|