kopia lustrzana https://github.com/alanesq/esp32cam-demo
Update multiCameraViewer.htm
rodzic
b0937eae7c
commit
2436ff6da4
|
@ -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>
|
||||||
|
|
Ładowanie…
Reference in New Issue