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:
root
2026-04-14 10:44:51 -05:00
parent 58ebd3e239
commit 5fe7bc71ef
14 changed files with 566 additions and 77 deletions

View File

@@ -11,7 +11,9 @@ import { CameraOverlay } from '@/components/cameras';
import { CameraFeed } from '@/components/cameras/CameraFeed';
import { JellyfinOverlay } from '@/components/media';
import { GlobalKeyboard } from '@/components/keyboard';
import { PhotoFrame } from '@/components/photoframe';
import { useHomeAssistant } from '@/hooks';
import { useIdle } from '@/hooks/useIdle';
// Motion detection now runs in Electron main process (MotionDetector.ts)
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
@@ -182,6 +184,7 @@ export default function App() {
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
const isIdle = useIdle(env.photoFrameIdleTimeout);
// Person detection alert state
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
@@ -334,6 +337,7 @@ export default function App() {
{mediaOverlayOpen && <JellyfinOverlay />}
{cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />}
{isIdle && !alertCamera && <PhotoFrame intervalMs={env.photoFrameInterval} />}
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
<GlobalKeyboard />
</>

View 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>
);
}

View File

@@ -0,0 +1 @@
export { PhotoFrame } from './PhotoFrame';

View File

@@ -20,6 +20,10 @@ export const env = {
// Screen management
screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10),
// Photo frame idle timeout (ms) - 5 minutes default
photoFrameIdleTimeout: parseInt(import.meta.env.VITE_PHOTO_FRAME_IDLE_TIMEOUT || '300000', 10),
photoFrameInterval: parseInt(import.meta.env.VITE_PHOTO_FRAME_INTERVAL || '15000', 10),
// Presence detection
presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true',
presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'),

50
src/hooks/useIdle.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useUIStore } from '@/stores/uiStore';
type ElectronAPILike = {
motion?: { onDetected: (cb: () => void) => () => void };
};
/**
* Tracks touch/mouse/keyboard activity. After `timeoutMs` of no activity,
* flips the UI into idle mode (photo frame). Any activity exits idle.
* Motion detected by Electron's MotionDetector also cancels idle.
*/
export function useIdle(timeoutMs: number) {
const isIdle = useUIStore((s) => s.isIdle);
const setIdle = useUIStore((s) => s.setIdle);
useEffect(() => {
if (timeoutMs <= 0) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const reset = () => {
if (timer) clearTimeout(timer);
if (useUIStore.getState().isIdle) setIdle(false);
timer = setTimeout(() => setIdle(true), timeoutMs);
};
const events: Array<keyof DocumentEventMap> = [
'touchstart',
'mousedown',
'mousemove',
'keydown',
'wheel',
];
for (const e of events) document.addEventListener(e, reset, { passive: true });
const api = (window as unknown as { electronAPI?: ElectronAPILike }).electronAPI;
const unsubMotion = api?.motion?.onDetected?.(() => reset());
reset();
return () => {
if (timer) clearTimeout(timer);
for (const e of events) document.removeEventListener(e, reset);
if (unsubMotion) unsubMotion();
};
}, [timeoutMs, setIdle]);
return isIdle;
}

View File

@@ -21,6 +21,10 @@ interface UIState {
// Screen state
screenOn: boolean;
// Idle / photo frame
isIdle: boolean;
setIdle: (idle: boolean) => void;
// Virtual keyboard
keyboardOpen: boolean;
keyboardNumpad: boolean;
@@ -71,6 +75,7 @@ export const useUIStore = create<UIState>((set) => ({
alarmoAction: null,
settingsOpen: false,
screenOn: true,
isIdle: false,
keyboardOpen: false,
keyboardNumpad: false,
@@ -137,6 +142,9 @@ export const useUIStore = create<UIState>((set) => ({
// Screen
setScreenOn: (on) => set({ screenOn: on }),
// Idle
setIdle: (idle) => set({ isIdle: idle }),
// Keyboard
openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }),
closeKeyboard: () => set({ keyboardOpen: false }),

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=Quicksand:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@@ -6,13 +6,13 @@
@layer base {
html {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-dark-primary text-white;
@apply bg-dark-primary text-ink;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
@@ -20,8 +20,8 @@
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@@ -29,7 +29,7 @@
}
::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded;
@apply bg-dark-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@@ -40,9 +40,10 @@
@layer components {
/* Button base */
.btn {
@apply flex items-center justify-center gap-2 px-4 py-2.5
bg-dark-tertiary border border-dark-border rounded-xl
font-medium text-sm text-white
@apply flex items-center justify-center gap-2 px-5 py-3
bg-dark-secondary border border-dark-border rounded-2xl
font-semibold text-base text-ink
shadow-card
transition-all duration-150 ease-out
hover:bg-dark-hover hover:border-dark-border-light
active:scale-[0.98] touch-manipulation;
@@ -50,30 +51,31 @@
.btn-primary {
@apply btn bg-accent border-accent text-white
hover:bg-accent-light hover:border-accent-light;
hover:bg-accent-dark hover:border-accent-dark;
}
.btn-sm {
@apply px-2.5 py-1 text-xs rounded-lg;
@apply px-3 py-1.5 text-sm rounded-xl;
}
.btn-icon {
@apply btn p-2.5;
@apply btn p-3;
}
/* Widget */
/* Widget - Skylight-style rounded card */
.widget {
@apply bg-dark-secondary border border-dark-border rounded-2xl p-4
@apply bg-dark-secondary border border-dark-border rounded-3xl p-5
shadow-card
flex flex-col overflow-hidden;
}
.widget-title {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wide
text-gray-400 mb-3;
@apply flex items-center gap-2 text-sm font-bold uppercase tracking-wide
text-ink-muted mb-3;
}
.widget-title svg {
@apply w-4 h-4 opacity-70;
@apply w-5 h-5 opacity-70;
}
.widget-content {
@@ -82,36 +84,36 @@
/* Toggle switch */
.toggle {
@apply relative w-9 h-5 bg-dark-elevated rounded-full cursor-pointer
@apply relative w-11 h-6 bg-dark-tertiary border border-dark-border rounded-full cursor-pointer
transition-colors duration-200;
}
.toggle.active {
@apply bg-accent;
@apply bg-accent border-accent;
}
.toggle-thumb {
@apply absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full
@apply absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full
transition-transform duration-200 shadow-sm;
}
.toggle.active .toggle-thumb {
@apply translate-x-4;
@apply translate-x-5;
}
/* Status badge */
.status-badge {
@apply flex items-center gap-2 px-3.5 py-2 bg-dark-tertiary rounded-full
text-sm text-gray-400;
@apply flex items-center gap-2 px-4 py-2 bg-dark-tertiary rounded-full
text-sm text-ink-muted;
}
.status-dot {
@apply w-2 h-2 rounded-full;
@apply w-2.5 h-2.5 rounded-full;
}
.status-dot.connected {
@apply bg-status-success;
box-shadow: 0 0 8px theme('colors.status.success');
box-shadow: 0 0 8px rgba(74, 157, 122, 0.5);
}
.status-dot.disconnected {
@@ -128,21 +130,21 @@
}
.person-avatar {
@apply w-8 h-8 rounded-full overflow-hidden border-2 transition-all;
@apply w-9 h-9 rounded-full overflow-hidden border-2 transition-all;
}
.person-avatar.home {
@apply border-status-success;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.3);
box-shadow: 0 0 8px rgba(74, 157, 122, 0.35);
}
.person-avatar.away {
@apply border-gray-500 opacity-70;
@apply border-dark-border-light opacity-70;
}
.person-avatar.work {
@apply border-accent;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3);
box-shadow: 0 0 8px rgba(127, 168, 148, 0.35);
}
.person-avatar img {
@@ -150,7 +152,7 @@
}
.person-location {
@apply text-[0.55rem] font-medium uppercase tracking-wide text-gray-500;
@apply text-[0.6rem] font-semibold uppercase tracking-wide text-ink-subtle;
}
.person-location.home {
@@ -158,12 +160,12 @@
}
.person-location.work {
@apply text-accent;
@apply text-accent-dark;
}
/* Status icon */
.status-icon {
@apply relative w-8 h-8 rounded-lg flex items-center justify-center
@apply relative w-9 h-9 rounded-xl flex items-center justify-center
cursor-pointer transition-transform hover:scale-110;
}
@@ -174,55 +176,56 @@
.status-icon.package::after {
content: '';
@apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full
border-2 border-dark-tertiary animate-pulse;
border-2 border-dark-secondary animate-pulse;
}
/* Keypad */
.keypad-btn {
@apply w-14 h-14 rounded-xl bg-dark-tertiary border border-dark-border
text-xl font-medium text-white
@apply w-16 h-16 rounded-2xl bg-dark-secondary border border-dark-border
text-2xl font-semibold text-ink
shadow-card
transition-all duration-150
hover:border-accent active:bg-accent active:scale-95
hover:border-accent hover:bg-dark-hover active:bg-accent active:text-white active:scale-95
touch-manipulation;
}
/* Temperature */
.temp-display {
@apply text-5xl font-light tracking-tight;
@apply text-5xl font-light tracking-tight text-ink;
}
.temp-setpoint {
@apply text-center px-2.5 py-1.5 bg-dark-tertiary rounded-lg;
@apply text-center px-3 py-2 bg-dark-tertiary rounded-xl;
}
.temp-setpoint-label {
@apply text-[0.5rem] text-gray-500 uppercase tracking-wide mb-0.5;
@apply text-[0.55rem] text-ink-subtle uppercase tracking-wide mb-0.5 font-semibold;
}
.temp-setpoint-value {
@apply text-lg font-semibold;
@apply text-lg font-bold;
}
.temp-setpoint-value.heat {
@apply text-orange-400;
@apply text-status-warning;
}
.temp-setpoint-value.cool {
@apply text-sky-400;
@apply text-accent-dark;
}
.temp-btn {
@apply w-8 h-8 rounded-full bg-dark-tertiary border border-dark-border
@apply w-9 h-9 rounded-full bg-dark-secondary border border-dark-border
flex items-center justify-center text-base
transition-all hover:bg-dark-hover;
}
.temp-btn.heat {
@apply border-orange-400 text-orange-400 hover:bg-orange-400/15;
@apply border-status-warning text-status-warning hover:bg-status-warning/15;
}
.temp-btn.cool {
@apply border-sky-400 text-sky-400 hover:bg-sky-400/15;
@apply border-accent-dark text-accent-dark hover:bg-accent/15;
}
/* Overlay */
@@ -233,9 +236,14 @@
/* Compact rows */
.compact-row {
@apply flex items-center justify-between px-2.5 py-2 bg-dark-tertiary rounded-lg
@apply flex items-center justify-between px-3 py-2.5 bg-dark-tertiary rounded-xl
transition-colors hover:bg-dark-hover;
}
/* Legacy imperial card for ConnectionPrompt */
.card-imperial {
@apply bg-dark-secondary border border-dark-border rounded-3xl p-8 shadow-card-lg;
}
}
@layer utilities {
@@ -256,4 +264,25 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/*
* Theme remap: existing components use text-white and text-gray-* for
* dark-theme text. For the Skylight light theme, rebind those utilities
* to the warm ink scale so every component becomes readable on cream bg
* without touching hundreds of component files.
*/
.text-white { color: #3a322a; }
.text-gray-300 { color: #4a4239; }
.text-gray-400 { color: #6b5f51; }
.text-gray-500 { color: #8a7d6e; }
.text-gray-600 { color: #a0937f; }
/* Dark backgrounds that were nearly-black in the old theme */
.bg-imperial-black { background-color: #faf6f0; }
.bg-black { background-color: #2a2520; }
.text-ink { color: #3a322a; }
.text-ink-muted { color: #6b5f51; }
.text-ink-subtle { color: #8a7d6e; }
.text-ink-faint { color: #b0a494; }
}

10
src/vite-env.d.ts vendored
View File

@@ -12,6 +12,8 @@ interface ImportMetaEnv {
readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string;
readonly VITE_FRIGATE_STREAM_ENABLED: string;
readonly VITE_FRIGATE_RTSP_OUTPUT: string;
readonly VITE_PHOTO_FRAME_IDLE_TIMEOUT: string;
readonly VITE_PHOTO_FRAME_INTERVAL: string;
}
interface ImportMeta {
@@ -44,6 +46,14 @@ interface ElectronAPI {
getStoredToken: () => Promise<string | null>;
getJellyfinApiKey: () => Promise<string | null>;
};
photos: {
list: () => Promise<string[]>;
getDir: () => Promise<string>;
getUrl: (relative: string) => Promise<string | null>;
};
motion: {
onDetected: (callback: () => void) => () => void;
};
}
interface Window {