kopia lustrzana https://github.com/alanesq/esp32cam-demo
View multiple esp32cams with zoom facility
rodzic
de4b090934
commit
187b9ffae1
|
@ -0,0 +1,276 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Display all cameras with resize and zoom
|
||||||
|
Hold shift to zoom/pan the image
|
||||||
|
|
||||||
|
enter camera IP details under "const cameras"
|
||||||
|
|
||||||
|
16May25
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Camera Viewer Shift-Flipped</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #111;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
#toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
#toolbar button {
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.img-container {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid #444;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 1;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.img-container.active {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.img-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
cursor: grab;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.img-inner img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: red;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="toolbar"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let isShiftPressed = false;
|
||||||
|
window.addEventListener('keydown', e => { if (e.key === 'Shift') isShiftPressed = true; });
|
||||||
|
window.addEventListener('keyup', e => { if (e.key === 'Shift') isShiftPressed = false; });
|
||||||
|
window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false });
|
||||||
|
|
||||||
|
// Camera details
|
||||||
|
// Camera should supply a jpg image - for esp32cam see: https://github.com/alanesq/esp32cam-demo
|
||||||
|
const cameras = [
|
||||||
|
{ src: "http://192.168.1.100/jpg", id: "Front" },
|
||||||
|
{ src: "http://192.168.1.110/jpg", id: "Side" },
|
||||||
|
{ src: "http://192.168.1.120/jpg", id: "Back" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolbar = document.getElementById('toolbar');
|
||||||
|
cameras.forEach(cam => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = cam.id;
|
||||||
|
btn.onclick = () => createCameraWindow(cam);
|
||||||
|
toolbar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createCameraWindow(cam) {
|
||||||
|
if (document.getElementById(cam.id)) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'img-container';
|
||||||
|
div.id = cam.id;
|
||||||
|
div.style = 'left:10px; top:60px; width:320px; height:240px;';
|
||||||
|
div.innerHTML = `
|
||||||
|
<button class="close-btn" onclick="this.parentElement.remove()">×</button>
|
||||||
|
<div class="img-inner"><img /></div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
initContainer(div, cam.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initContainer(container, src) {
|
||||||
|
const inner = container.querySelector('.img-inner');
|
||||||
|
const img = inner.querySelector('img');
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
scale: 1,
|
||||||
|
panX: 0,
|
||||||
|
panY: 0,
|
||||||
|
isPanning: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
imgNaturalWidth: 0,
|
||||||
|
imgNaturalHeight: 0,
|
||||||
|
};
|
||||||
|
container._zoomPanState = state;
|
||||||
|
|
||||||
|
interact(container)
|
||||||
|
.draggable({
|
||||||
|
listeners: {
|
||||||
|
start(event) { if (isShiftPressed) event.interaction.stop(); },
|
||||||
|
move(event) {
|
||||||
|
if (isShiftPressed) return;
|
||||||
|
container.style.left = (parseFloat(container.style.left) || 0) + event.dx + 'px';
|
||||||
|
container.style.top = (parseFloat(container.style.top) || 0) + event.dy + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.resizable({
|
||||||
|
edges: { left: true, right: true, bottom: true, top: true },
|
||||||
|
modifiers: [
|
||||||
|
interact.modifiers.aspectRatio({ ratio: 4 / 3 }),
|
||||||
|
interact.modifiers.restrictSize({ min: { width: 100, height: 75 } })
|
||||||
|
],
|
||||||
|
listeners: {
|
||||||
|
move(event) {
|
||||||
|
container.style.width = event.rect.width + 'px';
|
||||||
|
container.style.height = event.rect.height + 'px';
|
||||||
|
const scaleX = container.clientWidth / state.imgNaturalWidth;
|
||||||
|
const scaleY = container.clientHeight / state.imgNaturalHeight;
|
||||||
|
state.scale = Math.min(scaleX, scaleY);
|
||||||
|
state.panX = 0;
|
||||||
|
state.panY = 0;
|
||||||
|
updateImageTransform(img, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.addEventListener('wheel', e => {
|
||||||
|
if (!isShiftPressed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = inner.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
const prevScale = state.scale;
|
||||||
|
state.scale += -e.deltaY * 0.001;
|
||||||
|
state.scale = Math.min(Math.max(0.5, state.scale), 5);
|
||||||
|
state.panX = mouseX - ((mouseX - state.panX) * (state.scale / prevScale));
|
||||||
|
state.panY = mouseY - ((mouseY - state.panY) * (state.scale / prevScale));
|
||||||
|
clampPan();
|
||||||
|
updateImageTransform(img, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.addEventListener('mousedown', e => {
|
||||||
|
if (!isShiftPressed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
state.isPanning = true;
|
||||||
|
state.startX = e.clientX - state.panX;
|
||||||
|
state.startY = e.clientY - state.panY;
|
||||||
|
inner.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
if (!state.isPanning) return;
|
||||||
|
state.panX = e.clientX - state.startX;
|
||||||
|
state.panY = e.clientY - state.startY;
|
||||||
|
clampPan();
|
||||||
|
updateImageTransform(img, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
if (state.isPanning) {
|
||||||
|
state.isPanning = false;
|
||||||
|
inner.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
img.ondragstart = () => false;
|
||||||
|
|
||||||
|
function clampPan() {
|
||||||
|
const cw = container.clientWidth, ch = container.clientHeight;
|
||||||
|
const iw = state.imgNaturalWidth * state.scale;
|
||||||
|
const ih = state.imgNaturalHeight * state.scale;
|
||||||
|
state.panX = Math.max(Math.min(0, cw - iw), state.panX);
|
||||||
|
state.panY = Math.max(Math.min(0, ch - ih), state.panY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageTransform(image, s) {
|
||||||
|
image.style.left = s.panX + 'px';
|
||||||
|
image.style.top = s.panY + 'px';
|
||||||
|
image.style.transform = `scale(${s.scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('mousedown', () => {
|
||||||
|
document.querySelectorAll('.img-container').forEach(c => c.classList.remove('active'));
|
||||||
|
container.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchNextFrame() {
|
||||||
|
const imgUrl = `${src}?t=${Date.now()}`;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
fetchNextFrame();
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
const tempImg = new Image();
|
||||||
|
tempImg.onload = () => {
|
||||||
|
if (timedOut) return;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
img.src = tempImg.src;
|
||||||
|
state.imgNaturalWidth = tempImg.naturalWidth;
|
||||||
|
state.imgNaturalHeight = tempImg.naturalHeight;
|
||||||
|
|
||||||
|
if (!state.initialized) {
|
||||||
|
const scaleX = container.clientWidth / state.imgNaturalWidth;
|
||||||
|
const scaleY = container.clientHeight / state.imgNaturalHeight;
|
||||||
|
state.scale = Math.min(scaleX, scaleY);
|
||||||
|
state.panX = 0;
|
||||||
|
state.panY = 0;
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImageTransform(img, state);
|
||||||
|
setTimeout(fetchNextFrame, 600);
|
||||||
|
};
|
||||||
|
tempImg.onerror = () => {
|
||||||
|
if (!timedOut) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
setTimeout(fetchNextFrame, 600);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tempImg.src = imgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// cameras.forEach(cam => createCameraWindow(cam)); // display cameras at startup
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
Ładowanie…
Reference in New Issue