Add photo frame idle mode and switch to Skylight-style theme
- After 5 min of no touch/motion, dashboard hides behind a fullscreen photo slideshow with centered time and date overlay - Photos loaded from PHOTOS_PATH env var (defaults to ~/Pictures/dashboard) via IPC + file:// URLs; traversal-guarded, recursive up to 2 levels - Motion or touch exits idle back to dashboard - Theme repainted warm cream / sage / stone ink with Nunito font and rounded cards; dark tokens kept so component classes still resolve - Adds PHOTO_FRAME.md with Samba cifs mount + systemd env instructions
This commit is contained in:
174
src/components/photoframe/PhotoFrame.tsx
Normal file
174
src/components/photoframe/PhotoFrame.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
interface PhotoFrameProps {
|
||||
intervalMs?: number;
|
||||
transitionMs?: number;
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function useClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function PhotoFrame({ intervalMs = 15_000, transitionMs = 1_200 }: PhotoFrameProps) {
|
||||
const setIdle = useUIStore((s) => s.setIdle);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
||||
const [prevUrl, setPrevUrl] = useState<string | null>(null);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const [emptyDir, setEmptyDir] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const api = window.electronAPI;
|
||||
if (!api?.photos) {
|
||||
setEmpty(true);
|
||||
return;
|
||||
}
|
||||
const list = await api.photos.list();
|
||||
const dir = await api.photos.getDir();
|
||||
if (!mountedRef.current) return;
|
||||
if (!list.length) {
|
||||
setEmpty(true);
|
||||
setEmptyDir(dir);
|
||||
return;
|
||||
}
|
||||
setFiles(shuffle(list));
|
||||
setEmpty(false);
|
||||
};
|
||||
load();
|
||||
// Re-scan every 10 min in case photos are added while idle
|
||||
const id = setInterval(load, 10 * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!files.length) return;
|
||||
const id = setInterval(() => {
|
||||
setIndex((i) => (i + 1) % files.length);
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [files.length, intervalMs]);
|
||||
|
||||
// Resolve file:// URLs via IPC when the index changes
|
||||
useEffect(() => {
|
||||
if (!files.length) return;
|
||||
const api = window.electronAPI;
|
||||
if (!api?.photos?.getUrl) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const url = await api.photos.getUrl(files[index]);
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
setPrevUrl(currentUrl);
|
||||
setCurrentUrl(url);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index, files]);
|
||||
|
||||
const handleExit = () => setIdle(false);
|
||||
|
||||
const now = useClock();
|
||||
const timeStr = useMemo(
|
||||
() =>
|
||||
now.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
[now],
|
||||
);
|
||||
const dateStr = useMemo(
|
||||
() =>
|
||||
now.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
[now],
|
||||
);
|
||||
|
||||
const currentSrc = currentUrl;
|
||||
const prevSrc = prevUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black flex items-center justify-center overflow-hidden animate-fade-in"
|
||||
onClick={handleExit}
|
||||
onTouchStart={handleExit}
|
||||
>
|
||||
{empty ? (
|
||||
<div className="text-center text-white/80 px-8">
|
||||
<div className="text-6xl font-light mb-6">{timeStr}</div>
|
||||
<div className="text-2xl mb-10 capitalize">{dateStr}</div>
|
||||
<div className="text-lg opacity-60">
|
||||
No photos found{emptyDir ? ` in ${emptyDir}` : ''}.
|
||||
</div>
|
||||
<div className="text-sm opacity-40 mt-2">Touch anywhere to exit</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{prevSrc && (
|
||||
<img
|
||||
key={`prev-${prevSrc}`}
|
||||
src={prevSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover animate-ken-burns"
|
||||
style={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
{currentSrc && (
|
||||
<img
|
||||
key={`cur-${index}`}
|
||||
src={currentSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover animate-ken-burns"
|
||||
style={{
|
||||
opacity: 0,
|
||||
animation: `photo-fade-in ${transitionMs}ms ease-out forwards, ken-burns 20s ease-in-out infinite alternate`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Gradient + centered clock/date overlay (Skylight style) */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-black/75 via-black/30 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-14 flex justify-center">
|
||||
<div className="px-10 py-5 rounded-3xl bg-black/25 backdrop-blur-md border border-white/10 text-white text-center drop-shadow-2xl">
|
||||
<div className="text-7xl md:text-8xl font-light tracking-tight leading-none">{timeStr}</div>
|
||||
<div className="text-2xl md:text-3xl mt-3 capitalize font-light opacity-95">{dateStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes photo-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user