Live portal: React + dockview multi-stream viewer behind Authentik; MediaMTX HLS/WebRTC/API re-enabled; is-live Discord hook

live/: every active stream opens as a dockable window (WHEP, HLS fallback), audio follows the
focused window, per-window mute/volume, pinning. Caddy live.HOST block with forward-auth proxies
/api /webrtc /hls to MediaMTX on loopback and serves /srv/live. WebRTC media on 8189 (firewall).
configure.py asks the live hostname and an optional Discord webhook; MediaMTX -ffmpeg image runs
the runOnReady hook. deployLive.sh builds and ships dist/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:01:11 -05:00
parent 3d42271ae2
commit 463d9b2385
22 changed files with 2398 additions and 28 deletions

2
live/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

18
live/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Vanguard Live — the live-stream portal
React + dockview page at `https://live.<domain>/` (behind Authentik): every active stream opens as a
dockable window; audio follows the focused window; per-window mute button and volume slider.
Playback is WebRTC (WHEP) with HLS fallback, straight from MediaMTX via Caddy.
npm install
npm run build # -> dist/ (static; Caddy serves it)
npm run dev # local dev; VITE_MTX=http://<server> to proxy to a MediaMTX
Deploy: build here, `scp -r live/dist debian@HOST:~/vanguard-stream/live/`, Caddy picks it up (no restart).
Audio rules: all windows start muted; the focused window is unmuted at its slider volume. Clicking the
speaker on an unfocused window pins its audio on (yellow border) until you click it again. "Mute all"
silences everything until you focus a window; "Follow focus" clears pins.
Endpoints (proxied by Caddy, all behind the Authentik gate):
`/api/*` → MediaMTX API 9997 · `/webrtc/*` → WebRTC/WHEP 8889 · `/hls/*` → HLS 8888.

13
live/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Vanguard Live</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1890
live/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
live/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "vanguard-live",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dockview-react": "^8.8.3",
"hls.js": "^1.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.0.0"
}
}

68
live/src/App.jsx Normal file
View File

@@ -0,0 +1,68 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { DockviewReact } from 'dockview-react'
import Player from './Player.jsx'
import { fetchStreams } from './api.js'
import { audio } from './useAudio.js'
const components = { player: Player }
const POLL_MS = 3000
export default function App() {
const apiRef = useRef(null)
const [streams, setStreams] = useState([])
const [err, setErr] = useState(null)
const focused = audio.use(s => s.focused)
// poll MediaMTX; open/close panels to match
useEffect(() => {
let alive = true
const tick = async () => {
try {
const list = await fetchStreams(); if (!alive) return
setErr(null); setStreams(list)
const api = apiRef.current; if (!api) return
const have = new Set(api.panels.map(p => p.id))
const want = new Set(list.map(s => s.id))
for (const s of list) {
if (!have.has(s.id)) {
const last = api.panels[api.panels.length - 1]
api.addPanel({
id: s.id, component: 'player', title: `${s.user} · ${s.title}`,
params: s,
position: last ? { referencePanel: last.id, direction: api.panels.length % 2 ? 'below' : 'right' } : undefined,
})
} else {
api.getPanel(s.id)?.api.updateParameters(s)
}
}
for (const p of [...api.panels]) if (!want.has(p.id)) { api.removePanel(p); audio.forget(p.id) }
} catch (e) { if (alive) setErr(e.message) }
}
tick(); const t = setInterval(tick, POLL_MS)
return () => { alive = false; clearInterval(t) }
}, [])
const onReady = useCallback(e => {
apiRef.current = e.api
e.api.onDidActivePanelChange(p => audio.focus(p?.id ?? null))
}, [])
return (
<div className="app">
<div className="topbar">
<h1>Vanguard Live</h1>
<span className="count">{streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''}</span>
<span className="spacer" />
<span className="count">audio follows the focused window</span>
<button onClick={() => audio.muteAll()} title="mute every window until you click one">Mute all</button>
<button onClick={() => audio.followFocus()} title="clear pinned audio; only the focused window plays sound">Follow focus</button>
</div>
{streams.length === 0 && !err && (
<div className="empty"><h2>Nobody is live right now</h2><span>This page updates itself when a stream starts.</span></div>
)}
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}>
<DockviewReact className="dockview-theme-vanguard" components={components} onReady={onReady} />
</div>
</div>
)
}

90
live/src/Player.jsx Normal file
View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { whepUrl, hlsUrl } from './api.js'
import { audio, effectiveMuted } from './useAudio.js'
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
export default function Player({ params, api }) {
const { id, user, title } = params
const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…')
const [viewers, setViewers] = useState(params.viewers ?? 0)
const state = audio.use()
const muted = effectiveMuted(state, id)
const volume = state.streams[id]?.volume ?? 0.8
const override = state.streams[id]?.override
const focused = state.focused === id
useEffect(() => { audio.ensure(id) }, [id])
useEffect(() => { const d = api.onDidActiveChange?.(() => {}) ; return () => d?.dispose?.() }, [api])
useEffect(() => { const t = setInterval(() => setViewers(params.viewers ?? 0), 2000); return () => clearInterval(t) }, [params.viewers])
// apply audio state to the element
useEffect(() => {
const v = videoRef.current; if (!v) return
v.muted = muted; v.volume = volume
if (!muted && v.paused) v.play().catch(() => {})
}, [muted, volume])
// connect
useEffect(() => {
const v = videoRef.current; if (!v) return
let pc, hls, cancelled = false
const startHls = () => {
if (cancelled) return
setStatus('hls')
if (Hls.isSupported()) {
hls = new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, enableWorker: true })
hls.loadSource(hlsUrl(id)); hls.attachMedia(v)
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}))
hls.on(Hls.Events.ERROR, (_, d) => { if (d.fatal) setStatus('hls error: ' + d.details) })
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = hlsUrl(id); v.play().catch(() => {})
} else setStatus('no playback method')
}
const startWhep = async () => {
try {
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = e => { if (v.srcObject !== e.streams[0]) { v.srcObject = e.streams[0]; v.play().catch(() => {}) } }
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') setStatus('')
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState) && !cancelled) { pc.close(); startHls() }
}
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 1500); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
const r = await fetch(whepUrl(id), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
if (!r.ok) throw new Error('whep ' + r.status)
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
setStatus('webrtc')
} catch (e) {
if (cancelled) return
try { pc?.close() } catch {}
startHls()
}
}
startWhep()
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
}, [id])
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
return (
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
<video ref={videoRef} autoPlay playsInline muted />
{status && <div className="status">{status}</div>}
<div className="bar">
<span className="live">LIVE</span>
<span className="who">{user}</span>
<span className="title" title={title}>{title}</span>
<span className="viewers">👁 {viewers}</span>
<button className={'mute' + (!muted ? ' on' : '') + (override === 'unmuted' && !focused ? ' pinned' : '')}
title={override ? (override === 'unmuted' ? 'audio pinned on (click to mute)' : 'muted (click to unmute)') : (focused ? 'focused: audio on (click to mute)' : 'muted: click to pin audio on')}
onMouseDown={e => e.stopPropagation()} onClick={() => audio.toggleMute(id)}>{icon}</button>
<input type="range" min="0" max="1" step="0.02" value={volume} onMouseDown={e => e.stopPropagation()}
onChange={e => audio.setVolume(id, parseFloat(e.target.value))} />
</div>
</div>
)
}

23
live/src/api.js Normal file
View File

@@ -0,0 +1,23 @@
// MediaMTX control API, proxied by Caddy at /api (-> 127.0.0.1:9997). Returns active streams.
export async function fetchStreams() {
const r = await fetch('/api/v3/paths/list?itemsPerPage=200', { credentials: 'same-origin' })
if (!r.ok) throw new Error(`api ${r.status}`)
const data = await r.json()
return (data.items || [])
.filter(p => p.ready && (p.tracks || []).length > 0)
.map(p => {
const [user, ...rest] = p.name.split('/')
return {
id: p.name, // e.g. techy/VanguardTraining
user,
title: rest.join('/') || p.name,
viewers: (p.readers || []).length,
since: p.readyTime,
tracks: p.tracks || [],
}
})
.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
}
export const whepUrl = id => `/webrtc/${id}/whep`
export const hlsUrl = id => `/hls/${id}/index.m3u8`

7
live/src/main.jsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import 'dockview/dist/styles/dockview.css'
import './styles.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)

32
live/src/styles.css Normal file
View File

@@ -0,0 +1,32 @@
:root { --bg:#141414; --panel:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --live:#e74c3c; }
html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui, sans-serif; }
.app { display:flex; flex-direction:column; height:100%; }
.topbar { display:flex; align-items:center; gap:14px; padding:8px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
.topbar h1 { font-size:15px; margin:0; font-weight:700; letter-spacing:.3px; }
.topbar .count { color:var(--mut); font-size:13px; }
.topbar .spacer { flex:1; }
.topbar button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; }
.topbar button:hover { border-color:var(--acc); }
.dock { flex:1; min-height:0; }
.dockview-theme-vanguard { --dv-background-color:var(--bg); --dv-paneview-active-outline-color:var(--acc);
--dv-tabs-and-actions-container-background-color:#181818; --dv-activegroup-visiblepanel-tab-background-color:var(--panel);
--dv-activegroup-hiddenpanel-tab-background-color:#181818; --dv-inactivegroup-visiblepanel-tab-background-color:#1a1a1a;
--dv-inactivegroup-hiddenpanel-tab-background-color:#181818; --dv-tab-divider-color:#2a2a2a; --dv-activegroup-visiblepanel-tab-color:var(--fg);
--dv-activegroup-hiddenpanel-tab-color:var(--mut); --dv-inactivegroup-visiblepanel-tab-color:var(--mut); --dv-inactivegroup-hiddenpanel-tab-color:#777;
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
--dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; }
.player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; }
.player video { flex:1; min-height:0; width:100%; height:100%; object-fit:contain; background:#000; }
.player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; }
.player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; }
.player .who { font-weight:600; }
.player .title { color:var(--mut); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }
.player .viewers { color:var(--mut); }
.player .mute { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; width:34px; height:28px; cursor:pointer; font-size:15px; }
.player .mute.on { border-color:var(--acc); }
.player .mute.pinned { border-color:#f1c40f; }
.player input[type=range] { width:110px; accent-color:var(--acc); }
.player .status { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.6); color:var(--mut); font-size:12px; padding:3px 8px; border-radius:4px; }
.player.focused .bar { background:#1c2333; }
.empty { flex:1; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:8px; color:var(--mut); }
.empty h2 { color:var(--fg); margin:0; font-size:20px; }

22
live/src/tinyStore.js Normal file
View File

@@ -0,0 +1,22 @@
import { useSyncExternalStore } from 'react'
// 40-line store: state object + action methods that return partial updates.
export function create(def) {
let state = {}
const listeners = new Set()
const api = {}
for (const [k, v] of Object.entries(def)) {
if (typeof v === 'function') {
api[k] = (...args) => {
const patch = v.apply(state, args)
if (patch) { state = { ...state, ...patch }; listeners.forEach(l => l()) }
}
} else {
state[k] = v
}
}
api.get = () => state
api.subscribe = l => { listeners.add(l); return () => listeners.delete(l) }
api.use = (selector = s => s) => useSyncExternalStore(api.subscribe, () => selector(state), () => selector(state))
return api
}

46
live/src/useAudio.js Normal file
View File

@@ -0,0 +1,46 @@
import { create } from './tinyStore.js'
// Per-stream audio state. Effective mute = override ?? !focused.
// override: null (follow focus) | 'unmuted' (pinned on) | 'muted' (pinned off)
export const audio = create({
focused: null,
streams: {}, // id -> { volume: 0..1, override }
focus(id) {
const s = { ...this.streams }
if (id && !s[id]) s[id] = { volume: 0.8, override: null }
if (id && s[id]) s[id] = { ...s[id], override: null } // focusing always gives you sound
return { focused: id, streams: s }
},
ensure(id) {
if (this.streams[id]) return null
return { streams: { ...this.streams, [id]: { volume: 0.8, override: null } } }
},
forget(id) {
const s = { ...this.streams }; delete s[id]
return { streams: s, focused: this.focused === id ? null : this.focused }
},
setVolume(id, volume) {
const cur = this.streams[id] || { volume: 0.8, override: null }
return { streams: { ...this.streams, [id]: { ...cur, volume } } }
},
toggleMute(id) {
const cur = this.streams[id] || { volume: 0.8, override: null }
const effectiveMuted = cur.override ? cur.override === 'muted' : this.focused !== id
return { streams: { ...this.streams, [id]: { ...cur, override: effectiveMuted ? 'unmuted' : 'muted' } } }
},
muteAll() {
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: 'muted' }
return { streams: s }
},
followFocus() {
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: null }
return { streams: s }
},
})
export function effectiveMuted(state, id) {
const cur = state.streams[id]
if (!cur) return true
if (cur.override) return cur.override === 'muted'
return state.focused !== id
}

17
live/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Dev: `npm run dev` proxies the three MediaMTX endpoints to a running server (set VITE_MTX=https://live.example.com
// to hit production through Caddy, cookies permitting) or to a local MediaMTX.
const target = process.env.VITE_MTX || 'http://127.0.0.1'
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist', emptyOutDir: true },
server: {
proxy: {
'/api': { target: `${target}:9997`, changeOrigin: true, rewrite: p => p.replace(/^\/api/, '') },
'/webrtc': { target: `${target}:8889`, changeOrigin: true, rewrite: p => p.replace(/^\/webrtc/, '') },
'/hls': { target: `${target}:8888`, changeOrigin: true, rewrite: p => p.replace(/^\/hls/, '') },
},
},
})