This commit is contained in:
@@ -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) {
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */}
|
||||
<Screensaver />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
286
apps/client/src/components/screensaver/Screensaver.tsx
Normal file
286
apps/client/src/components/screensaver/Screensaver.tsx
Normal file
@@ -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<T>(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<Photo[]>([]);
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const activeRef = useRef(false);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const photoTimerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const hintTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
|
||||
// ── Fetch settings ───────────────────────────────────────────────────────
|
||||
const { data: settings } = useQuery<AppSettings>({
|
||||
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 (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[200] overflow-hidden bg-black cursor-pointer select-none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
onClick={deactivate}
|
||||
>
|
||||
{/* ── Photo layer ───────────────────────────────────────────────────── */}
|
||||
<AnimatePresence mode="sync">
|
||||
{currentPhoto ? (
|
||||
<motion.div
|
||||
key={`photo-${photoIdx}-${currentPhoto.rel}`}
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.2, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Ken Burns motion layer */}
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={kb.i}
|
||||
animate={kb.a}
|
||||
transition={{ duration: slideshowSpeed / 1000, ease: 'linear' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${currentPhoto.url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* No photos: animated dark gradient */
|
||||
<motion.div
|
||||
key="no-photo"
|
||||
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Preload next photo (hidden) */}
|
||||
{nextPhoto && nextPhoto.rel !== currentPhoto?.rel && (
|
||||
<img src={nextPhoto.url} className="hidden" alt="" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* ── Gradient scrim (bottom-heavy for clock legibility) ─────────── */}
|
||||
<div className="absolute inset-0 pointer-events-none"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.10) 100%)' }}
|
||||
/>
|
||||
|
||||
{/* ── Clock ─────────────────────────────────────────────────────────── */}
|
||||
<div className="absolute bottom-12 left-14 pointer-events-none">
|
||||
{/* Time row */}
|
||||
<div className="flex items-end gap-3">
|
||||
<span
|
||||
className="text-white font-thin tabular-nums leading-none"
|
||||
style={{ fontSize: 'clamp(5rem, 11vw, 10rem)' }}
|
||||
>
|
||||
{timeStr}
|
||||
</span>
|
||||
{periodStr && (
|
||||
<span
|
||||
className="text-white/75 font-light leading-none mb-3"
|
||||
style={{ fontSize: 'clamp(1.75rem, 3.5vw, 3.25rem)' }}
|
||||
>
|
||||
{periodStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<p
|
||||
className="text-white/65 font-light tracking-wide mt-2"
|
||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.75rem)' }}
|
||||
>
|
||||
{dateStr}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── "Tap to dismiss" hint ─────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{hintVisible && (
|
||||
<motion.div
|
||||
className="absolute top-6 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white/75 text-sm pointer-events-none whitespace-nowrap"
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
Tap anywhere to dismiss
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user