Live portal: Go Live — browser window capture published over WHIP as <user>/HelmetCam-<date>; dumpd /me/stream-key
Go Live closes viewer windows, fetches the Authentik-identified user's MediaMTX key from dumpd (/etc/vanguard/streamers.json written by configure.py), opens Chrome's window-only picker with audio, prefers H.264 so MediaMTX can record, caps at 12 Mbps / 1440p60, shows live stats, Stop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,12 @@ 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.
|
||||
|
||||
## Go Live (browser broadcast)
|
||||
Toolbar → **Go Live**: closes all viewer windows, fetches the logged-in user's stream key from
|
||||
`/me/stream-key` (dumpd, identity from Authentik's header), opens Chrome's window picker (windows only,
|
||||
audio requested), and publishes over WebRTC/WHIP to `<user>/HelmetCam-<date>` at ≤12 Mbps, H.264, up to
|
||||
1440p60. Recording, the "is live" post, the sweep and Jellyfin follow exactly as for an OBS stream.
|
||||
Window audio: Chrome on Windows. Linux/Mac: pick a tab or the whole screen for sound.
|
||||
Requires the Authentik username to equal the MediaMTX streamer name (configure.py keeps them in sync via
|
||||
`/etc/vanguard/streamers.json`).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DockviewReact } from 'dockview-react'
|
||||
import Player from './Player.jsx'
|
||||
import { fetchStreams } from './api.js'
|
||||
import { audio } from './useAudio.js'
|
||||
import GoLive from './GoLive.jsx'
|
||||
|
||||
const components = { player: Player }
|
||||
const POLL_MS = 3000
|
||||
@@ -11,6 +12,7 @@ export default function App() {
|
||||
const apiRef = useRef(null)
|
||||
const [streams, setStreams] = useState([])
|
||||
const [err, setErr] = useState(null)
|
||||
const [mode, setMode] = useState('watch') // watch | golive
|
||||
const focused = audio.use(s => s.focused)
|
||||
|
||||
// poll MediaMTX; open/close panels to match
|
||||
@@ -20,7 +22,7 @@ export default function App() {
|
||||
try {
|
||||
const list = await fetchStreams(); if (!alive) return
|
||||
setErr(null); setStreams(list)
|
||||
const api = apiRef.current; if (!api) return
|
||||
const api = apiRef.current; if (!api) return // (in golive mode the dock is unmounted: no viewer connections)
|
||||
const have = new Set(api.panels.map(p => p.id))
|
||||
const want = new Set(list.map(s => s.id))
|
||||
for (const s of list) {
|
||||
@@ -56,13 +58,18 @@ export default function App() {
|
||||
<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>
|
||||
<button className="golive-btn" onClick={() => { audio.muteAll(); apiRef.current = null; setMode('golive') }} title="stop watching and broadcast a window from this browser">Go Live</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>
|
||||
{mode === 'golive' ? (
|
||||
<GoLive onExit={() => { audio.followFocus(); setMode('watch') }} />
|
||||
) : (<>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
89
live/src/GoLive.jsx
Normal file
89
live/src/GoLive.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, MAX_KBPS } from './broadcast.js'
|
||||
|
||||
// Broadcaster view: replaces the viewer grid while you're live.
|
||||
export default function GoLive({ onExit }) {
|
||||
const videoRef = useRef(null)
|
||||
const sessionRef = useRef(null)
|
||||
const [phase, setPhase] = useState('setup') // setup | capturing | live | error
|
||||
const [title, setTitle] = useState(defaultTitle())
|
||||
const [key, setKey] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [conn, setConn] = useState('')
|
||||
const [stats, setStats] = useState(null)
|
||||
const [started, setStarted] = useState(null)
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
const [hasAudio, setHasAudio] = useState(true)
|
||||
|
||||
useEffect(() => { fetchStreamKey().then(setKey).catch(e => { setErr(e.message); setPhase('error') }) }, [])
|
||||
useEffect(() => { if (!started) return; const t = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => clearInterval(t) }, [started])
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') return
|
||||
let last = { bytes: 0, t: Date.now() }
|
||||
const t = setInterval(async () => {
|
||||
const pc = sessionRef.current?.pc; if (!pc) return
|
||||
for (const s of await pc.getStats()) if (s.type === 'outbound-rtp' && s.kind === 'video') {
|
||||
const now = Date.now(); const kbps = Math.round((s.bytesSent - last.bytes) * 8 / ((now - last.t) || 1)); last = { bytes: s.bytesSent, t: now }
|
||||
setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason })
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [phase])
|
||||
useEffect(() => () => { sessionRef.current?.stop() }, [])
|
||||
|
||||
const start = async () => {
|
||||
setErr('')
|
||||
const t = cleanTitle(title); if (!t) return setErr('give it a title')
|
||||
if (!key) return setErr('no stream key')
|
||||
try {
|
||||
setPhase('capturing')
|
||||
const stream = await captureWindow()
|
||||
setHasAudio(stream.getAudioTracks().length > 0)
|
||||
if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.muted = true; videoRef.current.play().catch(() => {}) }
|
||||
stream.getVideoTracks()[0].onended = () => stop() // user clicked Chrome's "Stop sharing"
|
||||
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn })
|
||||
sessionRef.current = session
|
||||
setStarted(Date.now()); setPhase('live')
|
||||
} catch (e) { setErr(e.message); setPhase(e.message === 'capture cancelled' ? 'setup' : 'error') }
|
||||
}
|
||||
const stop = async () => { await sessionRef.current?.stop(); sessionRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn('') }
|
||||
|
||||
const mmss = s => `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
return (
|
||||
<div className="golive">
|
||||
<div className="golive-head">
|
||||
<h2>{phase === 'live' ? '🔴 You are live' : 'Go live from this browser'}</h2>
|
||||
<span className="who">{key ? `as ${key.user}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{phase === 'live' ? <button className="danger" onClick={stop}>Stop stream</button>
|
||||
: <button onClick={() => { sessionRef.current?.stop(); onExit() }}>Back to watching</button>}
|
||||
</div>
|
||||
<div className="golive-body">
|
||||
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
|
||||
{phase !== 'live' && (
|
||||
<div className="golive-form">
|
||||
<label>Stream title <small>(becomes the folder and the Jellyfin title)</small></label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
|
||||
<div className="hint">
|
||||
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
|
||||
Window audio works in Chrome on Windows; on Linux/Mac pick a tab or the whole screen for sound.
|
||||
Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264.
|
||||
</div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
<button className="primary" disabled={!key || phase === 'capturing'} onClick={start}>{phase === 'capturing' ? 'Waiting for the window picker…' : 'Start'}</button>
|
||||
</div>
|
||||
)}
|
||||
{phase === 'live' && (
|
||||
<div className="golive-stats">
|
||||
<span className="pill live">LIVE</span>
|
||||
<span>{mmss(elapsed)}</span>
|
||||
<span>{key.user}/{cleanTitle(title)}</span>
|
||||
<span>{conn}</span>
|
||||
{stats && <span>{stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}</span>}
|
||||
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
live/src/broadcast.js
Normal file
86
live/src/broadcast.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc/<path>/whip.
|
||||
// Credentials go as query params so the Authentik forward-auth in front doesn't see an Authorization header.
|
||||
|
||||
export const MAX_KBPS = 12000
|
||||
|
||||
export function defaultTitle(now = new Date()) {
|
||||
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
|
||||
return `HelmetCam-${d}`
|
||||
}
|
||||
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '')
|
||||
|
||||
export async function captureWindow() {
|
||||
// Window-only picker with audio. Chrome on Windows captures that window's audio; elsewhere the user
|
||||
// should pick a tab or the whole screen to get sound.
|
||||
const constraints = {
|
||||
video: { displaySurface: 'window', frameRate: { ideal: 60, max: 60 }, width: { max: 2560 }, height: { max: 1440 } },
|
||||
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, channelCount: 2 },
|
||||
systemAudio: 'include',
|
||||
selfBrowserSurface: 'exclude',
|
||||
monitorTypeSurfaces: 'exclude', // hide "Entire screen" so people pick the game window
|
||||
surfaceSwitching: 'include',
|
||||
preferCurrentTab: false,
|
||||
}
|
||||
try {
|
||||
return await navigator.mediaDevices.getDisplayMedia(constraints)
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') throw new Error('capture cancelled')
|
||||
// older browsers choke on the newer hints; retry plain
|
||||
return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 60 }, audio: true })
|
||||
}
|
||||
}
|
||||
|
||||
function preferH264(pc, kind = 'video') {
|
||||
const caps = RTCRtpSender.getCapabilities?.(kind)
|
||||
if (!caps) return
|
||||
const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind)
|
||||
if (!tx?.setCodecPreferences) return
|
||||
const h264 = caps.codecs.filter(c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || ''))
|
||||
const rest = caps.codecs.filter(c => !/H264/i.test(c.mimeType))
|
||||
if (h264.length) tx.setCodecPreferences([...h264, ...rest]) // MediaMTX records H264 into fMP4; VP8 could not be recorded
|
||||
}
|
||||
|
||||
export async function publish({ stream, path, user, pass, onState }) {
|
||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
||||
for (const track of stream.getTracks()) pc.addTransceiver(track, { direction: 'sendonly' })
|
||||
preferH264(pc)
|
||||
const vs = pc.getSenders().find(s => s.track?.kind === 'video')
|
||||
if (vs) {
|
||||
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
|
||||
p.encodings[0].maxBitrate = MAX_KBPS * 1000
|
||||
p.encodings[0].maxFramerate = 60
|
||||
p.degradationPreference = 'maintain-framerate'
|
||||
await vs.setParameters(p).catch(() => {})
|
||||
}
|
||||
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 2000); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
|
||||
const url = `/webrtc/${path}/whip?user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
|
||||
if (!r.ok) { pc.close(); throw new Error(r.status === 401 || r.status === 403 ? 'stream key rejected' : r.status === 400 && /busy|already/i.test(await r.text().catch(() => '')) ? 'that stream name is already live (OBS running?)' : `publish failed (${r.status})`) }
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
|
||||
const session = r.headers.get('Location')
|
||||
return {
|
||||
pc,
|
||||
stats: async () => {
|
||||
let kbps = 0, fps = 0, w = 0, h = 0
|
||||
for (const s of await pc.getStats()) {
|
||||
if (s.type === 'outbound-rtp' && s.kind === 'video') { fps = s.framesPerSecond || 0; w = s.frameWidth || 0; h = s.frameHeight || 0; kbps = s.__k || 0 }
|
||||
}
|
||||
return { kbps, fps, w, h }
|
||||
},
|
||||
stop: async () => {
|
||||
try { if (session) await fetch(session.startsWith('http') ? new URL(session).pathname : session, { method: 'DELETE', credentials: 'same-origin' }) } catch {}
|
||||
pc.close()
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStreamKey() {
|
||||
const r = await fetch('/me/stream-key', { credentials: 'same-origin' })
|
||||
const body = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
|
||||
return body
|
||||
}
|
||||
@@ -30,3 +30,26 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
|
||||
.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; }
|
||||
|
||||
/* --- Go Live --------------------------------------------------------------------------------- */
|
||||
.golive { flex:1; display:flex; flex-direction:column; min-height:0; }
|
||||
.golive-head { display:flex; align-items:center; gap:12px; padding:10px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
|
||||
.golive-head h2 { margin:0; font-size:16px; }
|
||||
.golive-head .who { color:var(--mut); }
|
||||
.golive-head .spacer { flex:1; }
|
||||
.golive-head button, .golive-form button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:8px 14px; cursor:pointer; font-size:14px; }
|
||||
.golive-head button.danger { background:var(--live); border-color:var(--live); color:#fff; font-weight:600; }
|
||||
.golive-form button.primary { background:var(--acc); border-color:var(--acc); color:#fff; font-weight:600; width:100%; padding:12px; margin-top:8px; }
|
||||
.golive-form button:disabled { opacity:.5; cursor:default; }
|
||||
.golive-body { flex:1; position:relative; display:flex; align-items:center; justify-content:center; min-height:0; background:#000; }
|
||||
.golive-body video { display:none; width:100%; height:100%; object-fit:contain; }
|
||||
.golive-body video.show { display:block; }
|
||||
.golive-form { position:absolute; width:min(520px, calc(100% - 32px)); background:var(--panel); border:1px solid #3a3a3a; border-radius:12px; padding:22px; display:grid; gap:10px; }
|
||||
.golive-form label { color:var(--mut); font-size:13px; }
|
||||
.golive-form input { padding:10px 12px; font-size:15px; background:#141414; color:var(--fg); border:1px solid #444; border-radius:8px; }
|
||||
.golive-form .hint { color:var(--mut); font-size:13px; line-height:1.5; }
|
||||
.golive-form .err { color:var(--live); font-size:13px; }
|
||||
.golive-stats { position:absolute; left:12px; bottom:12px; display:flex; gap:14px; align-items:center; background:rgba(0,0,0,.65); padding:8px 12px; border-radius:8px; font-size:13px; }
|
||||
.golive-stats .pill.live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; }
|
||||
.golive-stats .warn { color:#f1c40f; }
|
||||
.topbar button.golive-btn { background:var(--live); border-color:var(--live); color:#fff; font-weight:700; }
|
||||
|
||||
Reference in New Issue
Block a user