export function createCameraPreview({ root, baseUrl, getState, onAvailable }) { const cameras = new Map(); let enabled = false; let timer = null; function sources() { const { state, receivedAt } = getState(); if (!enabled || Date.now() - receivedAt >= 3000) return []; const list = Array.isArray(state?.cameras) ? state.cameras : state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : []; const seen = new Set(); return list.filter(item => { if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false; seen.add(item.id); return true; }).slice(0, 8); } function clearFrame(camera) { if (camera.url) URL.revokeObjectURL(camera.url); camera.url = null; camera.image.removeAttribute('src'); } function remove(id, camera) { cameras.delete(id); camera.controller?.abort(); clearFrame(camera); camera.element.remove(); } async function frame(id, camera) { if (camera.controller || Date.now() < camera.retryAt || document.hidden) return; const controller = new AbortController(); camera.controller = controller; const timeout = window.setTimeout(() => controller.abort(), 2000); try { const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`; const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal }); if (!response.ok) throw new Error('Camera frame unavailable'); const blob = await response.blob(); if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return; const previous = camera.url; camera.url = URL.createObjectURL(blob); camera.image.src = camera.url; camera.retryAt = 0; if (previous) URL.revokeObjectURL(previous); } catch (_) { if (cameras.get(id) === camera) { camera.retryAt = Date.now() + 5000; clearFrame(camera); } } finally { window.clearTimeout(timeout); if (camera.controller === controller) camera.controller = null; } } function refresh() { const live = sources(); const ids = new Set(live.map(item => item.id)); for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera); for (const item of live) { let camera = cameras.get(item.id); if (!camera) { const element = document.createElement('div'); element.className = 'camera-preview-item'; element.dataset.cameraId = item.id; const label = document.createElement('div'); label.className = 'camera-preview-label'; const image = document.createElement('img'); image.className = 'camera-preview-image'; element.append(label, image); root.append(element); camera = { element, label, image, url: null, controller: null, retryAt: 0 }; image.addEventListener('error', () => { if (cameras.get(item.id) === camera) { clearFrame(camera); camera.retryAt = Date.now() + 5000; } }); cameras.set(item.id, camera); } camera.label.textContent = item.name || item.id; camera.image.alt = item.name || item.id; void frame(item.id, camera); } onAvailable(enabled && cameras.size > 0); } function visibilityChanged() { if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort(); else refresh(); } document.addEventListener('visibilitychange', visibilityChanged); function setEnabled(value) { enabled = value; if (timer) window.clearInterval(timer); timer = null; refresh(); if (enabled) timer = window.setInterval(refresh, 200); } return { setEnabled, refresh, dispose() { setEnabled(false); document.removeEventListener('visibilitychange', visibilityChanged); }, }; }