"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(); 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): 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(); 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(null); const videoRef = useRef(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(null); const [corsOk, setCorsOk] = useState(true); const [lastGoodAt, setLastGoodAt] = useState(null); const [frameSince, setFrameSince] = useState(null); // when the shown frame first appeared const [stale, setStale] = useState(false); const [now, setNow] = useState(() => Date.now()); const sigRef = useRef(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 (
{useVideo ? (
); }