Skip to content

Commit 5d2bfba

Browse files
nabacoclaude
andcommitted
feat(player): auto quality mode with WebRTC→HLS fallback on degradation
- Add Auto/Fast/Stable mode selector (replaces WebRTC/HLS format toggle) - Auto mode (default): starts WebRTC, monitors packet loss (>5%) and video freeze every 5s via getStats(), silently switches to HLS when network degrades; shows "switched to stable" indicator when it does - Fast: forces WebRTC; Stable: forces HLS - Fix publisher checkbox to send source:"publisher" instead of "" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 28f0cf0 commit 5d2bfba

4 files changed

Lines changed: 93 additions & 50 deletions

File tree

frontend/src/lib/components/StreamPlayer.svelte

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -41,34 +41,77 @@
4141
}
4242
let { streamName, info }: Props = $props()
4343
44-
type Format = 'webrtc' | 'hls'
45-
let format = $state<Format>('webrtc')
44+
type Mode = 'auto' | 'webrtc' | 'hls'
45+
let mode = $state<Mode>('auto')
46+
let autoFallback = $state(false)
4647
let videoEl = $state<HTMLVideoElement | null>(null)
4748
let status = $state<'loading' | 'playing' | 'error' | 'idle'>('idle')
4849
let errorMsg = $state('')
4950
5051
let hlsInstance: Hls | null = null
5152
let rtcPeer: RTCPeerConnection | null = null
53+
let qualityTimer: ReturnType<typeof setInterval> | null = null
54+
let lastPktReceived = 0
55+
let lastPktLost = 0
56+
let lastVideoTime = 0
57+
let frozenTicks = 0
5258
53-
// If the server returns localhost/127.0.0.1, use the browser's hostname instead
54-
// so stream URLs work when accessing the UI from a remote machine.
5559
const resolvedHost = $derived(
5660
(info.mediamtxHost === 'localhost' || info.mediamtxHost === '127.0.0.1')
5761
? window.location.hostname
5862
: info.mediamtxHost
5963
)
60-
61-
// Match the page scheme so browsers don't block mixed content when served over HTTPS.
6264
const scheme = $derived(window.location.protocol === 'https:' ? 'https' : 'http')
65+
const hlsUrl = $derived(`${scheme}://${resolvedHost}:${info.hlsPort}/${streamName}/index.m3u8`)
66+
const webrtcUrl = $derived(`${scheme}://${resolvedHost}:${info.webrtcPort}/${streamName}`)
6367
64-
const hlsUrl = $derived(
65-
`${scheme}://${resolvedHost}:${info.hlsPort}/${streamName}/index.m3u8`
66-
)
67-
const webrtcUrl = $derived(
68-
`${scheme}://${resolvedHost}:${info.webrtcPort}/${streamName}`
69-
)
68+
function stopQualityMonitor() {
69+
if (qualityTimer) { clearInterval(qualityTimer); qualityTimer = null }
70+
}
71+
72+
function startQualityMonitor() {
73+
stopQualityMonitor()
74+
lastPktReceived = 0; lastPktLost = 0
75+
lastVideoTime = videoEl?.currentTime ?? 0
76+
frozenTicks = 0
77+
78+
qualityTimer = setInterval(async () => {
79+
if (!rtcPeer || mode !== 'auto' || status !== 'playing') return
80+
81+
// Video freeze detection: if currentTime hasn't advanced for two ticks (10s), bail out
82+
const ct = videoEl?.currentTime ?? 0
83+
if (ct === lastVideoTime) {
84+
if (++frozenTicks >= 2) { fallbackToHLS(); return }
85+
} else {
86+
frozenTicks = 0
87+
}
88+
lastVideoTime = ct
89+
90+
// Packet loss detection via WebRTC stats
91+
try {
92+
const stats = await rtcPeer!.getStats()
93+
stats.forEach(report => {
94+
if (report.type !== 'inbound-rtp' || report.kind !== 'video') return
95+
const dr = report.packetsReceived - lastPktReceived
96+
const dl = report.packetsLost - lastPktLost
97+
lastPktReceived = report.packetsReceived
98+
lastPktLost = report.packetsLost
99+
const total = dr + dl
100+
// Only act if we have a meaningful sample and >5% loss
101+
if (total > 10 && dl / total > 0.05) fallbackToHLS()
102+
})
103+
} catch {}
104+
}, 5000)
105+
}
106+
107+
function fallbackToHLS() {
108+
stopQualityMonitor()
109+
autoFallback = true
110+
startHLS()
111+
}
70112
71113
function stopAll() {
114+
stopQualityMonitor()
72115
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null }
73116
if (rtcPeer) { rtcPeer.close(); rtcPeer = null }
74117
if (videoEl) { videoEl.srcObject = null; videoEl.src = '' }
@@ -84,16 +127,11 @@
84127
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
85128
})
86129
rtcPeer = pc
87-
88-
// Helper: bail out silently if this pc was superseded by stopAll()
89130
const stale = () => pc.signalingState === 'closed'
90131
91132
pc.addTransceiver('video', { direction: 'recvonly' })
92133
pc.addTransceiver('audio', { direction: 'recvonly' })
93134
94-
// Build a local MediaStream and add each incoming track to it.
95-
// Do NOT rely on e.streams[0]: mediamtx WHEP may send audio tracks without
96-
// a stream association, causing e.streams[0] to be undefined and audio to be dropped.
97135
const remoteStream = new MediaStream()
98136
videoEl.srcObject = remoteStream
99137
let playStarted = false
@@ -121,7 +159,6 @@
121159
if (stale()) return
122160
await pc.setLocalDescription(offer)
123161
124-
// Wait for ICE gathering to complete so all candidates are in the SDP
125162
await new Promise<void>(resolve => {
126163
if (pc.iceGatheringState === 'complete') { resolve(); return }
127164
const onStateChange = () => {
@@ -131,7 +168,6 @@
131168
}
132169
}
133170
pc.addEventListener('icegatheringstatechange', onStateChange)
134-
// Safety timeout: send after 5s even if gathering isn't done
135171
setTimeout(resolve, 5000)
136172
})
137173
@@ -195,7 +231,6 @@
195231
}
196232
})
197233
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
198-
// Safari native HLS
199234
videoEl.src = hlsUrl
200235
videoEl.muted = true
201236
videoEl.play().then(() => status = 'playing').catch(e => {
@@ -208,24 +243,27 @@
208243
}
209244
210245
async function start() {
211-
if (format === 'webrtc') {
212-
await startWebRTC()
213-
// Auto-fallback: if WebRTC fails, silently try HLS
214-
if (status === 'error') {
215-
format = 'hls'
216-
await startHLS()
217-
}
218-
} else {
246+
autoFallback = false
247+
stopQualityMonitor()
248+
if (mode === 'hls') {
249+
await startHLS()
250+
return
251+
}
252+
// 'webrtc' or 'auto'
253+
await startWebRTC()
254+
if (status === 'error' && mode === 'auto') {
255+
autoFallback = true
219256
await startHLS()
257+
} else if (status === 'playing' && mode === 'auto') {
258+
startQualityMonitor()
220259
}
221260
}
222261
223-
function switchFormat(f: Format) {
224-
format = f
262+
function switchMode(m: Mode) {
263+
mode = m
225264
start()
226265
}
227266
228-
// Auto-start when videoEl is available
229267
$effect(() => {
230268
if (videoEl) start()
231269
return () => stopAll()
@@ -235,18 +273,21 @@
235273
</script>
236274

237275
<div class="flex flex-col gap-3">
238-
<!-- Format switcher -->
239-
<div class="flex items-center gap-2">
240-
<span class="text-sm text-slate-500">{$_('player.format')}:</span>
241-
{#each (['webrtc', 'hls'] as Format[]) as f}
276+
<!-- Mode switcher -->
277+
<div class="flex items-center gap-2 flex-wrap">
278+
<span class="text-sm text-slate-500">{$_('player.quality')}:</span>
279+
{#each (['auto', 'webrtc', 'hls'] as Mode[]) as m}
242280
<button
243-
onclick={() => switchFormat(f)}
281+
onclick={() => switchMode(m)}
244282
class="px-3 py-1 text-xs rounded-full font-medium transition-colors
245-
{format === f ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
283+
{mode === m ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
246284
>
247-
{$_(`player.${f}`)}
285+
{$_(`player.mode_${m}`)}
248286
</button>
249287
{/each}
288+
{#if autoFallback}
289+
<span class="text-xs text-amber-600 italic">{$_('player.auto_downgraded')}</span>
290+
{/if}
250291
</div>
251292

252293
<!-- Video element -->

frontend/src/lib/i18n/en.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@
6969
"publish_srt": "SRT (push)"
7070
},
7171
"player": {
72-
"format": "Format",
73-
"webrtc": "WebRTC",
74-
"hls": "HLS",
72+
"quality": "Quality",
73+
"mode_auto": "Auto",
74+
"mode_webrtc": "Fast (WebRTC)",
75+
"mode_hls": "Stable (HLS)",
76+
"auto_downgraded": "switched to stable",
7577
"loading": "Loading stream...",
7678
"error": "Stream unavailable",
77-
"not_ready": "Stream not ready yet",
78-
"switch_format": "Switch Format"
79+
"not_ready": "Stream not ready yet"
7980
},
8081
"users": {
8182
"title": "Users",

frontend/src/lib/i18n/he.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@
6969
"publish_srt": "SRT (דחיפה)"
7070
},
7171
"player": {
72-
"format": "פורמט",
73-
"webrtc": "WebRTC",
74-
"hls": "HLS",
72+
"quality": "איכות",
73+
"mode_auto": "אוטומטי",
74+
"mode_webrtc": "מהיר (WebRTC)",
75+
"mode_hls": "יציב (HLS)",
76+
"auto_downgraded": "עבר למצב יציב",
7577
"loading": "טוען זרם...",
7678
"error": "הזרם אינו זמין",
77-
"not_ready": "הזרם עדיין לא מוכן",
78-
"switch_format": "החלף פורמט"
79+
"not_ready": "הזרם עדיין לא מוכן"
7980
},
8081
"users": {
8182
"title": "משתמשים",

frontend/src/routes/admin/Streams.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
expandedSaving = true
7575
try {
7676
const { isPublisher, ...fields } = expandedForm
77-
await streamsApi.update(expandedName, { ...fields, source: isPublisher ? '' : fields.source })
77+
await streamsApi.update(expandedName, { ...fields, source: isPublisher ? 'publisher' : fields.source })
7878
addToast('success', $_('streams.updated'))
7979
expandedName = null
8080
await load()
@@ -89,7 +89,7 @@
8989
addSaving = true
9090
try {
9191
const { isPublisher, ...fields } = addForm
92-
await streamsApi.create({ ...fields, source: isPublisher ? '' : fields.source })
92+
await streamsApi.create({ ...fields, source: isPublisher ? 'publisher' : fields.source })
9393
addToast('success', $_('streams.created'))
9494
showAddForm = false
9595
addForm = { name: '', description: '', source: '', isPublisher: false, sourceOnDemand: false, record: false, maxReaders: 0 }

0 commit comments

Comments
 (0)