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

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
}