WebRTC audio mirror: AAC cannot cross WebRTC, so transcode audio to Opus into <path>.webrtc
bin/mtx-on-ready.sh (runOnReady inside the mediamtx container) posts the is-live notice and, for sources whose audio is not Opus, runs ffmpeg: video copy, audio -> Opus, published over MediaMTX's loopback RTSP to <user>/<Title>.webrtc (record: no). dumpd accepts .webrtc publishes only from 127.0.0.1. The portal hides mirror paths, plays the mirror over WHEP (HLS stays on the source), holds an AAC source for 15 s while its mirror comes up, and corrects the viewer count. Verified headless with a mocked path list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ 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 whep = params.whep || id // Opus audio mirror path when the source audio is AAC
|
||||
const videoRef = useRef(null)
|
||||
const [status, setStatus] = useState('connecting…')
|
||||
const [viewers, setViewers] = useState(params.viewers ?? 0)
|
||||
@@ -78,7 +79,7 @@ export default function Player({ params, api }) {
|
||||
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' })
|
||||
const r = await fetch(whepUrl(whep), { 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')
|
||||
@@ -90,7 +91,7 @@ export default function Player({ params, api }) {
|
||||
}
|
||||
startWhep()
|
||||
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
|
||||
}, [id])
|
||||
}, [id, whep])
|
||||
|
||||
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
|
||||
return (
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
// MediaMTX control API, proxied by Caddy at /api (-> 127.0.0.1:9997). Returns active streams.
|
||||
const VIDEO = new Set(['H264', 'H265', 'VP8', 'VP9', 'AV1', 'MJPEG', 'M-JPEG', 'MPEG-4 Video', 'MPEG-1 Video'])
|
||||
const firstSeen = new Map() // path -> first time we saw it ready (mirror grace period)
|
||||
const MIRROR_WAIT_MS = 15000
|
||||
|
||||
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 || [],
|
||||
}
|
||||
const ready = (data.items || []).filter(p => p.ready && (p.tracks || []).length > 0)
|
||||
const byName = new Map(ready.map(p => [p.name, p]))
|
||||
const now = Date.now()
|
||||
for (const p of ready) if (!firstSeen.has(p.name)) firstSeen.set(p.name, now)
|
||||
for (const k of [...firstSeen.keys()]) if (!byName.has(k)) firstSeen.delete(k)
|
||||
const out = []
|
||||
for (const p of ready) {
|
||||
if (p.name.endsWith('.webrtc')) continue // audio mirror: played through its source's panel, never its own
|
||||
const tracks = p.tracks || []
|
||||
const audio = tracks.filter(t => !VIDEO.has(t))
|
||||
const needsMirror = audio.length > 0 && !audio.includes('Opus') // AAC etc. cannot cross WebRTC
|
||||
const mirror = byName.get(p.name + '.webrtc')
|
||||
if (needsMirror && !mirror && now - firstSeen.get(p.name) < MIRROR_WAIT_MS) continue // give the mirror a moment to come up
|
||||
const [user, ...rest] = p.name.split('/')
|
||||
out.push({
|
||||
id: p.name, // e.g. techy/VanguardTraining
|
||||
user,
|
||||
title: rest.join('/') || p.name,
|
||||
whep: mirror ? mirror.name : p.name, // WebRTC plays the Opus mirror when there is one; HLS always the source
|
||||
viewers: (p.readers || []).length + (mirror ? (mirror.readers || []).length - 1 : 0), // the mirror's ffmpeg is one reader
|
||||
since: p.readyTime,
|
||||
tracks,
|
||||
})
|
||||
.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
|
||||
}
|
||||
return out.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
|
||||
}
|
||||
|
||||
export const whepUrl = id => `/webrtc/${id}/whep`
|
||||
|
||||
Reference in New Issue
Block a user