This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
Image, Menu, X, ChevronRight,
|
Image, Menu, X, ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
||||||
|
import { Screensaver } from '@/components/screensaver/Screensaver';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -192,6 +193,9 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */}
|
||||||
|
<Screensaver />
|
||||||
</div>
|
</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