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;