From c1a2721fbec2bb2d5b47ddb82128a34895de6411 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 14:43:40 -0400 Subject: [PATCH] Map: pin 511NY camera feeds as tiles on the map (#183) 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 --- drb-frontend/app/globals.css | 12 +++++ drb-frontend/components/CameraFeed.tsx | 64 ++++++++++++++++++++++++++ drb-frontend/components/MapView.tsx | 43 +++++++++++++++-- drb-frontend/package.json | 1 + 4 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 drb-frontend/components/CameraFeed.tsx diff --git a/drb-frontend/app/globals.css b/drb-frontend/app/globals.css index 4dbea4f..93a7262 100644 --- a/drb-frontend/app/globals.css +++ b/drb-frontend/app/globals.css @@ -177,6 +177,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; } z-index: 0; } +/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip + * padding and let the tile's own theme colours show, so the tile is no bigger + * than the video it holds. */ +.leaflet-tooltip.drb-cam-pin { + padding: 0; + border-radius: 6px; + background: var(--surface); + border: 1px solid var(--line-strong); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); + white-space: normal; +} + /* ── Form inputs ─────────────────────────────────────────────────────────── */ html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) select, diff --git a/drb-frontend/components/CameraFeed.tsx b/drb-frontend/components/CameraFeed.tsx new file mode 100644 index 0000000..c53d23c --- /dev/null +++ b/drb-frontend/components/CameraFeed.tsx @@ -0,0 +1,64 @@ +"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(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