Clicking a camera's snapshot pins a 192x108 tile anchored at the camera -- the live HLS stream when the camera has one (hls.js; Safari native), else the snapshot refreshed every 60s, falling back to the snapshot if the stream dies. Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay is on, so hiding it stops every stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
2.4 KiB
TypeScript
65 lines
2.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type HlsType from "hls.js";
|
|
import type { Ny511Camera } from "@/lib/types";
|
|
|
|
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
|
|
const SNAPSHOT_REFRESH_MS = 60_000;
|
|
|
|
/**
|
|
* 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
|
|
* a timer. A stream that fails falls back to the snapshot rather than a black box.
|
|
*/
|
|
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [videoFailed, setVideoFailed] = useState(false);
|
|
const [tick, setTick] = useState(() => Date.now());
|
|
const useVideo = !!camera.video_url && !videoFailed;
|
|
|
|
useEffect(() => {
|
|
if (useVideo) return;
|
|
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
|
|
return () => clearInterval(id);
|
|
}, [useVideo]);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
const src = camera.video_url;
|
|
if (!useVideo || !video || !src) return;
|
|
let hls: HlsType | null = null;
|
|
let cancelled = false;
|
|
|
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
video.src = src; // Safari/iOS play HLS natively
|
|
} else {
|
|
import("hls.js")
|
|
.then(({ default: Hls }) => {
|
|
if (cancelled) return;
|
|
if (!Hls.isSupported()) { setVideoFailed(true); return; }
|
|
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
|
|
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
|
|
hls.loadSource(src);
|
|
hls.attachMedia(video);
|
|
})
|
|
.catch(() => setVideoFailed(true));
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
hls?.destroy();
|
|
video.removeAttribute("src");
|
|
video.load(); // stop the download, not just the playback
|
|
};
|
|
}, [useVideo, camera.video_url]);
|
|
|
|
if (useVideo) {
|
|
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
|
|
}
|
|
if (!camera.image_url) {
|
|
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
|
|
}
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
|
|
}
|