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>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|