/* eslint-disable @next/next/no-img-element -- plain is intended: same-origin cached JPEGs, no optimizer */ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number }; type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean }; type Slideshow = { display: Display; photos: SlidePhoto[]; version: string }; type Layer = { photo: SlidePhoto; id: number }; const POLL_MS = 30_000; const FADE_MS = 1_400; const FULL_RELOAD_MS = 12 * 60 * 60 * 1000; const TIME_ZONE = "America/Chicago"; function shuffled(values: T[]) { const copy = [...values]; for (let index = copy.length - 1; index > 0; index -= 1) { const other = Math.floor(Math.random() * (index + 1)); [copy[index], copy[other]] = [copy[other], copy[index]]; } return copy; } 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)); } function preload(src: string) { return new Promise((resolve) => { const image = new Image(); image.onload = () => resolve(); image.onerror = () => resolve(); image.src = src; }); } export default function PhotoFrame() { const [show, setShow] = useState(null); const [layers, setLayers] = useState([]); const [failed, setFailed] = useState(false); const queue = useRef([]); const versionRef = useRef(""); const showRef = useRef(null); const layerId = useRef(0); // The newest layer's src, readable from callbacks without depending on layers state. const topSrc = useRef(""); const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => { let next = shuffle ? shuffled(photos) : [...photos]; if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]]; queue.current = next; }, []); const advance = useCallback(async () => { const current = showRef.current; if (!current || !current.photos.length) return; if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current); const photo = queue.current.shift(); if (!photo) return; await preload(photo.src); layerId.current += 1; const id = layerId.current; setLayers((existing) => [...existing.slice(-1), { photo, id }]); }, [refill]); useEffect(() => { topSrc.current = layers[layers.length - 1]?.photo.src ?? ""; }, [layers]); // Poll settings + photo list; rebuild the queue only when something changed. useEffect(() => { let cancelled = false; const poll = 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 Slideshow; if (cancelled) return; setFailed(false); if (next.version === versionRef.current) return; const first = !versionRef.current; versionRef.current = next.version; showRef.current = next; setShow(next); refill(next.photos, next.display.shuffle, topSrc.current); if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance(); } catch { if (!cancelled) setFailed(true); } }; void poll(); const timer = window.setInterval(poll, POLL_MS); const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS); return () => { cancelled = true; window.clearInterval(timer); window.clearTimeout(reload); }; }, [advance, refill]); const intervalSeconds = show?.display.intervalSeconds ?? 20; const photoCount = show?.photos.length ?? 0; useEffect(() => { if (photoCount < 2) return; const timer = window.setInterval(() => void advance(), intervalSeconds * 1000); return () => window.clearInterval(timer); }, [advance, intervalSeconds, photoCount]); const fit = show?.display.fit ?? "contain-blur"; const showDate = show?.display.showDate ?? true; const top = layers[layers.length - 1]; return (
{layers.map((layer) => (
{fit === "contain-blur" && ( )}
))} {showDate && top?.photo.takenAt ? (
{formatTaken(top.photo.takenAt)}
) : null} {show && !show.photos.length && (
No photos yet. Add a Google Photos album in the admin screen.
)} {failed && !layers.length && (
Can't reach the photo server…
)}
); }