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:
68
live/src/App.jsx
Normal file
68
live/src/App.jsx
Normal 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
90
live/src/Player.jsx
Normal 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
23
live/src/api.js
Normal 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
7
live/src/main.jsx
Normal 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
32
live/src/styles.css
Normal 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
22
live/src/tinyStore.js
Normal 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
46
live/src/useAudio.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user