/* eslint-disable @next/next/no-img-element -- plain is intended: same-origin cached JPEGs, no optimizer */ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; /* * Gallery for the Shield photo app's launcher tile: a grid of tiles driven by the remote's * D-pad (arrow keys), OK (Enter) opens a photo full screen, Back returns to the grid. * Android's Back key never reaches the page; the app maps it to WebView history, so opening * a photo pushes a history entry and closing it pops that entry. */ type GalleryPhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number }; type Payload = { photos: GalleryPhoto[]; version: string }; const REFRESH_MS = 5 * 60 * 1000; /** With no key press for this long, start the slideshow (the app keeps the screen on). */ const IDLE_SLIDESHOW_MS = 10 * 60 * 1000; const TILE_TARGET_PX = 300; const SLIDESHOW = -1; const TIME_ZONE = "America/Chicago"; function formatTaken(takenAt: number | null) { if (!takenAt) return ""; return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt)); } /** Newest first; photos without a date keep their album order at the end. */ function sortPhotos(photos: GalleryPhoto[]) { return photos .map((photo, position) => ({ photo, position })) .sort((left, right) => (right.photo.takenAt ?? -1) - (left.photo.takenAt ?? -1) || left.position - right.position) .map((entry) => entry.photo); } function useColumns() { const [columns, setColumns] = useState(6); useEffect(() => { const update = () => setColumns(Math.max(2, Math.min(8, Math.round(window.innerWidth / TILE_TARGET_PX)))); update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); return columns; } export default function PhotoGallery() { const [photos, setPhotos] = useState(null); const [failed, setFailed] = useState(false); const [focus, setFocus] = useState(0); const [viewer, setViewer] = useState(null); const columns = useColumns(); const versionRef = useRef(""); const focusSrc = useRef(""); const tileRefs = useRef<(HTMLDivElement | null)[]>([]); const slideshowRef = useRef(null); const scrollerRef = useRef(null); const viewerOpenedHere = useRef(false); // Load (and occasionally refresh) the list, keeping focus on the same photo. useEffect(() => { let cancelled = false; const load = async () => { try { const response = await fetch("/api/photos", { cache: "no-store" }); if (!response.ok) throw new Error(String(response.status)); const next = (await response.json()) as Payload; if (cancelled) return; setFailed(false); if (next.version === versionRef.current) return; versionRef.current = next.version; const sorted = sortPhotos(next.photos); setPhotos(sorted); const kept = sorted.findIndex((photo) => photo.src === focusSrc.current); setFocus((current) => (current === SLIDESHOW ? SLIDESHOW : kept >= 0 ? kept : Math.min(current, Math.max(0, sorted.length - 1)))); setViewer((current) => (current === null ? null : kept >= 0 ? kept : null)); } catch { if (!cancelled) setFailed(true); } }; void load(); const timer = window.setInterval(load, REFRESH_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, []); const count = photos?.length ?? 0; useEffect(() => { focusSrc.current = photos && focus >= 0 ? (photos[focus]?.src ?? "") : ""; }, [photos, focus]); // Keep the focused tile on screen. useEffect(() => { if (viewer !== null) return; // On the top row (or the Slideshow button) show the whole header, not just the focused item. if (focus < columns) { scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); return; } const element = focus === SLIDESHOW ? slideshowRef.current : tileRefs.current[focus]; element?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [focus, viewer, columns]); const openViewer = useCallback((index: number) => { window.history.pushState({ hfoViewer: true }, ""); viewerOpenedHere.current = true; setViewer(index); }, []); const closeViewer = useCallback(() => { if (viewerOpenedHere.current) window.history.back(); else setViewer(null); }, []); // Back (the app turns the remote's Back into history.back()) closes the viewer. useEffect(() => { const onPop = () => { viewerOpenedHere.current = false; setViewer((current) => { if (current !== null) setFocus(current); return null; }); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); const startSlideshow = useCallback(() => { // A full page load on purpose: the slideshow is a separate page with its own 12h self-reload, // and the Shield app's Back key then returns here through WebView history. // eslint-disable-next-line @next/next/no-location-assign-relative-destination window.location.assign("/photo"); }, []); // Idle -> slideshow. const idleTimer = useRef(undefined); const resetIdle = useCallback(() => { window.clearTimeout(idleTimer.current); idleTimer.current = window.setTimeout(startSlideshow, IDLE_SLIDESHOW_MS); }, [startSlideshow]); useEffect(() => { resetIdle(); return () => window.clearTimeout(idleTimer.current); }, [resetIdle]); useEffect(() => { const onKey = (event: KeyboardEvent) => { resetIdle(); const key = event.key; const handled = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", " ", "Escape", "Backspace"].includes(key); if (!handled) return; event.preventDefault(); if (!count && key !== "Enter") return; if (viewer !== null) { if (key === "ArrowRight") setViewer((viewer + 1) % count); else if (key === "ArrowLeft") setViewer((viewer - 1 + count) % count); else if (key === "Enter" || key === " " || key === "Escape" || key === "Backspace") closeViewer(); return; } if (key === "Enter" || key === " ") { if (focus === SLIDESHOW || !count) startSlideshow(); else openViewer(focus); return; } setFocus((current) => { if (current === SLIDESHOW) return key === "ArrowDown" || key === "ArrowRight" ? 0 : SLIDESHOW; if (key === "ArrowRight") return Math.min(count - 1, current + 1); if (key === "ArrowLeft") return Math.max(0, current - 1); if (key === "ArrowDown") return Math.min(count - 1, current + columns); if (key === "ArrowUp") return current - columns < 0 ? SLIDESHOW : current - columns; return current; }); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [closeViewer, columns, count, focus, openViewer, resetIdle, startSlideshow, viewer]); const focused = photos && focus >= 0 ? photos[focus] : undefined; const shown = photos && viewer !== null ? photos[viewer] : undefined; // Neighbours to prefetch while the viewer is open, so ◀ ▶ feel instant. const neighbours = useMemo(() => { if (!photos || viewer === null || count < 2) return []; return [photos[(viewer + 1) % count], photos[(viewer - 1 + count) % count]]; }, [photos, viewer, count]); return (

Family Photos

{photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`} {focused?.takenAt ? · {formatTaken(focused.takenAt)} : null}

▶ Slideshow
{photos && !count && (
No photos yet. Add a Google Photos album in the admin screen.
)}
{photos?.map((photo, index) => { const active = index === focus && viewer === null; return (
{ tileRefs.current[index] = element; }} onClick={() => { setFocus(index); openViewer(index); }} className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-white/5 transition-all duration-150 ${ active ? "z-10 scale-[1.08] shadow-[0_0_0_4px_#fff,0_18px_40px_rgb(0_0_0/0.6)]" : "opacity-80" }`} >
); })}
{count > 0 && (

Arrows to move · OK to open · Back to return

)}
{shown && (
{/* The tile is already cached, so it shows at once while the full photo loads on top. */}
◀ {(viewer ?? 0) + 1} / {count} ▶
{shown.takenAt ? (
{formatTaken(shown.takenAt)}
) : null}
)} {neighbours.map((photo) => ( ))}
); }