From 4672f70a60de6003929e2aaa0f14fc8f7cfb7936 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 30 Mar 2026 10:45:16 -0500 Subject: [PATCH] photos phase 3 --- .../client/src/components/layout/AppShell.tsx | 4 + .../components/screensaver/Screensaver.tsx | 286 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 apps/client/src/components/screensaver/Screensaver.tsx diff --git a/apps/client/src/components/layout/AppShell.tsx b/apps/client/src/components/layout/AppShell.tsx index c5548d2..9bb2111 100644 --- a/apps/client/src/components/layout/AppShell.tsx +++ b/apps/client/src/components/layout/AppShell.tsx @@ -8,6 +8,7 @@ import { Image, Menu, X, ChevronRight, } from 'lucide-react'; import { ThemeToggle } from '@/components/ui/ThemeToggle'; +import { Screensaver } from '@/components/screensaver/Screensaver'; interface NavItem { to: string; @@ -192,6 +193,9 @@ export function AppShell({ children }: AppShellProps) { + + {/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */} + ); } diff --git a/apps/client/src/components/screensaver/Screensaver.tsx b/apps/client/src/components/screensaver/Screensaver.tsx new file mode 100644 index 0000000..dbddff9 --- /dev/null +++ b/apps/client/src/components/screensaver/Screensaver.tsx @@ -0,0 +1,286 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; +import { format } from 'date-fns'; +import { api, type AppSettings } from '@/lib/api'; + +// ── Types ────────────────────────────────────────────────────────────────── +interface Photo { + name: string; + rel: string; + url: string; +} + +// ── Ken Burns presets ────────────────────────────────────────────────────── +// Scale always ≥ 1.08 so panning never reveals black edges. +// Alternates between zoom-in and zoom-out for variety. +const KB_PRESETS = [ + { i: { scale: 1.08, x: '-3%', y: '-2%' }, a: { scale: 1.18, x: '3%', y: '2%' } }, // zoom in → bottom-right + { i: { scale: 1.18, x: '3%', y: '2%' }, a: { scale: 1.08, x: '-3%', y: '-2%' } }, // zoom out → top-left + { i: { scale: 1.08, x: '3%', y: '-2%' }, a: { scale: 1.18, x: '-3%', y: '2%' } }, // zoom in → bottom-left + { i: { scale: 1.18, x: '-3%', y: '2%' }, a: { scale: 1.08, x: '3%', y: '-2%' } }, // zoom out → top-right + { i: { scale: 1.08, x: '0%', y: '-3%' }, a: { scale: 1.18, x: '0%', y: '3%' } }, // zoom in → pan down + { i: { scale: 1.18, x: '0%', y: '3%' }, a: { scale: 1.08, x: '0%', y: '-3%' } }, // zoom out → pan up + { i: { scale: 1.08, x: '-3%', y: '0%' }, a: { scale: 1.18, x: '3%', y: '0%' } }, // zoom in → pan right + { i: { scale: 1.18, x: '3%', y: '0%' }, a: { scale: 1.08, x: '-3%', y: '0%' } }, // zoom out → pan left +] as const; + +// ── Helpers ──────────────────────────────────────────────────────────────── +function shuffleArray(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function orderPhotos(photos: Photo[], order: string): Photo[] { + if (order === 'random') return shuffleArray(photos); + if (order === 'newest') return [...photos].reverse(); + return [...photos]; // sequential +} + +// Pick a random Ken Burns preset that isn't the one we just used +function pickKb(prevIdx: number, total = KB_PRESETS.length): number { + if (total === 1) return 0; + let next: number; + do { next = Math.floor(Math.random() * total); } while (next === prevIdx); + return next; +} + +// ── Component ────────────────────────────────────────────────────────────── +export function Screensaver() { + const [active, setActive] = useState(false); + const [photoIdx, setPhotoIdx] = useState(0); + const [kbIdx, setKbIdx] = useState(0); + const [clock, setClock] = useState(new Date()); + const [hintVisible, setHintVisible] = useState(false); + const [orderedPhotos, setOrderedPhotos] = useState([]); + + // Refs to avoid stale closures in event handlers + const activeRef = useRef(false); + const idleTimerRef = useRef>(); + const photoTimerRef = useRef>(); + const hintTimerRef = useRef>(); + + // Keep ref in sync with state + useEffect(() => { activeRef.current = active; }, [active]); + + // ── Fetch settings ─────────────────────────────────────────────────────── + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: () => api.get('/settings').then((r) => r.data), + staleTime: 60_000, + }); + + // ── Fetch photos for slideshow ──────────────────────────────────────────── + const { data: slideshowData } = useQuery<{ count: number; photos: Photo[] }>({ + queryKey: ['photos-slideshow'], + queryFn: () => api.get('/photos/slideshow').then((r) => r.data), + staleTime: 60_000, + }); + + const idleTimeoutMs = parseInt(settings?.idle_timeout ?? '120000', 10); + const slideshowSpeed = parseInt(settings?.slideshow_speed ?? '6000', 10); + const slideshowOrder = settings?.slideshow_order ?? 'random'; + const timeFormat = settings?.time_format ?? '12h'; + const allPhotos = slideshowData?.photos ?? []; + + // ── Clock tick ──────────────────────────────────────────────────────────── + useEffect(() => { + const tick = setInterval(() => setClock(new Date()), 1000); + return () => clearInterval(tick); + }, []); + + // ── Activate ────────────────────────────────────────────────────────────── + const activate = useCallback(() => { + const ordered = orderPhotos(allPhotos, slideshowOrder); + setOrderedPhotos(ordered); + setPhotoIdx(0); + setKbIdx(Math.floor(Math.random() * KB_PRESETS.length)); + setActive(true); + setHintVisible(true); + clearTimeout(hintTimerRef.current); + hintTimerRef.current = setTimeout(() => setHintVisible(false), 3500); + }, [allPhotos, slideshowOrder]); + + // ── Deactivate ──────────────────────────────────────────────────────────── + const deactivate = useCallback(() => { + setActive(false); + clearTimeout(hintTimerRef.current); + setHintVisible(false); + }, []); + + // ── Idle detection ──────────────────────────────────────────────────────── + useEffect(() => { + if (idleTimeoutMs === 0) return; // disabled in settings + + const startIdleTimer = () => { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(activate, idleTimeoutMs); + }; + + const onActivity = () => { + if (activeRef.current) return; // screensaver handles its own dismissal + startIdleTimer(); + }; + + const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const; + EVENTS.forEach((e) => document.addEventListener(e, onActivity, { passive: true })); + startIdleTimer(); // kick off on mount / settings change + + return () => { + EVENTS.forEach((e) => document.removeEventListener(e, onActivity)); + clearTimeout(idleTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activate, idleTimeoutMs]); // intentionally omit `active` — we read it via ref + + // ── Dismiss on any keypress while screensaver is active ─────────────────── + useEffect(() => { + if (!active) return; + const onKey = () => deactivate(); + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [active, deactivate]); + + // ── Photo advance timer ─────────────────────────────────────────────────── + useEffect(() => { + if (!active || orderedPhotos.length === 0) return; + + photoTimerRef.current = setInterval(() => { + setPhotoIdx((prev) => { + const next = prev + 1; + if (next >= orderedPhotos.length) { + // Re-order for next cycle (reshuffle random) + if (slideshowOrder === 'random') { + setOrderedPhotos(shuffleArray(allPhotos)); + } + return 0; + } + return next; + }); + setKbIdx((prev) => pickKb(prev)); + }, slideshowSpeed); + + return () => clearInterval(photoTimerRef.current); + }, [active, orderedPhotos.length, slideshowSpeed, slideshowOrder, allPhotos]); + + // ── Derived values ──────────────────────────────────────────────────────── + const currentPhoto = orderedPhotos[photoIdx] ?? null; + const nextPhoto = orderedPhotos[(photoIdx + 1) % Math.max(orderedPhotos.length, 1)] ?? null; + const kb = KB_PRESETS[kbIdx]; + + const timeStr = timeFormat === '24h' ? format(clock, 'H:mm') : format(clock, 'h:mm'); + const periodStr = timeFormat === '12h' ? format(clock, 'a') : ''; + const dateStr = format(clock, 'EEEE, MMMM d'); + + // ── Render ──────────────────────────────────────────────────────────────── + if (!active) return null; + + return ( + + {/* ── Photo layer ───────────────────────────────────────────────────── */} + + {currentPhoto ? ( + + {/* Ken Burns motion layer */} + +
+ + + ) : ( + /* No photos: animated dark gradient */ + + )} + + + {/* Preload next photo (hidden) */} + {nextPhoto && nextPhoto.rel !== currentPhoto?.rel && ( + + )} + + {/* ── Gradient scrim (bottom-heavy for clock legibility) ─────────── */} +
+ + {/* ── Clock ─────────────────────────────────────────────────────────── */} +
+ {/* Time row */} +
+ + {timeStr} + + {periodStr && ( + + {periodStr} + + )} +
+ + {/* Date */} +

+ {dateStr} +

+
+ + {/* ── "Tap to dismiss" hint ─────────────────────────────────────────── */} + + {hintVisible && ( + + Tap anywhere to dismiss + + )} + + + ); +}