claude: webcam
This commit is contained in:
@@ -6,51 +6,32 @@
|
||||
<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;
|
||||
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;
|
||||
}
|
||||
#cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
||||
|
||||
.cam-box {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.cam-box video { display: block; }
|
||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; }
|
||||
|
||||
/* go2rtc Web-Component */
|
||||
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
||||
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.cam-label {
|
||||
position: absolute; top: 5px; left: 8px;
|
||||
background: rgba(0,0,0,.65);
|
||||
padding: 2px 7px; border-radius: 3px;
|
||||
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 { 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;
|
||||
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); }
|
||||
@@ -62,6 +43,9 @@
|
||||
<span id="statusText">Verbinde...</span>
|
||||
</header>
|
||||
<div id="cameras"></div>
|
||||
<script src="viewer.js"></script>
|
||||
|
||||
<!-- go2rtc's offizieller Player (über Node-Proxy von go2rtc geladen) -->
|
||||
<script type="module" src="/video-stream.js"></script>
|
||||
<script src="viewer.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
169
public/viewer.js
169
public/viewer.js
@@ -1,126 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const ICE_SERVERS = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
|
||||
function log(camId, msg) {
|
||||
console.log(`[${camId}] ${msg}`);
|
||||
}
|
||||
|
||||
function waitIceComplete(pc, timeoutMs = 5000) {
|
||||
return new Promise(resolve => {
|
||||
if (pc.iceGatheringState === 'complete') { resolve(); return; }
|
||||
const check = () => { if (pc.iceGatheringState === 'complete') resolve(); };
|
||||
pc.addEventListener('icegatheringstatechange', check);
|
||||
setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', check);
|
||||
log('ice', `gathering timeout nach ${timeoutMs}ms – sende trotzdem`);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function startWebRTC(camId, videoEl, statusEl) {
|
||||
setStatus(statusEl, 'Verbinde...', '#888');
|
||||
log(camId, `WebRTC start → /api/webrtc?src=${camId}`);
|
||||
|
||||
let pc;
|
||||
try {
|
||||
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
|
||||
pc.onicecandidate = ({ candidate }) => {
|
||||
if (candidate) log(camId, `ICE candidate: ${candidate.type} ${candidate.address ?? '?'}`);
|
||||
};
|
||||
|
||||
pc.onicegatheringstatechange = () =>
|
||||
log(camId, `ICE gathering: ${pc.iceGatheringState}`);
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const s = pc.iceConnectionState;
|
||||
log(camId, `ICE connection: ${s}`);
|
||||
const colors = { connected: '#4c4', completed: '#4c4', checking: '#fa0', failed: '#c44', disconnected: '#c44' };
|
||||
setStatus(statusEl, s, colors[s] ?? '#888');
|
||||
if (s === 'failed' || s === 'closed') {
|
||||
pc.close();
|
||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () =>
|
||||
log(camId, `connection: ${pc.connectionState}`);
|
||||
|
||||
pc.ontrack = ({ streams }) => {
|
||||
log(camId, `Track erhalten: ${streams.length} stream(s)`);
|
||||
if (streams[0]) {
|
||||
videoEl.srcObject = streams[0];
|
||||
videoEl.play().catch(e => log(camId, `play() Fehler: ${e.message}`));
|
||||
setStatus(statusEl, 'Live ✓', '#4c4');
|
||||
}
|
||||
};
|
||||
|
||||
log(camId, 'Erstelle SDP Offer...');
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
log(camId, `ICE gathering wartet (max 5s)...`);
|
||||
await waitIceComplete(pc);
|
||||
|
||||
log(camId, `Sende Offer (${pc.localDescription.sdp.length} Bytes) an Server...`);
|
||||
const resp = await fetch(`/api/webrtc?src=${encodeURIComponent(camId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: pc.localDescription.sdp,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Signaling HTTP ${resp.status}: ${body}`);
|
||||
}
|
||||
|
||||
const sdpAnswer = await resp.text();
|
||||
log(camId, `Answer erhalten (${sdpAnswer.length} Bytes)`);
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
log(camId, 'Remote description gesetzt – warte auf ICE...');
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[${camId}] Fehler:`, err);
|
||||
setStatus(statusEl, `${err.message}`, '#c44');
|
||||
pc?.close();
|
||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, text, color) {
|
||||
el.textContent = text;
|
||||
el.style.color = color ?? '#999';
|
||||
}
|
||||
|
||||
function createCameraView(camId, container) {
|
||||
log(camId, 'View erstellt');
|
||||
// go2rtc-Player-Modi in Fallback-Reihenfolge.
|
||||
// webrtc zuerst (niedrigste Latenz), dann MSE, dann MJPEG – das verhindert
|
||||
// schwarze Seiten: schlägt WebRTC fehl, springt der Player automatisch weiter.
|
||||
const MODE = 'webrtc,mse,mjpeg';
|
||||
|
||||
function buildCamera(camId, container) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'cam-box';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.muted = true;
|
||||
video.style.cssText = 'display:block;width:640px;height:480px;background:#111';
|
||||
box.appendChild(video);
|
||||
// go2rtc Web-Component – verbindet sich relativ auf /api/ws (→ Node-Proxy)
|
||||
const stream = document.createElement('video-stream');
|
||||
stream.mode = MODE;
|
||||
stream.src = `/api/ws?src=${encodeURIComponent(camId)}`;
|
||||
box.appendChild(stream);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'cam-label';
|
||||
label.textContent = camId;
|
||||
box.appendChild(label);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'cam-info';
|
||||
box.appendChild(status);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'cam-actions';
|
||||
const snapBtn = document.createElement('button');
|
||||
@@ -135,32 +34,28 @@ function createCameraView(camId, container) {
|
||||
box.appendChild(actions);
|
||||
|
||||
container.appendChild(box);
|
||||
startWebRTC(camId, video, status);
|
||||
}
|
||||
|
||||
// Kamera-Liste via /api/snapshot (proxied go2rtc /api/streams)
|
||||
log('init', 'Frage Kamera-Liste ab...');
|
||||
fetch('/api/snapshot')
|
||||
.then(r => {
|
||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
log('init', `Kameras: ${JSON.stringify(data.cameras)}`);
|
||||
const container = document.getElementById('cameras');
|
||||
const cams = data.cameras ?? [];
|
||||
if (cams.length === 0) {
|
||||
document.getElementById('statusText').textContent = 'Keine Kameras (go2rtc läuft?)';
|
||||
console.warn('go2rtc meldet keine Streams. Prüfe http://server:1984');
|
||||
return;
|
||||
async function init() {
|
||||
// Warten bis die go2rtc-Web-Component definiert ist (sonst greift der .src-Setter nicht)
|
||||
await customElements.whenDefined('video-stream');
|
||||
|
||||
const container = document.getElementById('cameras');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
let cams = ['cam0', 'cam1']; // Fallback
|
||||
try {
|
||||
const r = await fetch('/api/snapshot');
|
||||
const d = await r.json();
|
||||
if (Array.isArray(d.cameras) && d.cameras.length) {
|
||||
cams = d.cameras.map(c => c.id);
|
||||
}
|
||||
cams.forEach(c => createCameraView(c.id, container));
|
||||
document.getElementById('statusText').textContent =
|
||||
`${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[init] /api/snapshot Fehler:', err);
|
||||
document.getElementById('statusText').textContent = 'API-Fehler – Fallback';
|
||||
const container = document.getElementById('cameras');
|
||||
['cam0', 'cam1'].forEach(id => createCameraView(id, container));
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Kamera-Liste nicht abrufbar, nutze Fallback:', err.message);
|
||||
}
|
||||
|
||||
cams.forEach(id => buildCamera(id, container));
|
||||
statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user