FusionSystem Control Panel

FusionSystem V2

0 sites Disconnected
No active streams. Select a site and start streaming.
Telemetry
Waiting for data…
No active streams. Start streaming from the control panel.
// --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let apiKey = ''; let ws = null; let lastSnapshot = null; /** @type {Map} */ const hlsPlayers = new Map(); // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- document.getElementById('auth-btn').addEventListener('click', tryConnect); document.getElementById('api-key-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') tryConnect(); }); async function tryConnect() { const key = document.getElementById('api-key-input').value.trim(); if (!key) return; apiKey = key; try { const res = await apiFetch('/api/status'); if (!res.ok) throw new Error(`HTTP ${res.status}`); document.getElementById('auth-overlay').style.display = 'none'; document.getElementById('auth-error').textContent = ''; startTelemetryWS(); } catch (err) { document.getElementById('auth-error').textContent = `Connection failed: ${err.message}`; apiKey = ''; } } // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- function apiFetch(path, method = 'GET', body = null) { const opts = { method, headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, }; if (body) opts.body = JSON.stringify(body); return fetch(BACKEND_BASE + path, opts); } async function apiPost(path) { try { const res = await apiFetch(path, 'POST'); if (!res.ok) console.warn(`POST ${path} →`, res.status); } catch (err) { console.error(`POST ${path} failed:`, err.message); } } // --------------------------------------------------------------------------- // Global stream buttons // --------------------------------------------------------------------------- document.getElementById('btn-stream-start').addEventListener('click', () => apiPost('/api/streaming/start')); document.getElementById('btn-stream-stop').addEventListener('click', () => apiPost('/api/streaming/stop')); // --------------------------------------------------------------------------- // WebSocket telemetry // --------------------------------------------------------------------------- function startTelemetryWS() { setBadge('Connecting…', ''); ws = new WebSocket(BACKEND_WS); ws.addEventListener('open', () => { setBadge('Connected', 'connected'); console.log('[ws] connected'); }); ws.addEventListener('message', (event) => { try { const snap = JSON.parse(event.data); if (snap.type === 'keepalive') return; lastSnapshot = snap; renderSnapshot(snap); } catch (e) { console.warn('[ws] bad JSON:', e.message); } }); ws.addEventListener('close', () => { setBadge('Disconnected', 'error'); console.warn('[ws] disconnected — retrying in 3s'); setTimeout(startTelemetryWS, 3000); }); ws.addEventListener('error', () => ws.close()); } function setBadge(text, cls) { const el = document.getElementById('conn-badge'); el.textContent = text; el.className = cls; } // --------------------------------------------------------------------------- // Render telemetry snapshot // --------------------------------------------------------------------------- function renderSnapshot(snap) { // Raw telemetry box const pretty = JSON.stringify(snap, null, 2); document.getElementById('telemetry-raw').textContent = pretty; // Source sidebar cards renderSourceList(snap.sources || []); // Video grid renderVideoGrid(snap.sources || []); } // --------------------------------------------------------------------------- // Source sidebar // --------------------------------------------------------------------------- function renderSourceList(sources) { const container = document.getElementById('source-list'); const prevCards = new Map([...container.querySelectorAll('.source-card')].map(c => [c.dataset.id, c])); for (const src of sources) { const existing = prevCards.get(src.id); const card = existing || buildSourceCard(src); updateSourceCard(card, src); if (!existing) container.appendChild(card); prevCards.delete(src.id); } // Remove stale cards for (const card of prevCards.values()) card.remove(); } function buildSourceCard(src) { const card = document.createElement('div'); card.className = 'source-card'; card.dataset.id = src.id; card.innerHTML = `
`; card.addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; const id = card.dataset.id; const action = btn.dataset.action; apiPost(`/api/sources/${encodeURIComponent(id)}/${action}`); }); return card; } function updateSourceCard(card, src) { card.classList.toggle('disabled', !src.enabled); const dot = card.querySelector('.dot'); dot.className = 'dot'; if (src.streaming) dot.classList.add('streaming'); else if (src.running) dot.classList.add('running'); card.querySelector('.source-name').textContent = src.name || src.id; const transport = (src.transport || 'udp').toUpperCase(); const port = src.port ? `:${src.port}` : ''; card.querySelector('.source-meta').textContent = `${transport}${port} • ${src.running ? 'running' : 'stopped'} • ${src.enabled ? 'enabled' : 'disabled'}`; } // --------------------------------------------------------------------------- // Video grid — HLS players via hls.js // --------------------------------------------------------------------------- function renderVideoGrid(sources) { const grid = document.getElementById('video-grid'); const noSrc = document.getElementById('no-sources'); const streaming = sources.filter(s => s.streaming); noSrc.style.display = streaming.length === 0 ? '' : 'none'; const activeTileIds = new Set(); for (const src of streaming) { activeTileIds.add(src.id); let tile = grid.querySelector(`.video-tile[data-id="${src.id}"]`); if (!tile) { tile = buildVideoTile(src); grid.appendChild(tile); attachHlsPlayer(tile, src); } // Update transport badge const badge = tile.querySelector('.transport-badge'); const t = (src.transport || 'udp').toUpperCase(); badge.textContent = t; badge.className = 'transport-badge' + (t === 'SRT' ? ' srt' : ''); } // Remove tiles for sources that stopped streaming for (const tile of grid.querySelectorAll('.video-tile')) { if (!activeTileIds.has(tile.dataset.id)) { const player = hlsPlayers.get(tile.dataset.id); if (player) { player.destroy(); hlsPlayers.delete(tile.dataset.id); } tile.remove(); } } } function buildVideoTile(src) { const tile = document.createElement('div'); tile.className = 'video-tile'; tile.dataset.id = src.id; tile.innerHTML = `
${src.name || src.id}
Connecting to HLS stream…
`; return tile; } function attachHlsPlayer(tile, src) { const video = tile.querySelector('video'); const status = tile.querySelector('.video-status'); // Use mediamtx_path (e.g. "source_0") if present, otherwise fall back to id. // The path is determined by the UDP port: port 5004 → source_0, 5005 → source_1, etc. const mtxPath = src.mediamtx_path || src.id; const hlsUrl = `${MEDIAMTX_HLS}/${mtxPath}/index.m3u8`; if (Hls.isSupported()) { const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 2 }); hls.loadSource(hlsUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); status.textContent = `HLS • ${hlsUrl}`; }); hls.on(Hls.Events.ERROR, (_ev, data) => { if (data.fatal) { status.textContent = `Stream error: ${data.details}`; hls.destroy(); hlsPlayers.delete(src.id); setTimeout(() => attachHlsPlayer(tile, src), 3000); } }); hlsPlayers.set(src.id, hls); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Safari native HLS video.src = hlsUrl; video.addEventListener('loadedmetadata', () => video.play().catch(() => {})); status.textContent = `Native HLS • ${hlsUrl}`; } else { status.textContent = 'HLS not supported in this browser.'; } }