00e0cc42cf
- /photo/gallery: D-pad grid, OK opens full screen, arrows browse, Back returns - 400px gallery tiles cached per photo (sync + uploads) - com.dayline.photos 1.1: launcher tile opens the gallery, takes remote input, Back steps through WebView history; screensaver unchanged - slideshow interval 20s -> 15s (live settings) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
/* eslint-disable @next/next/no-img-element -- plain <img> 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<GalleryPhoto[] | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
const [focus, setFocus] = useState(0);
|
|
const [viewer, setViewer] = useState<number | null>(null);
|
|
const columns = useColumns();
|
|
const versionRef = useRef("");
|
|
const focusSrc = useRef("");
|
|
const tileRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const slideshowRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollerRef = useRef<HTMLDivElement | null>(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<number | undefined>(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 (
|
|
<div className="fixed inset-0 overflow-hidden bg-neutral-950 text-white" style={{ cursor: viewer === null ? "default" : "none" }}>
|
|
<div ref={scrollerRef} className="h-full overflow-y-auto px-14 pb-16 pt-10" style={{ scrollPaddingTop: 40, scrollPaddingBottom: 56 }}>
|
|
<header className="mb-8 flex items-center gap-6">
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-4xl font-semibold tracking-tight">Family Photos</h1>
|
|
<p className="mt-1 text-lg text-white/55">
|
|
{photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`}
|
|
{focused?.takenAt ? <span className="text-white/80"> · {formatTaken(focused.takenAt)}</span> : null}
|
|
</p>
|
|
</div>
|
|
<div
|
|
ref={slideshowRef}
|
|
onClick={startSlideshow}
|
|
className={`flex cursor-pointer items-center gap-3 rounded-full px-7 py-3 text-xl font-semibold transition-all duration-150 ${
|
|
focus === SLIDESHOW ? "scale-105 bg-white text-neutral-950 shadow-[0_0_0_4px_rgb(255_255_255/0.35)]" : "bg-white/10 text-white/85"
|
|
}`}
|
|
>
|
|
<span aria-hidden>▶</span> Slideshow
|
|
</div>
|
|
</header>
|
|
|
|
{photos && !count && (
|
|
<div className="mt-24 text-center text-2xl text-white/60">No photos yet. Add a Google Photos album in the admin screen.</div>
|
|
)}
|
|
|
|
<div className="grid gap-5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
{photos?.map((photo, index) => {
|
|
const active = index === focus && viewer === null;
|
|
return (
|
|
<div
|
|
key={photo.src}
|
|
ref={(element) => {
|
|
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"
|
|
}`}
|
|
>
|
|
<img src={photo.tile} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{count > 0 && (
|
|
<p className="mt-10 text-center text-base text-white/35">Arrows to move · OK to open · Back to return</p>
|
|
)}
|
|
</div>
|
|
|
|
{shown && (
|
|
<div key={shown.src} className="absolute inset-0 z-20 bg-black" style={{ animation: "gallery-zoom-in 260ms ease-out both" }}>
|
|
<img
|
|
src={shown.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)" }}
|
|
/>
|
|
{/* The tile is already cached, so it shows at once while the full photo loads on top. */}
|
|
<img src={shown.tile} alt="" aria-hidden className="absolute inset-0 h-full w-full object-contain" />
|
|
<img src={shown.src} alt="" className="absolute inset-0 h-full w-full object-contain" />
|
|
<div className="absolute bottom-6 left-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold text-white/85">
|
|
◀ {(viewer ?? 0) + 1} / {count} ▶
|
|
</div>
|
|
{shown.takenAt ? (
|
|
<div className="absolute bottom-6 right-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85">
|
|
{formatTaken(shown.takenAt)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{neighbours.map((photo) => (
|
|
<link key={photo.src} rel="prefetch" as="image" href={photo.src} />
|
|
))}
|
|
|
|
<style>{`@keyframes gallery-zoom-in { from { opacity: 0; transform: scale(0.82) } to { opacity: 1; transform: scale(1) } }`}</style>
|
|
</div>
|
|
);
|
|
}
|