From a68c55e009eace19151678136df68715ffaadeae Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 24 Jun 2026 15:12:55 -0500 Subject: [PATCH] fix(player): map MediaError codes, stop hls on 404, log autoplay rejections, cap reconnect loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small error-handling fixes in the player files: 1. video.error.code -> user-readable message The 'error' event handler surfaced a generic 'Video element error' for every MediaError. Map the four codes (NETWORK, DECODE, SRC_NOT_SUPPORTED) to actionable messages, and skip MEDIA_ERR_ABORTED (fires on deliberate tear-downs, not real errors). 2. hls.stopLoad() on 404 The 404 branch reported 'Stream not live' but kept hls.js running, so it kept polling the dead URL on its internal schedule. Stop loading after the 404. 3. console.warn on silent play() catches Three sites had .catch(() => {}) for video.play(): the click-to-play handler, WebRTC track attach, and Safari-native HLS. The HLS manifest path already had a warn; the others didn't. Autoplay blocking is a real user-facing failure mode and the user is staring at a black screen — at least surface it in dev. 4. WebRTC reconnect max-retry scheduleReconnect called itself forever on a dead stream, emitting the same error string on every attempt. Cap at 15 attempts (~45s of retries at the default 3s delay). Reset the counter to zero on the first track event, so a stream that works for a while and then drops gets a fresh retry budget. After the cap, surface 'Stream unavailable — stopped reconnecting' as a terminal error. --- js/web/src/components/player/hls-player.tsx | 5 +++- js/web/src/components/player/player.tsx | 20 +++++++++++++-- .../src/components/player/webrtc-player.tsx | 25 ++++++++++++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/js/web/src/components/player/hls-player.tsx b/js/web/src/components/player/hls-player.tsx index 2ed0f67b..54f21e28 100644 --- a/js/web/src/components/player/hls-player.tsx +++ b/js/web/src/components/player/hls-player.tsx @@ -111,6 +111,7 @@ export function HLSPlayer({ const status = (data.response as Response | undefined)?.status; if (status === 404) { onError?.("Stream not live"); + hls.stopLoad(); return; } switch (data.type) { @@ -145,7 +146,9 @@ export function HLSPlayer({ } else if (video.canPlayType("application/vnd.apple.mpegurl")) { video.src = src; const onCanPlay = () => { - video.play().catch(() => {}); + video + .play() + .catch((err) => console.warn("[hls-player] play() rejected", err)); video.removeEventListener("canplay", onCanPlay); }; video.addEventListener("canplay", onCanPlay); diff --git a/js/web/src/components/player/player.tsx b/js/web/src/components/player/player.tsx index 657d330f..894526c8 100644 --- a/js/web/src/components/player/player.tsx +++ b/js/web/src/components/player/player.tsx @@ -223,7 +223,21 @@ export function Player({ onPlaying?.(); }; const onLoadedMetadata = () => setManifestReady(true); - const onErrorEvt = () => surfaceError("Video element error"); + const onErrorEvt = () => { + const code = video.error?.code; + // MediaError code 1 (MEDIA_ERR_ABORTED) fires when the user or + // a backend tears down the source — not a real error to surface. + if (code === 1) return; + surfaceError( + code === 2 + ? "Network error" + : code === 3 + ? "Playback error" + : code === 4 + ? "Stream format not supported" + : "Playback error", + ); + }; video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); @@ -287,7 +301,9 @@ export function Player({ const video = videoRef.current; if (!video) return; if (video.paused) { - video.play().catch(() => {}); + video + .play() + .catch((err) => console.warn("[player] play() rejected", err)); } else { video.pause(); } diff --git a/js/web/src/components/player/webrtc-player.tsx b/js/web/src/components/player/webrtc-player.tsx index 7ddf10e9..2be0765e 100644 --- a/js/web/src/components/player/webrtc-player.tsx +++ b/js/web/src/components/player/webrtc-player.tsx @@ -29,6 +29,11 @@ const RECONNECT_DELAY_MS = 3000; const STUCK_THRESHOLD_MS = 2000; const ICE_GATHERING_TIMEOUT_MS = 1000; const STATS_POLL_MS = 1000; +// After this many consecutive failed reconnect attempts, give up and +// surface a terminal error instead of looping. Picked to give transient +// network blips time to clear (e.g. ~45s at the default 3s delay) but +// stop hammering a clearly-broken stream. +const MAX_RECONNECT_ATTEMPTS = 15; /** * Extracts the streamer handle/DID from a Streamplace playlist URL. @@ -67,6 +72,8 @@ export function WebRTCPlayer({ const pcRef = useRef(null); const statsIntervalRef = useRef | null>(null); const reconnectTimerRef = useRef | null>(null); + const reconnectAttemptsRef = useRef(0); + const gaveUpRef = useRef(false); const agentRef = useRef(null); // WebRTC doesn't have quality levels — expose a no-op so the chrome @@ -125,11 +132,17 @@ export function WebRTCPlayer({ // When tracks arrive, attach the first stream to the video element. peerConnection.addEventListener("track", (event) => { if (cancelled) return; + // First media arriving means the connection actually works. If + // we later stall and have to reconnect, start the failure count + // from scratch — this isn't a string of broken attempts. + reconnectAttemptsRef.current = 0; if (event.streams && event.streams[0]) { const v = videoRef.current; if (v) { v.srcObject = event.streams[0]; - v.play().catch(() => {}); + v.play().catch((err) => + console.warn("[webrtc-player] play() rejected", err), + ); } } }); @@ -176,11 +189,17 @@ export function WebRTCPlayer({ const pendingCleanup = { current: null as (() => void) | null }; function scheduleReconnect() { - if (cancelled) return; + if (cancelled || gaveUpRef.current) return; if (reconnectTimerRef.current) return; + reconnectAttemptsRef.current += 1; + if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) { + gaveUpRef.current = true; + onErrorRef.current?.("Stream unavailable — stopped reconnecting"); + return; + } reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; - if (!cancelled && activeRef.current) { + if (!cancelled && activeRef.current && !gaveUpRef.current) { // Tear down old connection, spin up a new one. pcRef.current?.close(); pcRef.current = null; -- 2.51.2