diff --git a/cameras/app.js b/cameras/app.js new file mode 100644 index 0000000..baef0c1 --- /dev/null +++ b/cameras/app.js @@ -0,0 +1,402 @@ +'use strict'; + +// ── Config (persisted in localStorage) ────────────────────────────────────── + +const STORAGE_KEY = 'cam_dashboard_config'; + +const DEFAULT_CONFIG = { + host: 'localhost:1984', + cameras: [], +}; + +function loadConfig() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? Object.assign({}, DEFAULT_CONFIG, JSON.parse(raw)) : Object.assign({}, DEFAULT_CONFIG); + } catch { + return Object.assign({}, DEFAULT_CONFIG); + } +} + +function saveConfig(cfg) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); +} + +let config = loadConfig(); + +// ── Active WebRTC connections { [cameraIndex]: RTCPeerConnection } ────────── + +const connections = {}; + +// ── WebRTC via go2rtc ──────────────────────────────────────────────────────── +// +// go2rtc REST WebRTC API: +// POST http://{host}/api/webrtc?src={streamName} +// Body: SDP offer (plain text) +// Response: SDP answer (plain text) +// +// go2rtc uses "ICE Complete" mode, so we must wait for all local ICE +// candidates to be gathered before POSTing the offer. + +async function waitForIceGathering(pc, timeoutMs = 3000) { + return new Promise((resolve) => { + if (pc.iceGatheringState === 'complete') { resolve(); return; } + const timer = setTimeout(resolve, timeoutMs); + function handler() { + if (pc.iceGatheringState === 'complete') { + clearTimeout(timer); + pc.removeEventListener('icegatheringstatechange', handler); + resolve(); + } + } + pc.addEventListener('icegatheringstatechange', handler); + }); +} + +async function connectCamera(index) { + const cam = config.cameras[index]; + if (!cam) return; + + // Clean up any prior connection for this slot + disconnectCamera(index, /* silent */ true); + + const tile = document.getElementById('tile-' + index); + if (!tile) return; + + setTileStatus(tile, 'connecting', 'Connecting\u2026'); + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + connections[index] = pc; + + // Display the stream once tracks arrive + pc.addEventListener('track', (e) => { + if (!e.streams[0]) return; + const video = tile.querySelector('video'); + const placeholder = tile.querySelector('.tile-placeholder'); + video.srcObject = e.streams[0]; + video.play().catch(() => {}); + if (placeholder) placeholder.style.display = 'none'; + setTileStatus(tile, 'live', '\u25cf LIVE'); + updateConnectBtn(tile, index, true); + updateGlobalStatus(); + }); + + // Handle unexpected disconnection + pc.addEventListener('iceconnectionstatechange', () => { + if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'closed') { + onCameraLost(index, tile, 'Connection lost'); + } + }); + + // Receive-only transceivers (Wyze cams transmit video + audio) + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await waitForIceGathering(pc); + + const protocol = location.protocol === 'https:' ? 'https:' : 'http:'; + const url = protocol + '//' + config.host + '/api/webrtc?src=' + encodeURIComponent(cam.stream); + + const resp = await fetch(url, { + method: 'POST', + body: pc.localDescription.sdp, + }); + + if (!resp.ok) { + throw new Error('go2rtc responded with HTTP ' + resp.status + '. Check that the stream name matches your go2rtc config.'); + } + + const answerSdp = await resp.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + + } catch (err) { + console.error('[Camera ' + index + '] connect error:', err); + pc.close(); + delete connections[index]; + onCameraLost(index, tile, err.message); + } +} + +function disconnectCamera(index, silent) { + const pc = connections[index]; + if (pc) { + pc.close(); + delete connections[index]; + } + if (!silent) { + const tile = document.getElementById('tile-' + index); + if (tile) { + const video = tile.querySelector('video'); + const placeholder = tile.querySelector('.tile-placeholder'); + if (video) video.srcObject = null; + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.querySelector('p').textContent = 'Not connected'; + } + setTileStatus(tile, 'idle', 'Disconnected'); + updateConnectBtn(tile, index, false); + } + updateGlobalStatus(); + } +} + +function onCameraLost(index, tile, message) { + delete connections[index]; + const video = tile && tile.querySelector('video'); + const placeholder = tile && tile.querySelector('.tile-placeholder'); + if (video) video.srcObject = null; + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.querySelector('p').textContent = message || 'Connection lost'; + } + if (tile) { + setTileStatus(tile, 'error', '\u26a0 Error'); + updateConnectBtn(tile, index, false); + } + updateGlobalStatus(); +} + +// ── Status helpers ─────────────────────────────────────────────────────────── + +function setTileStatus(tile, state, text) { + const el = tile.querySelector('.tile-status'); + if (!el) return; + el.className = 'tile-status ' + state; + el.textContent = text; +} + +function updateConnectBtn(tile, index, isConnected) { + const btn = tile.querySelector('[data-action="toggle"]'); + if (!btn) return; + btn.title = isConnected ? 'Disconnect' : 'Connect'; + btn.textContent = isConnected ? '\u23f9' : '\u25b6'; +} + +function updateGlobalStatus() { + const el = document.getElementById('globalStatus'); + const total = config.cameras.length; + const live = Object.keys(connections).length; + if (total === 0) { + el.className = 'connection-status off'; + el.textContent = '\u25cf No cameras'; + } else if (live === 0) { + el.className = 'connection-status off'; + el.textContent = '\u25cf Disconnected'; + } else if (live < total) { + el.className = 'connection-status partial'; + el.textContent = '\u25d1 ' + live + '/' + total + ' connected'; + } else { + el.className = 'connection-status live'; + el.textContent = '\u25cf ' + live + '/' + total + ' live'; + } +} + +// ── Grid rendering ─────────────────────────────────────────────────────────── + +let currentLayout = 1; + +function renderGrid() { + const grid = document.getElementById('cameraGrid'); + grid.innerHTML = ''; + grid.className = 'camera-grid layout-' + currentLayout; + + if (config.cameras.length === 0) { + const addTile = document.createElement('div'); + addTile.className = 'add-tile'; + addTile.innerHTML = '
Add a camera to get started
'; + addTile.addEventListener('click', openSettings); + grid.appendChild(addTile); + } else { + config.cameras.forEach((cam, i) => grid.appendChild(buildTile(cam, i))); + } + updateGlobalStatus(); +} + +function buildTile(cam, index) { + const tile = document.createElement('div'); + tile.className = 'camera-tile'; + tile.id = 'tile-' + index; + + tile.innerHTML = + 'Not connected
' + + '