Update multiCameraViewer.htm

master
Alan 2025-08-15 08:42:37 +01:00 zatwierdzone przez GitHub
rodzic b0937eae7c
commit 2436ff6da4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
1 zmienionych plików z 213 dodań i 112 usunięć

Wyświetl plik

@ -11,10 +11,11 @@
Add camera IPs under "ADD YOUR CAMERA IPs HERE" Add camera IPs under "ADD YOUR CAMERA IPs HERE"
Modify load settings keys under 'load settings with keypress' Modify load settings keys under 'load settings with keypress'
default detection timer under "// Timer properties"
Specify config on url: http://x.x.x.x?config=xxx Specify config on url: http://x.x.x.x?config=xxx
03Jun25 14Aug25
--> -->
@ -34,7 +35,6 @@
overflow: hidden; overflow: hidden;
font-family: sans-serif; font-family: sans-serif;
} }
/* Styles for the toolbar at the top */ /* Styles for the toolbar at the top */
#toolbar { #toolbar {
position: relative; position: relative;
@ -43,7 +43,6 @@
height: 0; height: 0;
overflow: visible; overflow: visible;
} }
/* Styles for buttons within the toolbar */ /* Styles for buttons within the toolbar */
#toolbar button { #toolbar button {
margin-right: 5px; margin-right: 5px;
@ -53,7 +52,6 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
/* Styles for the containers holding each camera image */ /* Styles for the containers holding each camera image */
.img-container { .img-container {
position: absolute; position: absolute;
@ -63,11 +61,9 @@
z-index: 1; z-index: 1;
background: #000; background: #000;
} }
.img-container.active { .img-container.active {
z-index: 1000; z-index: 1000;
} }
/* Styles for the inner div used for zoom/pan */ /* Styles for the inner div used for zoom/pan */
.img-inner { .img-inner {
width: 100%; width: 100%;
@ -76,7 +72,6 @@
cursor: grab; cursor: grab;
overflow: hidden; overflow: hidden;
} }
.img-inner img { .img-inner img {
position: absolute; position: absolute;
top: 0; top: 0;
@ -85,7 +80,6 @@
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
} }
/* Styles for the close button */ /* Styles for the close button */
.close-btn { .close-btn {
position: absolute; position: absolute;
@ -98,7 +92,6 @@
cursor: pointer; cursor: pointer;
z-index: 10; z-index: 10;
} }
/* Styles for the camera title */ /* Styles for the camera title */
.cam-title { .cam-title {
position: absolute; position: absolute;
@ -111,7 +104,6 @@
z-index: 5; z-index: 5;
pointer-events: none; pointer-events: none;
} }
/* Styles for motion detection controls */ /* Styles for motion detection controls */
.motion-controls { .motion-controls {
position: absolute; position: absolute;
@ -126,17 +118,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.motion-controls label { .motion-controls label {
margin-right: 0px; margin-right: 0px;
cursor: pointer; cursor: pointer;
} }
.motion-controls input[type="checkbox"] { .motion-controls input[type="checkbox"] {
margin-right: 0px; margin-right: 0px;
cursor: pointer; cursor: pointer;
} }
.motion-controls input[type="range"] { .motion-controls input[type="range"] {
width: 120px; width: 120px;
height: 8px; height: 8px;
@ -162,41 +151,71 @@
pointer-events: none; /* Allow clicks through */ pointer-events: none; /* Allow clicks through */
animation: pulse 0.5s infinite alternate; animation: pulse 0.5s infinite alternate;
} }
@keyframes pulse { @keyframes pulse {
from { transform: translate(-50%, -50%) scale(1); opacity: 0.7; } from { transform: translate(-50%, -50%) scale(1); opacity: 0.7; }
to { transform: translate(-50%, -50%) scale(1.2); opacity: 0.3; } to { transform: translate(-50%, -50%) scale(1.2); opacity: 0.3; }
} }
/* Timer styles */
.timer-controls {
position: absolute;
top: 44px;
left: 5px;
background: rgba(0,0,0,0.4);
padding: 5px;
border-radius: 3px;
z-index: 10;
color: #00A;
font-size: 12px;
display: none; /* Hidden by default */
}
.timer-controls.visible {
display: block;
}
.timer-controls input {
width: 40px;
margin-left: 5px;
background: #333;
color: white;
border: 1px solid #555;
}
.timer-display {
margin-left: 10px;
font-weight: bold;
color: #ff9900;
}
.timer-controls button {
margin-left: 5px;
padding: 2px 6px;
background: #444;
color: white;
border: none;
cursor: pointer;
font-size: 10px;
}
</style> </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; let isShiftPressed = false;
let audioContext = null; // Global AudioContext let audioContext = null; // Global AudioContext
let lastBeepTime = 0; let lastBeepTime = 0;
const beepCooldown = 1000; // 1 second cooldown const beepCooldown = 1000; // 1 second cooldown
// --- DEBUGGING --- // --- DEBUGGING ---
const DEBUG_MOTION = true; // Set to true to enable detailed motion logs const DEBUG_MOTION = true; // Set to true to enable detailed motion logs
function debugLog(message) { function debugLog(message) {
if (DEBUG_MOTION) { if (DEBUG_MOTION) {
console.log(`[${new Date().toLocaleTimeString()}] ${message}`); console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
} }
} }
// --- END DEBUGGING --- // --- END DEBUGGING ---
// load settings with keypress ('r' and 'b' unavailable) // load settings with keypress ('r' and 'b' unavailable)
const keyConfigMap = [ const keyConfigMap = [
{ key: 'm', config: 'main' }, { key: 'm', config: 'main' },
{ key: 's', config: 'small' }, { key: 's', config: 'small' },
{ key: 'f', config: 'front' }, { key: 'f', config: 'front' },
]; ];
window.addEventListener('keydown', e => { window.addEventListener('keydown', e => {
if (e.key === 'Shift') isShiftPressed = true; if (e.key === 'Shift') isShiftPressed = true;
if (e.key === 'b') { if (e.key === 'b') {
@ -206,7 +225,6 @@
tb.style.display = (currentDisplay === 'none' || tb.style.display === 'none') ? 'block' : 'none'; tb.style.display = (currentDisplay === 'none' || tb.style.display === 'none') ? 'block' : 'none';
} }
if (e.key === 'r') resetCameras(); if (e.key === 'r') resetCameras();
const match = keyConfigMap.find(item => e.key === item.key); const match = keyConfigMap.find(item => e.key === item.key);
if (match) { if (match) {
const data = localStorage.getItem('cameraState_' + match.config); const data = localStorage.getItem('cameraState_' + match.config);
@ -217,16 +235,13 @@
} }
} }
}); });
window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; }); window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; });
window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false }); window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false });
// ADD YOUR CAMERA IPs HERE // ADD YOUR CAMERA IPs HERE
const cameras = [ const cameras = [
{ src: "http://192.168.1.100/jpg", id: "Front" }, { src: "http://192.168.1.100/jpg", id: "Front" },
{ src: "http://192.168.1.101/jpg", id: "Side" }, { src: "http://192.168.1.200/jpg", id: "Side" }
{ src: "http://192.168.1.102/jpg", id: "Back" }
];
const toolbar = document.getElementById('toolbar'); const toolbar = document.getElementById('toolbar');
const camButtonContainer = document.createElement('span'); const camButtonContainer = document.createElement('span');
@ -236,14 +251,12 @@
camButtonContainer.style.zIndex = '2000'; // Ensure toolbar buttons are above camera windows camButtonContainer.style.zIndex = '2000'; // Ensure toolbar buttons are above camera windows
toolbar.appendChild(camButtonContainer); toolbar.appendChild(camButtonContainer);
cameras.forEach(cam => { cameras.forEach(cam => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.textContent = cam.id; btn.textContent = cam.id;
btn.onclick = () => createCameraWindow(cam); btn.onclick = () => createCameraWindow(cam);
camButtonContainer.appendChild(btn); camButtonContainer.appendChild(btn);
}); });
function createButton(text, onClick, marginLeft = '5px') { function createButton(text, onClick, marginLeft = '5px') {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.textContent = text; btn.textContent = text;
@ -252,12 +265,10 @@
camButtonContainer.appendChild(btn); camButtonContainer.appendChild(btn);
return btn; return btn;
} }
createButton('Save', () => { createButton('Save', () => {
const name = prompt('Enter config name:'); const name = prompt('Enter config name:');
if (name) localStorage.setItem('cameraState_' + name, localStorage.getItem('cameraState')); if (name) localStorage.setItem('cameraState_' + name, localStorage.getItem('cameraState'));
}, '20px'); }, '20px');
createButton('Load', () => { createButton('Load', () => {
const name = prompt('Enter config name:'); const name = prompt('Enter config name:');
if (!name) return; if (!name) return;
@ -269,7 +280,6 @@
alert(`Config "${name}" not found.`); alert(`Config "${name}" not found.`);
} }
}); });
createButton('Export', () => { createButton('Export', () => {
const data = localStorage.getItem('cameraState'); const data = localStorage.getItem('cameraState');
if (data) { if (data) {
@ -283,7 +293,6 @@
alert('No current state to export.'); alert('No current state to export.');
} }
}); });
createButton('Import', () => { createButton('Import', () => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@ -306,10 +315,8 @@
}; };
input.click(); input.click();
}); });
createButton('Reset', resetCameras, '20px'); createButton('Reset', resetCameras, '20px');
createButton('About', showInstructions, '20px'); createButton('About', showInstructions, '20px');
function showInstructions() { function showInstructions() {
alert(`CCTV Camera Viewer with Motion Detection alert(`CCTV Camera Viewer with Motion Detection
- Click camera button to open its feed. - Click camera button to open its feed.
@ -318,6 +325,8 @@
- SHIFT + Scroll to zoom, SHIFT + drag to pan. - SHIFT + Scroll to zoom, SHIFT + drag to pan.
- Motion Detection: Check box to enable; - Motion Detection: Check box to enable;
use slider for sensitivity. use slider for sensitivity.
- Timer: Set countdown (1-120 mins) when motion is enabled.
Timer will auto-disable motion when it reaches zero.
- 'b' hide/show the toolbar. - 'b' hide/show the toolbar.
- Quick load configs: - Quick load configs:
'm' Main, 's' Side, 'f' Front. 'm' Main, 's' Side, 'f' Front.
@ -325,23 +334,31 @@
- Specify config on URL: ?config= - Specify config on URL: ?config=
`); `);
} }
function closeCameraWindow(buttonElement) { function closeCameraWindow(buttonElement) {
const container = buttonElement.parentElement; const container = buttonElement.parentElement;
if (container && container._zoomPanState) { if (container && container._zoomPanState) {
debugLog(`Closing camera ${container.id}. Setting isActive=false.`); debugLog(`Closing camera ${container.id}. Setting isActive=false.`);
container._zoomPanState.isActive = false; // Signal to stop fetching container._zoomPanState.isActive = false; // Signal to stop fetching
// Clear any active timer
if (container._zoomPanState.timerInterval) {
clearInterval(container._zoomPanState.timerInterval);
}
} }
if (container) container.remove(); if (container) container.remove();
saveState(); saveState();
} }
function resetCameras() { function resetCameras() {
debugLog('Resetting all cameras.'); debugLog('Resetting all cameras.');
document.querySelectorAll('.img-container').forEach(el => { document.querySelectorAll('.img-container').forEach(el => {
if (el._zoomPanState) { if (el._zoomPanState) {
debugLog(`Reset: Setting isActive=false for ${el.id}`); debugLog(`Reset: Setting isActive=false for ${el.id}`);
el._zoomPanState.isActive = false; el._zoomPanState.isActive = false;
// Clear any active timer
if (el._zoomPanState.timerInterval) {
clearInterval(el._zoomPanState.timerInterval);
}
} }
el.remove(); el.remove();
}); });
@ -353,7 +370,6 @@
// }); // });
} }
function saveState() { function saveState() {
const stateToSave = {}; const stateToSave = {};
document.querySelectorAll('.img-container').forEach(c => { document.querySelectorAll('.img-container').forEach(c => {
@ -367,37 +383,39 @@
scale: s.scale, panX: s.panX, panY: s.panY, scale: s.scale, panX: s.panX, panY: s.panY,
uniqueId: c.id, uniqueId: c.id,
motionEnabled: s.motionEnabled, motionEnabled: s.motionEnabled,
motionSensitivity: s.motionSensitivity motionSensitivity: s.motionSensitivity,
timerMinutes: s.timerMinutes,
timerSeconds: s.timerSeconds,
timerActive: s.timerActive
}); });
}); });
localStorage.setItem('cameraState', JSON.stringify(stateToSave)); localStorage.setItem('cameraState', JSON.stringify(stateToSave));
debugLog('State saved.'); debugLog('State saved.');
} }
function getQueryParam(name) { function getQueryParam(name) {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return params.get(name); return params.get(name);
} }
function loadState(configName) { function loadState(configName) {
debugLog('Loading state...'); debugLog('Loading state...');
// Clear existing cameras // Clear existing cameras
document.querySelectorAll('.img-container').forEach(el => { document.querySelectorAll('.img-container').forEach(el => {
if (el._zoomPanState) el._zoomPanState.isActive = false; if (el._zoomPanState) {
el._zoomPanState.isActive = false;
if (el._zoomPanState.timerInterval) {
clearInterval(el._zoomPanState.timerInterval);
}
}
el.remove(); el.remove();
}); });
// Get state from URL config or fallback to default // Get state from URL config or fallback to default
const stateString = configName const stateString = configName
? localStorage.getItem('cameraState_' + configName) ? localStorage.getItem('cameraState_' + configName)
: localStorage.getItem('cameraState'); : localStorage.getItem('cameraState');
if (!stateString) { if (!stateString) {
debugLog('No state found to load.'); debugLog('No state found to load.');
return; return;
} }
let stateToLoad; let stateToLoad;
try { try {
stateToLoad = JSON.parse(stateString); stateToLoad = JSON.parse(stateString);
@ -406,18 +424,14 @@
alert("Error loading saved state."); alert("Error loading saved state.");
return; return;
} }
for (const camDef of cameras) { for (const camDef of cameras) {
(stateToLoad[camDef.id] || []).forEach(cfg => { (stateToLoad[camDef.id] || []).forEach(cfg => {
createCameraWindow(camDef, cfg); createCameraWindow(camDef, cfg);
}); });
} }
debugLog('State loaded.'); debugLog('State loaded.');
} }
function initAudio() { function initAudio() {
if (!audioContext) { if (!audioContext) {
try { try {
@ -433,37 +447,31 @@
.catch(e => console.error("Error resuming AudioContext:", e)); .catch(e => console.error("Error resuming AudioContext:", e));
} }
} }
function playBeep(cameraIdentifier = "unknown", isWarning = false) {
function playBeep(cameraIdentifier = "unknown") {
const now = Date.now(); const now = Date.now();
if (!audioContext || audioContext.state !== 'running') { if (!audioContext || audioContext.state !== 'running') {
debugLog(`playBeep (${cameraIdentifier}): AudioContext not ready or not running. State: ${audioContext ? audioContext.state : 'null'}`); debugLog(`playBeep (${cameraIdentifier}): AudioContext not ready or not running. State: ${audioContext ? audioContext.state : 'null'}`);
return; return;
} }
if (now - lastBeepTime < beepCooldown) { if (now - lastBeepTime < beepCooldown && !isWarning) {
// debugLog(`playBeep (${cameraIdentifier}): Beep cooldown active.`); // Can be spammy // debugLog(`playBeep (${cameraIdentifier}): Beep cooldown active.`); // Can be spammy
return; return;
} }
lastBeepTime = now; lastBeepTime = now;
debugLog(`playBeep triggered by ${cameraIdentifier}`); debugLog(`playBeep triggered by ${cameraIdentifier}`);
const oscillator = audioContext.createOscillator(); const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain(); const gainNode = audioContext.createGain();
oscillator.connect(gainNode); oscillator.connect(gainNode);
gainNode.connect(audioContext.destination); gainNode.connect(audioContext.destination);
oscillator.type = 'sine'; oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime); // Different sound for warning
oscillator.frequency.setValueAtTime(isWarning ? 440 : 880, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // Reduced volume slightly gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // Reduced volume slightly
oscillator.start(audioContext.currentTime); oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1); oscillator.stop(audioContext.currentTime + (isWarning ? 0.5 : 0.1)); // Longer beep for warning
} }
document.body.addEventListener('click', initAudio, { once: true }); document.body.addEventListener('click', initAudio, { once: true });
document.body.addEventListener('keydown', initAudio, { once: true }); document.body.addEventListener('keydown', initAudio, { once: true });
function createCameraWindow(cam, saved = null) { function createCameraWindow(cam, saved = null) {
const uniqueId = saved?.uniqueId || `${cam.id}_${Date.now()}`; const uniqueId = saved?.uniqueId || `${cam.id}_${Date.now()}`;
// Check if a window with this uniqueId already exists (e.g., from a partial loadState) // Check if a window with this uniqueId already exists (e.g., from a partial loadState)
@ -471,13 +479,11 @@
debugLog(`Window with ID ${uniqueId} already exists. Skipping creation.`); debugLog(`Window with ID ${uniqueId} already exists. Skipping creation.`);
return; return;
} }
debugLog(`Creating window for ${cam.id} (unique: ${uniqueId}). Saved: ${!!saved}`); debugLog(`Creating window for ${cam.id} (unique: ${uniqueId}). Saved: ${!!saved}`);
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'img-container'; div.className = 'img-container';
div.id = uniqueId; div.id = uniqueId;
div.setAttribute('data-cam-id', cam.id); div.setAttribute('data-cam-id', cam.id);
if (saved) { if (saved) {
div.style.left = saved.left; div.style.left = saved.left;
div.style.top = saved.top; div.style.top = saved.top;
@ -488,7 +494,6 @@
const y = Math.random() * 150 + 20; const y = Math.random() * 150 + 20;
div.style = `left:${x}px; top:${y}px; width:320px; height:240px;`; div.style = `left:${x}px; top:${y}px; width:320px; height:240px;`;
} }
div.innerHTML = ` div.innerHTML = `
<button class="close-btn" onclick="closeCameraWindow(this)">×</button> <button class="close-btn" onclick="closeCameraWindow(this)">×</button>
<div class="cam-title">${cam.id}</div> <div class="cam-title">${cam.id}</div>
@ -499,33 +504,34 @@
<input type="checkbox" id="motion_check_${uniqueId}" class="motion-checkbox"> <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"> <input type="range" id="motion_slider_${uniqueId}" class="motion-slider" min="1" max="100" value="50">
</div> </div>
<div class="timer-controls" id="timer_controls_${uniqueId}">
<label for="timer_input_${uniqueId}">Timer:</label>
<input type="number" id="timer_input_${uniqueId}" min="1" max="120" value="30">
<button id="timer_start_${uniqueId}">Start</button>
<button id="timer_stop_${uniqueId}">Stop</button>
<span class="timer-display" id="timer_display_${uniqueId}">00:00</span>
</div>
`; `;
document.body.appendChild(div); document.body.appendChild(div);
initContainer(div, cam.src, saved); initContainer(div, cam.src, saved);
} }
function compareFrames(ctx1, ctx2, width, height, sensitivity, containerId = "unknown_cam") { // Added containerId for logging function compareFrames(ctx1, ctx2, width, height, sensitivity, containerId = "unknown_cam") { // Added containerId for logging
const imgData1 = ctx1.getImageData(0, 0, width, height).data; const imgData1 = ctx1.getImageData(0, 0, width, height).data;
const imgData2 = ctx2.getImageData(0, 0, width, height).data; const imgData2 = ctx2.getImageData(0, 0, width, height).data;
let diffCount = 0; let diffCount = 0;
// --- MODIFIED PARAMETERS --- // --- MODIFIED PARAMETERS ---
// Original: const threshold = 10; // Original: const threshold = 10;
const threshold = 8; // Lowered from 10. Defines how much a single pixel's grayscale value needs to change. 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. // This makes individual pixel changes easier to detect.
// Original calculation was: (101 - sensitivity) * (width * height / 400) // Original calculation was: (101 - sensitivity) * (width * height / 400)
// New calculation aims for a less steep sensitivity curve and generally more sensitivity. // New calculation aims for a less steep sensitivity curve and generally more sensitivity.
// N_scaler: Compresses the effect of the sensitivity slider. // N_scaler: Compresses the effect of the sensitivity slider.
// A higher N_scaler means the slider has a less dramatic effect (curve is flatter). // 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. // Original formula implicitly had N_scaler around 1.0 relative to its 1-100 range.
const N_scaler = 2.0; const N_scaler = 2.0;
// baseDivisor: Affects overall sensitivity. Higher baseDivisor = more sensitive (fewer pixels needed). // baseDivisor: Affects overall sensitivity. Higher baseDivisor = more sensitive (fewer pixels needed).
// Original was 400.0. // Original was 400.0.
const baseDivisor = 800.0; const baseDivisor = 800.0;
// 'sensitivity' is the slider value from 1 (low sensitivity) to 100 (high sensitivity). // '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. // 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 = 100 (max), effectiveSensitivityFactor = ((100-100)/2.0) + 1.0 = 1.0 (most sensitive factor part).
@ -534,14 +540,12 @@
const requiredDiffPixels = effectiveSensitivityFactor * (width * height / baseDivisor); const requiredDiffPixels = effectiveSensitivityFactor * (width * height / baseDivisor);
// --- END MODIFIED PARAMETERS --- // --- END MODIFIED PARAMETERS ---
for (let i = 0; i < imgData1.length; i += 4) { for (let i = 0; i < imgData1.length; i += 4) {
const gray1 = (imgData1[i] + imgData1[i + 1] + imgData1[i + 2]) / 3; const gray1 = (imgData1[i] + imgData1[i + 1] + imgData1[i + 2]) / 3;
const gray2 = (imgData2[i] + imgData2[i + 1] + imgData2[i + 2]) / 3; const gray2 = (imgData2[i] + imgData2[i + 1] + imgData2[i + 2]) / 3;
if (Math.abs(gray1 - gray2) > threshold) diffCount++; if (Math.abs(gray1 - gray2) > threshold) diffCount++;
} }
const motionDetected = diffCount > requiredDiffPixels; const motionDetected = diffCount > requiredDiffPixels;
// For debugging the new values: // For debugging the new values:
// Only log ~10% of the calls to reduce console spam, if DEBUG_MOTION is true. // 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 if (DEBUG_MOTION && Math.random() < 0.1) { // Adjust 0.1 (10%) as needed for logging frequency
@ -549,17 +553,25 @@
} }
return motionDetected; return motionDetected;
} }
function initContainer(container, src, saved) { function initContainer(container, src, saved) {
const inner = container.querySelector('.img-inner'); const inner = container.querySelector('.img-inner');
const img = inner.querySelector('img'); const img = inner.querySelector('img');
const motionCheckbox = container.querySelector('.motion-checkbox'); const motionCheckbox = container.querySelector('.motion-checkbox');
const motionSlider = container.querySelector('.motion-slider'); const motionSlider = container.querySelector('.motion-slider');
const motionIndicator = container.querySelector('.motion-indicator'); const motionIndicator = container.querySelector('.motion-indicator');
const motionControlsDiv = container.querySelector('.motion-controls'); const motionControlsDiv = container.querySelector('.motion-controls');
// Timer elements
const timerControls = container.querySelector('.timer-controls');
const timerInput = container.querySelector(`#timer_input_${container.id}`);
const timerStart = container.querySelector(`#timer_start_${container.id}`);
const timerStop = container.querySelector(`#timer_stop_${container.id}`);
const timerDisplay = container.querySelector(`#timer_display_${container.id}`);
motionControlsDiv.addEventListener('mousedown', (event) => event.stopPropagation()); motionControlsDiv.addEventListener('mousedown', (event) => event.stopPropagation());
motionControlsDiv.addEventListener('pointerdown', (event) => event.stopPropagation()); motionControlsDiv.addEventListener('pointerdown', (event) => event.stopPropagation());
timerControls.addEventListener('mousedown', (event) => event.stopPropagation());
timerControls.addEventListener('pointerdown', (event) => event.stopPropagation());
const canvas1 = document.createElement('canvas'); const canvas1 = document.createElement('canvas');
const canvas2 = document.createElement('canvas'); const canvas2 = document.createElement('canvas');
@ -570,7 +582,6 @@
canvas1.width = canvas2.width = motionWidth; canvas1.width = canvas2.width = motionWidth;
canvas1.height = canvas2.height = motionHeight; canvas1.height = canvas2.height = motionHeight;
let prevImageLoaded = false; let prevImageLoaded = false;
const state = { const state = {
scale: saved?.scale || 1, panX: saved?.panX || 0, panY: saved?.panY || 0, scale: saved?.scale || 1, panX: saved?.panX || 0, panY: saved?.panY || 0,
isPanning: false, startX: 0, startY: 0, isPanning: false, startX: 0, startY: 0,
@ -579,15 +590,25 @@
motionEnabled: saved?.motionEnabled || false, motionEnabled: saved?.motionEnabled || false,
motionSensitivity: saved?.motionSensitivity || 50, motionSensitivity: saved?.motionSensitivity || 50,
lastMotionTime: 0, lastMotionTime: 0,
isActive: true // NEW: Flag to control the fetch loop isActive: true, // NEW: Flag to control the fetch loop
// Timer properties
timerMinutes: saved?.timerMinutes || 30,
timerSeconds: saved?.timerSeconds || 0,
timerActive: saved?.timerActive || false,
timerInterval: null
}; };
container._zoomPanState = state; // Attach state to the DOM element container._zoomPanState = state; // Attach state to the DOM element
debugLog(`[${container.id}] Initializing. MotionEnabled: ${state.motionEnabled}, Sensitivity: ${state.motionSensitivity}, IsActive: ${state.isActive}`); debugLog(`[${container.id}] Initializing. MotionEnabled: ${state.motionEnabled}, Sensitivity: ${state.motionSensitivity}, IsActive: ${state.isActive}`);
motionCheckbox.checked = state.motionEnabled; motionCheckbox.checked = state.motionEnabled;
motionSlider.value = state.motionSensitivity; motionSlider.value = state.motionSensitivity;
if (state.motionEnabled) motionSlider.classList.add('visible'); if (state.motionEnabled) {
motionSlider.classList.add('visible');
timerControls.classList.add('visible');
}
// Initialize timer display
updateTimerDisplay();
motionCheckbox.addEventListener('change', () => { motionCheckbox.addEventListener('change', () => {
initAudio(); // Ensure audio is ready initAudio(); // Ensure audio is ready
@ -595,14 +616,21 @@
debugLog(`[${container.id}] Checkbox changed. Old motionEnabled: ${state.motionEnabled}, New: ${newMotionEnabledState}`); debugLog(`[${container.id}] Checkbox changed. Old motionEnabled: ${state.motionEnabled}, New: ${newMotionEnabledState}`);
state.motionEnabled = newMotionEnabledState; state.motionEnabled = newMotionEnabledState;
motionSlider.classList.toggle('visible', state.motionEnabled); motionSlider.classList.toggle('visible', state.motionEnabled);
timerControls.classList.toggle('visible', state.motionEnabled);
// Reset prevImageLoaded whenever motion is toggled on OR off // 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.`); debugLog(`[${container.id}] Resetting prevImageLoaded to false due to checkbox change.`);
prevImageLoaded = false; prevImageLoaded = false;
if (!state.motionEnabled) { if (!state.motionEnabled) {
motionIndicator.style.display = 'none'; motionIndicator.style.display = 'none';
// Stop timer if motion is disabled
if (state.timerInterval) {
clearInterval(state.timerInterval);
state.timerInterval = null;
state.timerActive = false;
updateTimerDisplay();
}
} }
saveState(); saveState();
}); });
@ -611,16 +639,108 @@
state.motionSensitivity = parseInt(motionSlider.value, 10); state.motionSensitivity = parseInt(motionSlider.value, 10);
// No saveState() on input for performance, only on change // No saveState() on input for performance, only on change
}); });
motionSlider.addEventListener('change', () => { motionSlider.addEventListener('change', () => {
state.motionSensitivity = parseInt(motionSlider.value, 10); state.motionSensitivity = parseInt(motionSlider.value, 10);
debugLog(`[${container.id}] Sensitivity changed to ${state.motionSensitivity}`); debugLog(`[${container.id}] Sensitivity changed to ${state.motionSensitivity}`);
saveState(); saveState();
}); });
// Timer event listeners
timerInput.value = state.timerMinutes;
timerInput.addEventListener('change', () => {
const value = parseInt(timerInput.value, 10);
if (value >= 1 && value <= 120) {
state.timerMinutes = value;
state.timerSeconds = 0;
updateTimerDisplay();
saveState();
} else {
timerInput.value = state.timerMinutes; // Reset to valid value
}
});
timerStart.addEventListener('click', () => {
if (!state.timerActive) {
const minutes = parseInt(timerInput.value, 10);
if (minutes >= 1 && minutes <= 120) {
state.timerMinutes = minutes;
state.timerSeconds = 0;
startTimer();
saveState();
}
}
});
timerStop.addEventListener('click', () => {
if (state.timerInterval) {
clearInterval(state.timerInterval);
state.timerInterval = null;
}
state.timerActive = false;
updateTimerDisplay();
saveState();
});
function startTimer() {
if (state.timerInterval) {
clearInterval(state.timerInterval);
}
state.timerActive = true;
updateTimerDisplay();
state.timerInterval = setInterval(() => {
if (state.timerSeconds > 0) {
state.timerSeconds--;
} else if (state.timerMinutes > 0) {
state.timerMinutes--;
state.timerSeconds = 59;
} else {
// Timer reached zero
clearInterval(state.timerInterval);
state.timerInterval = null;
state.timerActive = false;
// Turn off motion detection
state.motionEnabled = false;
motionCheckbox.checked = false;
motionSlider.classList.remove('visible');
timerControls.classList.remove('visible');
motionIndicator.style.display = 'none';
// Play warning sound
playBeep(container.id, true);
debugLog(`[${container.id}] Timer expired. Motion detection disabled.`);
saveState();
}
updateTimerDisplay();
}, 1000);
}
function updateTimerDisplay() {
const minutes = String(state.timerMinutes).padStart(2, '0');
const seconds = String(state.timerSeconds).padStart(2, '0');
timerDisplay.textContent = `${minutes}:${seconds}`;
// Change color when less than 1 minute remaining
if (state.timerActive && state.timerMinutes === 0 && state.timerSeconds < 60) {
timerDisplay.style.color = '#ff0000';
} else {
timerDisplay.style.color = '#ff9900';
}
}
// If timer was active when saved, restart it
if (state.timerActive) {
startTimer();
}
interact(container) interact(container)
.draggable({ /* ... listeners ... */ }) // Keep existing interactjs setup .draggable({ /* ... listeners ... */ }) // Keep existing interactjs setup
.resizable({ /* ... 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) ... // ... (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 // The following are copy-pasted but should be verified if you made changes there not shown in the original problem
interact(container) interact(container)
@ -658,7 +778,6 @@
end() { if (state.isActive) saveState(); } end() { if (state.isActive) saveState(); }
} }
}); });
inner.addEventListener('wheel', e => { inner.addEventListener('wheel', e => {
if (!isShiftPressed || !state.isActive) return; if (!isShiftPressed || !state.isActive) return;
e.preventDefault(); e.preventDefault();
@ -674,7 +793,6 @@
updateImageTransform(img, state); updateImageTransform(img, state);
// No saveState() on wheel, usually saved on mouseup after pan or resize end // No saveState() on wheel, usually saved on mouseup after pan or resize end
}); });
inner.addEventListener('mousedown', e => { inner.addEventListener('mousedown', e => {
if (!isShiftPressed || !state.isActive) return; if (!isShiftPressed || !state.isActive) return;
e.preventDefault(); e.preventDefault();
@ -683,7 +801,6 @@
state.startY = e.clientY - state.panY; state.startY = e.clientY - state.panY;
inner.style.cursor = 'grabbing'; inner.style.cursor = 'grabbing';
}); });
window.addEventListener('mousemove', e => { // Global listener window.addEventListener('mousemove', e => { // Global listener
if (state.isPanning && state.isActive) { // Check state.isActive here too if (state.isPanning && state.isActive) { // Check state.isActive here too
state.panX = e.clientX - state.startX; state.panX = e.clientX - state.startX;
@ -692,7 +809,6 @@
updateImageTransform(img, state); updateImageTransform(img, state);
} }
}); });
window.addEventListener('mouseup', () => { // Global listener window.addEventListener('mouseup', () => { // Global listener
if (state.isPanning && state.isActive) { // Check state.isActive if (state.isPanning && state.isActive) { // Check state.isActive
state.isPanning = false; state.isPanning = false;
@ -700,7 +816,6 @@
saveState(); saveState();
} }
}); });
function clampPan() { function clampPan() {
if (!state.imgNaturalWidth || !state.imgNaturalHeight) return; // Avoid division by zero if image not loaded if (!state.imgNaturalWidth || !state.imgNaturalHeight) return; // Avoid division by zero if image not loaded
const cw = container.clientWidth, ch = container.clientHeight; const cw = container.clientWidth, ch = container.clientHeight;
@ -709,16 +824,13 @@
// Allow image to be smaller than container if zoomed out // Allow image to be smaller than container if zoomed out
const maxX = Math.max(0, iw - cw); const maxX = Math.max(0, iw - cw);
const maxY = Math.max(0, ih - ch); const maxY = Math.max(0, ih - ch);
state.panX = Math.max(Math.min(0, state.panX), cw - iw); state.panX = Math.max(Math.min(0, state.panX), cw - iw);
state.panY = Math.max(Math.min(0, state.panY), ch - ih); 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 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 (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)); if (ih < ch) state.panY = (ch - ih) / 2; else state.panY = Math.max(ch - ih, Math.min(0, state.panY));
} }
function updateImageTransform(image, s) { function updateImageTransform(image, s) {
image.style.left = s.panX + 'px'; image.style.left = s.panX + 'px';
image.style.top = s.panY + 'px'; image.style.top = s.panY + 'px';
@ -727,22 +839,18 @@
image.style.height = s.imgNaturalHeight ? `${s.imgNaturalHeight}px` : 'auto'; image.style.height = s.imgNaturalHeight ? `${s.imgNaturalHeight}px` : 'auto';
} }
container.addEventListener('mousedown', () => { container.addEventListener('mousedown', () => {
if (!state.isActive) return; if (!state.isActive) return;
document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active')); document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active'));
container.classList.add('active'); container.classList.add('active');
}); });
let fetchTimeoutId = null; // To clear timeout if container becomes inactive let fetchTimeoutId = null; // To clear timeout if container becomes inactive
function fetchNextFrame() { function fetchNextFrame() {
if (!state.isActive) { if (!state.isActive) {
debugLog(`[${container.id}] fetchNextFrame: state.isActive is false. Stopping loop.`); debugLog(`[${container.id}] fetchNextFrame: state.isActive is false. Stopping loop.`);
if (fetchTimeoutId) clearTimeout(fetchTimeoutId); if (fetchTimeoutId) clearTimeout(fetchTimeoutId);
return; return;
} }
const imgUrl = `${src}?t=${Date.now()}`; const imgUrl = `${src}?t=${Date.now()}`;
let requestTimedOut = false; // Renamed from timedOut to avoid conflict let requestTimedOut = false; // Renamed from timedOut to avoid conflict
const requestTimeoutId = setTimeout(() => { const requestTimeoutId = setTimeout(() => {
@ -750,7 +858,6 @@
debugLog(`[${container.id}] Image request timed out for ${imgUrl}. Retrying.`); debugLog(`[${container.id}] Image request timed out for ${imgUrl}. Retrying.`);
if (state.isActive) fetchNextFrame(); // Retry only if still active if (state.isActive) fetchNextFrame(); // Retry only if still active
}, 4000); }, 4000);
const tempImg = new Image(); const tempImg = new Image();
tempImg.crossOrigin = "Anonymous"; tempImg.crossOrigin = "Anonymous";
tempImg.onload = () => { tempImg.onload = () => {
@ -762,18 +869,18 @@
} }
clearTimeout(requestTimeoutId); clearTimeout(requestTimeoutId);
img.src = tempImg.src; // Update the visible image img.src = tempImg.src; // Update the visible image
const existingOverlay = container.querySelector('.error-overlay'); const existingOverlay = container.querySelector('.error-overlay');
if (existingOverlay) existingOverlay.remove(); if (existingOverlay) existingOverlay.remove();
state.imgNaturalWidth = tempImg.naturalWidth; state.imgNaturalWidth = tempImg.naturalWidth;
state.imgNaturalHeight = tempImg.naturalHeight; state.imgNaturalHeight = tempImg.naturalHeight;
if (state.motionEnabled && state.imgNaturalWidth > 0 && state.isActive) { if (state.motionEnabled && state.imgNaturalWidth > 0 && state.isActive) {
// debugLog(`[${container.id}] Motion detection ACTIVE. prevImageLoaded=${prevImageLoaded}.`); // Spammy // debugLog(`[${container.id}] Motion detection ACTIVE. prevImageLoaded=${prevImageLoaded}.`); // Spammy
ctx1.drawImage(canvas2, 0, 0, motionWidth, motionHeight); // Old canvas2 -> canvas1 ctx1.drawImage(canvas2, 0, 0, motionWidth, motionHeight); // Old canvas2 -> canvas1
ctx2.drawImage(tempImg, 0, 0, motionWidth, motionHeight); // New image -> canvas2 const sx = -state.panX / state.scale;
const sy = -state.panY / state.scale;
const sw = container.clientWidth / state.scale;
const sh = container.clientHeight / state.scale;
ctx2.drawImage(tempImg, sx, sy, sw, sh, 0, 0, motionWidth, motionHeight);
if (prevImageLoaded) { if (prevImageLoaded) {
// debugLog(`[${container.id}] Comparing frames.`); // Spammy // debugLog(`[${container.id}] Comparing frames.`); // Spammy
if (compareFrames(ctx1, ctx2, motionWidth, motionHeight, state.motionSensitivity, container.id)) { if (compareFrames(ctx1, ctx2, motionWidth, motionHeight, state.motionSensitivity, container.id)) {
@ -787,11 +894,9 @@
prevImageLoaded = true; prevImageLoaded = true;
} }
} }
if (motionIndicator.style.display === 'block' && (Date.now() - state.lastMotionTime > 500)) { if (motionIndicator.style.display === 'block' && (Date.now() - state.lastMotionTime > 500)) {
motionIndicator.style.display = 'none'; motionIndicator.style.display = 'none';
} }
if (!state.initialized && state.imgNaturalWidth > 0) { if (!state.initialized && state.imgNaturalWidth > 0) {
debugLog(`[${container.id}] First successful image load. Initializing scale and pan.`); debugLog(`[${container.id}] First successful image load. Initializing scale and pan.`);
const scaleX = container.clientWidth / state.imgNaturalWidth; const scaleX = container.clientWidth / state.imgNaturalWidth;
@ -806,7 +911,6 @@
updateImageTransform(img, state); // Just update if already initialized updateImageTransform(img, state); // Just update if already initialized
} }
if (state.isActive) { // Schedule next fetch only if still active if (state.isActive) { // Schedule next fetch only if still active
fetchTimeoutId = setTimeout(fetchNextFrame, 600); fetchTimeoutId = setTimeout(fetchNextFrame, 600);
} }
@ -833,14 +937,11 @@
}; };
tempImg.src = imgUrl; tempImg.src = imgUrl;
} }
fetchNextFrame(); // Start the loop fetchNextFrame(); // Start the loop
} }
// Initial load of state from localStorage when the page loads // Initial load of state from localStorage when the page loads
const startupConfig = getQueryParam('config'); const startupConfig = getQueryParam('config');
loadState(startupConfig); loadState(startupConfig);
</script> </script>
</body> </body>
</html> </html>