|
41 | 41 | } |
42 | 42 | let { streamName, info }: Props = $props() |
43 | 43 |
|
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) |
46 | 47 | let videoEl = $state<HTMLVideoElement | null>(null) |
47 | 48 | let status = $state<'loading' | 'playing' | 'error' | 'idle'>('idle') |
48 | 49 | let errorMsg = $state('') |
49 | 50 |
|
50 | 51 | let hlsInstance: Hls | null = null |
51 | 52 | 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 |
52 | 58 |
|
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. |
55 | 59 | const resolvedHost = $derived( |
56 | 60 | (info.mediamtxHost === 'localhost' || info.mediamtxHost === '127.0.0.1') |
57 | 61 | ? window.location.hostname |
58 | 62 | : info.mediamtxHost |
59 | 63 | ) |
60 | | -
|
61 | | - // Match the page scheme so browsers don't block mixed content when served over HTTPS. |
62 | 64 | 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}`) |
63 | 67 |
|
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 | + } |
70 | 112 |
|
71 | 113 | function stopAll() { |
| 114 | + stopQualityMonitor() |
72 | 115 | if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null } |
73 | 116 | if (rtcPeer) { rtcPeer.close(); rtcPeer = null } |
74 | 117 | if (videoEl) { videoEl.srcObject = null; videoEl.src = '' } |
|
84 | 127 | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], |
85 | 128 | }) |
86 | 129 | rtcPeer = pc |
87 | | -
|
88 | | - // Helper: bail out silently if this pc was superseded by stopAll() |
89 | 130 | const stale = () => pc.signalingState === 'closed' |
90 | 131 |
|
91 | 132 | pc.addTransceiver('video', { direction: 'recvonly' }) |
92 | 133 | pc.addTransceiver('audio', { direction: 'recvonly' }) |
93 | 134 |
|
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. |
97 | 135 | const remoteStream = new MediaStream() |
98 | 136 | videoEl.srcObject = remoteStream |
99 | 137 | let playStarted = false |
|
121 | 159 | if (stale()) return |
122 | 160 | await pc.setLocalDescription(offer) |
123 | 161 |
|
124 | | - // Wait for ICE gathering to complete so all candidates are in the SDP |
125 | 162 | await new Promise<void>(resolve => { |
126 | 163 | if (pc.iceGatheringState === 'complete') { resolve(); return } |
127 | 164 | const onStateChange = () => { |
|
131 | 168 | } |
132 | 169 | } |
133 | 170 | pc.addEventListener('icegatheringstatechange', onStateChange) |
134 | | - // Safety timeout: send after 5s even if gathering isn't done |
135 | 171 | setTimeout(resolve, 5000) |
136 | 172 | }) |
137 | 173 |
|
|
195 | 231 | } |
196 | 232 | }) |
197 | 233 | } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { |
198 | | - // Safari native HLS |
199 | 234 | videoEl.src = hlsUrl |
200 | 235 | videoEl.muted = true |
201 | 236 | videoEl.play().then(() => status = 'playing').catch(e => { |
|
208 | 243 | } |
209 | 244 |
|
210 | 245 | 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 |
219 | 256 | await startHLS() |
| 257 | + } else if (status === 'playing' && mode === 'auto') { |
| 258 | + startQualityMonitor() |
220 | 259 | } |
221 | 260 | } |
222 | 261 |
|
223 | | - function switchFormat(f: Format) { |
224 | | - format = f |
| 262 | + function switchMode(m: Mode) { |
| 263 | + mode = m |
225 | 264 | start() |
226 | 265 | } |
227 | 266 |
|
228 | | - // Auto-start when videoEl is available |
229 | 267 | $effect(() => { |
230 | 268 | if (videoEl) start() |
231 | 269 | return () => stopAll() |
|
235 | 273 | </script> |
236 | 274 |
|
237 | 275 | <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} |
242 | 280 | <button |
243 | | - onclick={() => switchFormat(f)} |
| 281 | + onclick={() => switchMode(m)} |
244 | 282 | 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'}" |
246 | 284 | > |
247 | | - {$_(`player.${f}`)} |
| 285 | + {$_(`player.mode_${m}`)} |
248 | 286 | </button> |
249 | 287 | {/each} |
| 288 | + {#if autoFallback} |
| 289 | + <span class="text-xs text-amber-600 italic">{$_('player.auto_downgraded')}</span> |
| 290 | + {/if} |
250 | 291 | </div> |
251 | 292 |
|
252 | 293 | <!-- Video element --> |
|
0 commit comments