c2e5c1d9dc
- /photo full-screen slideshow (crossfade, blurred backdrop or fill, shuffle, date) - admin Photos tab: add shared-album links (check, sync now, status/stale warnings), slideshow options, browser-resized uploads - 6-hourly album sync scraping the share page (Library API no longer exposes user albums), caches 1920px + 64px copies in /data/photos - /api/health/photos -> 503 when an album has not synced for 48h - history/restore covers photo settings; min admin password length 6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
/* eslint-disable @next/next/no-img-element -- plain <img> 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<T>(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<void>((resolve) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve();
|
|
image.onerror = () => resolve();
|
|
image.src = src;
|
|
});
|
|
}
|
|
|
|
export default function PhotoFrame() {
|
|
const [show, setShow] = useState<Slideshow | null>(null);
|
|
const [layers, setLayers] = useState<Layer[]>([]);
|
|
const [failed, setFailed] = useState(false);
|
|
const queue = useRef<SlidePhoto[]>([]);
|
|
const versionRef = useRef("");
|
|
const showRef = useRef<Slideshow | null>(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 (
|
|
<div className="fixed inset-0 overflow-hidden bg-black" style={{ cursor: "none" }}>
|
|
{layers.map((layer) => (
|
|
<div
|
|
key={layer.id}
|
|
className="absolute inset-0"
|
|
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both` }}
|
|
>
|
|
{fit === "contain-blur" && (
|
|
<img
|
|
src={layer.photo.thumb}
|
|
alt=""
|
|
aria-hidden
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
|
|
/>
|
|
)}
|
|
<img
|
|
src={layer.photo.src}
|
|
alt=""
|
|
className={`absolute inset-0 h-full w-full ${fit === "cover" ? "object-cover" : "object-contain"}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{showDate && top?.photo.takenAt ? (
|
|
<div
|
|
key={`date-${top.id}`}
|
|
className="absolute bottom-6 right-8 rounded-full bg-black/35 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85"
|
|
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both`, textShadow: "0 1px 4px rgb(0 0 0 / 60%)" }}
|
|
>
|
|
{formatTaken(top.photo.takenAt)}
|
|
</div>
|
|
) : null}
|
|
|
|
{show && !show.photos.length && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-center text-2xl text-white/60">
|
|
No photos yet. Add a Google Photos album in the admin screen.
|
|
</div>
|
|
)}
|
|
{failed && !layers.length && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-2xl text-white/60">Can't reach the photo server…</div>
|
|
)}
|
|
|
|
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
|
|
</div>
|
|
);
|
|
}
|