CameraFeed: recover from HLS blips, retry video instead of sticking on the still
Build & Deploy / Build & push images (push) Successful in 4m12s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m35s
Build & Deploy / Report a failed deploy (push) Skipped

Any fatal hls.js error set videoFailed permanently, so a brief 511NY feed
drop left the tile on a 60s snapshot until it was reopened. Now fatal
media/network errors get up to 3 in-place recoveries, and a tile that did
fall back retries video every 30s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 23:06:58 -04:00
co-authored by Claude Opus 5.5
parent e2364096f1
commit fb299008f8
+22 -2
View File
@@ -6,11 +6,17 @@ import type { Ny511Camera } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing. // 511NY stills are served with max-age=60, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000; const SNAPSHOT_REFRESH_MS = 60_000;
// A failed stream is not given up on for good: 511NY feeds drop out briefly,
// and a permanent fallback left pinned tiles showing a stale still image.
const VIDEO_RETRY_MS = 30_000;
// In-place recoveries hls.js gets per session before we fall back to the snapshot.
const MAX_HLS_RECOVERIES = 3;
/** /**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's * One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on * skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box. * a timer. A stream that fails falls back to the snapshot rather than a black box,
* and video is retried every VIDEO_RETRY_MS while it stays down.
*/ */
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) { export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@@ -24,6 +30,12 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
return () => clearInterval(id); return () => clearInterval(id);
}, [useVideo]); }, [useVideo]);
useEffect(() => {
if (!videoFailed || !camera.video_url) return;
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
return () => clearTimeout(id);
}, [videoFailed, camera.video_url]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
const src = camera.video_url; const src = camera.video_url;
@@ -39,7 +51,15 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
if (cancelled) return; if (cancelled) return;
if (!Hls.isSupported()) { setVideoFailed(true); return; } if (!Hls.isSupported()) { setVideoFailed(true); return; }
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 }); hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); }); let recoveries = 0;
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal || !hls) return;
if (recoveries++ < MAX_HLS_RECOVERIES) {
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; }
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { hls.startLoad(); return; }
}
setVideoFailed(true);
});
hls.loadSource(src); hls.loadSource(src);
hls.attachMedia(video); hls.attachMedia(video);
}) })