WebCam als schlanke alternative zum appVideoControl
This commit is contained in:
67
public/index.html
Normal file
67
public/index.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AppRobotWebcam</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body { background: #0f0f0f; color: #e0e0e0; font-family: monospace; }
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
||||
#statusText { font-size: 0.8rem; color: #888; margin-left: auto; }
|
||||
|
||||
#cameras {
|
||||
display: flex; flex-wrap: wrap; gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cam-box {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.cam-box canvas { display: block; }
|
||||
|
||||
.cam-label {
|
||||
position: absolute; top: 5px; left: 8px;
|
||||
background: rgba(0,0,0,.65);
|
||||
padding: 2px 7px; border-radius: 3px;
|
||||
font-size: 0.72rem; color: #ccc;
|
||||
}
|
||||
.cam-info {
|
||||
position: absolute; bottom: 5px; right: 8px;
|
||||
background: rgba(0,0,0,.65);
|
||||
padding: 2px 7px; border-radius: 3px;
|
||||
font-size: 0.68rem; color: #999;
|
||||
}
|
||||
|
||||
.cam-actions {
|
||||
position: absolute; top: 5px; right: 8px;
|
||||
display: flex; gap: 4px;
|
||||
}
|
||||
.cam-actions button {
|
||||
background: rgba(0,0,0,.65); color: #ccc;
|
||||
border: 1px solid #444; padding: 2px 8px;
|
||||
font-family: monospace; font-size: 0.7rem;
|
||||
cursor: pointer; border-radius: 3px;
|
||||
}
|
||||
.cam-actions button:hover { background: rgba(60,60,60,.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AppRobotWebcam</h1>
|
||||
<span id="statusText">Verbinde...</span>
|
||||
</header>
|
||||
<div id="cameras"></div>
|
||||
<script src="viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
110
public/viewer.js
Normal file
110
public/viewer.js
Normal file
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
const WS_RECONNECT_MS = 2000;
|
||||
|
||||
function createCameraView(idx, container) {
|
||||
// DOM
|
||||
const box = document.createElement('div');
|
||||
box.className = 'cam-box';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
box.appendChild(canvas);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'cam-label';
|
||||
label.textContent = `cam${idx}`;
|
||||
box.appendChild(label);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'cam-info';
|
||||
info.textContent = 'Verbinde...';
|
||||
box.appendChild(info);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'cam-actions';
|
||||
const snapBtn = document.createElement('button');
|
||||
snapBtn.textContent = 'Snapshot';
|
||||
actions.appendChild(snapBtn);
|
||||
box.appendChild(actions);
|
||||
|
||||
container.appendChild(box);
|
||||
|
||||
// Snapshot download
|
||||
snapBtn.addEventListener('click', () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/snapshot/cam${idx}`;
|
||||
a.download = `cam${idx}_${Date.now()}.jpg`;
|
||||
a.click();
|
||||
});
|
||||
|
||||
// Rendering
|
||||
const ctx = canvas.getContext('2d');
|
||||
let frameCount = 0;
|
||||
let lastFpsTs = Date.now();
|
||||
let fps = 0;
|
||||
|
||||
function drawFrame(arrayBuffer) {
|
||||
const blob = new Blob([arrayBuffer], { type: 'image/jpeg' });
|
||||
createImageBitmap(blob)
|
||||
.then((bmp) => {
|
||||
if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
|
||||
canvas.width = bmp.width;
|
||||
canvas.height = bmp.height;
|
||||
}
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
bmp.close();
|
||||
|
||||
frameCount++;
|
||||
const now = Date.now();
|
||||
if (now - lastFpsTs >= 1000) {
|
||||
fps = Math.round(frameCount * 1000 / (now - lastFpsTs));
|
||||
frameCount = 0;
|
||||
lastFpsTs = now;
|
||||
info.textContent = `${fps} fps`;
|
||||
}
|
||||
})
|
||||
.catch(() => {/* ignore decode errors */});
|
||||
}
|
||||
|
||||
// WebSocket connection with auto-reconnect
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/cam${idx}`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => { info.textContent = 'Verbunden'; };
|
||||
ws.onclose = () => {
|
||||
info.textContent = `Getrennt – neu in ${WS_RECONNECT_MS / 1000}s`;
|
||||
setTimeout(connect, WS_RECONNECT_MS);
|
||||
};
|
||||
ws.onerror = () => { info.textContent = 'Verbindungsfehler'; };
|
||||
ws.onmessage = (evt) => drawFrame(evt.data);
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
// Fetch camera list from server, then build one view per camera
|
||||
fetch('/api/snapshot')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('cameras');
|
||||
const count = data.cameras?.length ?? 0;
|
||||
|
||||
if (count === 0) {
|
||||
document.getElementById('statusText').textContent = 'Keine Kameras erkannt';
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) createCameraView(i, container);
|
||||
document.getElementById('statusText').textContent =
|
||||
`${count} Kamera${count !== 1 ? 's' : ''} erkannt`;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: show 2 cameras if API fails
|
||||
const container = document.getElementById('cameras');
|
||||
for (let i = 0; i < 2; i++) createCameraView(i, container);
|
||||
document.getElementById('statusText').textContent = 'Kamera-API nicht erreichbar';
|
||||
});
|
||||
Reference in New Issue
Block a user