Files
server-26/drb-frontend/components/CameraFeed.tsx
Logan CusanoandClaude Opus 5.5 bf206334e3 fix(frontend): finish the token migration — raw grey classes -> theme tokens
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.

Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].

Frontend unverified: no Node on the authoring box (#207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:29:13 -04:00

299 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import type HlsType from "hls.js";
import type { Camera, CameraCredit } from "@/lib/types";
// ── Feed hygiene (design-canvas/gods-eye-view-notes.md §2, top-5 #2) ─────────
// A 24/7 wall must not burn bandwidth on feeds nobody can see:
// * hidden tab or off-screen tile → no fetches, live video torn down;
// * at most MAX_LIVE_HLS live decoders page-wide (GEV uses 2); extra tiles
// show their still with a "live cap" badge (the drill-down gets one extra);
// * a failed refresh keeps the last good frame with a "stale Nm" badge;
// * a 64×36 downsample signature detects a frozen camera (same frame for
// FROZEN_AFTER_MS) without repainting it; a stalled HLS stream falls back.
// Video is played straight from the agency's host (no DRB egress).
// Most stills are cached ~60 s upstream, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
const VIDEO_RETRY_MS = 30_000;
const MAX_HLS_RECOVERIES = 3;
const VIDEO_STALL_MS = 20_000;
const FROZEN_AFTER_MS = 20 * 60_000; // > the slowest provider cadence (Fintraffic ~10 min)
export const MAX_LIVE_HLS = 2;
// ── Page-wide live-stream leases ─────────────────────────────────────────────
const holders = new Set<number>();
const leaseListeners = new Set<() => void>();
let nextLeaseId = 1;
function tryAcquire(id: number, high: boolean): boolean {
if (holders.has(id)) return true;
if (holders.size < MAX_LIVE_HLS + (high ? 1 : 0)) {
holders.add(id);
return true;
}
return false;
}
function release(id: number) {
if (holders.delete(id)) leaseListeners.forEach((l) => l());
}
function useLiveLease(want: boolean, high: boolean): boolean {
const idRef = useRef(0);
if (!idRef.current) idRef.current = nextLeaseId++;
const [granted, setGranted] = useState(false);
useEffect(() => {
const id = idRef.current;
if (!want) { release(id); setGranted(false); return; }
const attempt = () => setGranted(tryAcquire(id, high));
attempt();
leaseListeners.add(attempt);
return () => { leaseListeners.delete(attempt); release(id); };
}, [want, high]);
return granted;
}
// ── Visibility: tab shown AND element on screen ──────────────────────────────
function useOnScreen(ref: RefObject<HTMLElement | null>): boolean {
const [tabVisible, setTabVisible] = useState(true);
const [inView, setInView] = useState(true);
useEffect(() => {
const onVis = () => setTabVisible(!document.hidden);
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { rootMargin: "100px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return tabVisible && inView;
}
// Origins whose stills refuse CORS: shown without crossOrigin, no signature.
const noCorsOrigins = new Set<string>();
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return url; }
}
function withBust(url: string, t: number): string {
return `${url}${url.includes("?") ? "&" : "?"}t=${t}`;
}
function signature(img: HTMLImageElement): number[] | null {
try {
const c = document.createElement("canvas");
c.width = 64;
c.height = 36;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, 64, 36);
const d = ctx.getImageData(0, 0, 64, 36).data;
const out: number[] = [];
for (let i = 0; i < d.length; i += 4) out.push((d[i] + d[i + 1] + d[i + 2]) / 3);
return out;
} catch {
return null; // tainted canvas
}
}
function sameFrame(a: number[] | null, b: number[] | null): boolean {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / a.length < 1.5;
}
function ageLabel(ms: number): string {
const m = Math.floor(ms / 60_000);
return m < 1 ? "<1m" : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
}
function clock(t: number): string {
return new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
interface Props {
camera: Camera;
className?: string;
/** "still" never starts video (popups, thumbnails). */
mode?: "auto" | "still";
/** The drill-down camera may take one stream past the page-wide cap. */
priority?: "normal" | "high";
/** Required attribution for this provider — rendered on the imagery itself. */
credit?: CameraCredit | null;
}
/**
* One public camera: live HLS when the camera has a stream and a lease is
* free, otherwise its still on a timer — with honest state badges and the
* provider's attribution on the frame. A stream that fails or stalls falls
* back to the still and is retried every VIDEO_RETRY_MS.
*/
export function CameraFeed({ camera, className, mode = "auto", priority = "normal", credit }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const onScreen = useOnScreen(rootRef);
const [videoFailed, setVideoFailed] = useState(false);
const wantsVideo = mode === "auto" && !!camera.video_url && !videoFailed && onScreen;
const leased = useLiveLease(wantsVideo, priority === "high");
const useVideo = wantsVideo && leased;
const capped = wantsVideo && !leased;
// Still state
const [shownSrc, setShownSrc] = useState<string | null>(null);
const [corsOk, setCorsOk] = useState(true);
const [lastGoodAt, setLastGoodAt] = useState<number | null>(null);
const [frameSince, setFrameSince] = useState<number | null>(null); // when the shown frame first appeared
const [stale, setStale] = useState(false);
const [now, setNow] = useState(() => Date.now());
const sigRef = useRef<number[] | null>(null);
// Reset when the camera changes
useEffect(() => {
setShownSrc(null); setLastGoodAt(null); setFrameSince(null); setStale(false); setVideoFailed(false);
sigRef.current = null;
}, [camera.id]);
useEffect(() => {
const id = setInterval(() => { if (!document.hidden) setNow(Date.now()); }, 15_000);
return () => clearInterval(id);
}, []);
// Still polling — only while on screen and not playing video
useEffect(() => {
const url = camera.image_url;
if (useVideo || !onScreen || !url) return;
let cancelled = false;
let probe: HTMLImageElement | null = null;
const load = (allowCors: boolean) => {
const origin = originOf(url);
const cors = allowCors && !noCorsOrigins.has(origin);
const src = withBust(url, Date.now());
probe = new Image();
if (cors) probe.crossOrigin = "anonymous";
probe.onload = () => {
if (cancelled || !probe) return;
const t = Date.now();
const sig = cors ? signature(probe) : null;
const unchanged = sameFrame(sig, sigRef.current);
sigRef.current = sig;
setStale(false);
setLastGoodAt(t);
setCorsOk(cors);
if (!unchanged) {
setShownSrc(src);
setFrameSince(t);
}
};
probe.onerror = () => {
if (cancelled) return;
if (cors) { noCorsOrigins.add(origin); load(false); return; }
setStale(true); // keep the last good frame on screen
};
probe.src = src;
};
load(true);
const id = setInterval(() => load(true), SNAPSHOT_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
if (probe) { probe.onload = null; probe.onerror = null; probe.src = ""; } // abort in-flight
};
}, [camera.image_url, useVideo, onScreen]);
// Retry a failed stream later
useEffect(() => {
if (!videoFailed || !camera.video_url) return;
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
return () => clearTimeout(id);
}, [videoFailed, camera.video_url]);
// HLS player + stall watchdog
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 });
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.attachMedia(video);
})
.catch(() => setVideoFailed(true));
}
let lastT = -1;
let lastMove = Date.now();
const watchdog = setInterval(() => {
if (video.currentTime !== lastT) { lastT = video.currentTime; lastMove = Date.now(); return; }
if (Date.now() - lastMove > VIDEO_STALL_MS) setVideoFailed(true); // frozen stream → still
}, 5_000);
return () => {
cancelled = true;
clearInterval(watchdog);
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
// ── Badge ──
let badge: { text: string; tone: "live" | "ok" | "warn" | "err" };
if (useVideo) badge = { text: "LIVE", tone: "live" };
else if (!camera.image_url) badge = { text: capped ? "LIVE CAP · STREAM PAUSED" : camera.video_url ? "STREAM DOWN" : "NO IMAGE", tone: "err" };
else if (!shownSrc) badge = { text: stale ? "FRAME · UNAVAILABLE" : "FRAME · LOADING", tone: stale ? "err" : "warn" };
else if (stale && lastGoodAt) badge = { text: `STALE · ${ageLabel(now - lastGoodAt)}`, tone: "warn" };
else if (frameSince && now - frameSince > FROZEN_AFTER_MS) badge = { text: `FROZEN? · unchanged ${ageLabel(now - frameSince)}`, tone: "warn" };
else badge = { text: `STILL · ${frameSince ? clock(frameSince) : "—"}${capped ? " · LIVE CAP" : ""}`, tone: "ok" };
const toneClass = {
live: "bg-red-600/90 text-white",
ok: "bg-black/65 text-white",
warn: "bg-amber-500/90 text-black",
err: "bg-black/75 text-red-300",
}[badge.tone];
return (
<div ref={rootRef} className={`relative overflow-hidden ${className ?? ""}`}>
{useVideo ? (
<video ref={videoRef} muted autoPlay playsInline className="w-full h-full object-cover" onError={() => setVideoFailed(true)} />
) : shownSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shownSrc} crossOrigin={corsOk ? "anonymous" : undefined} alt={`Camera: ${camera.name}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] text-ink-muted bg-black/60">
{camera.image_url ? "…" : "No still for this camera"}
</div>
)}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text}
</span>
{credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
{credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution}
</span>
)}
</div>
);
}