Simplify home view and drop motion detection

- Top nav tabs (Home/Media/Cameras/Settings) replace the right-side button cluster
- Home view now shows calendar 90% / todo 10% vertically; lights, locks,
  alarm, thermostats removed from the dashboard since the photo frame now
  owns the idle space and the nav covers the remaining sections
- Motion detection deleted: the go2rtc-based Kitchen_Panel poll was only
  there to wake the screen before idle timeout, which photo-frame exit on
  touch replaces
This commit is contained in:
root
2026-04-14 13:27:20 -05:00
parent 5fe7bc71ef
commit 1dd32c6afe
6 changed files with 82 additions and 163 deletions

View File

@@ -4,14 +4,12 @@ import * as fs from 'fs';
import { ScreenManager } from './services/ScreenManager';
import { PresenceDetector } from './services/PresenceDetector';
import { FrigateStreamer } from './services/FrigateStreamer';
import { MotionDetector } from './services/MotionDetector';
import { PhotoManager } from './services/PhotoManager';
let mainWindow: BrowserWindow | null = null;
let screenManager: ScreenManager | null = null;
let presenceDetector: PresenceDetector | null = null;
let frigateStreamer: FrigateStreamer | null = null;
let motionDetector: MotionDetector | null = null;
let photoManager: PhotoManager | null = null;
let powerSaveBlockerId: number | null = null;
@@ -63,23 +61,6 @@ function createWindow(): void {
// Initialize services
screenManager = new ScreenManager(mainWindow);
// Initialize motion detector (runs in main process, not throttled by browser)
// Uses file size comparison which is more reliable for JPEG streams
motionDetector = new MotionDetector({
go2rtcUrl: 'http://192.168.1.241:1985',
cameraName: 'Kitchen_Panel',
sensitivityThreshold: 5, // % file size change to trigger (5% = significant motion)
checkIntervalMs: 2000, // Check every 2 seconds for responsiveness
});
motionDetector.on('motion', () => {
console.log('MotionDetector: Motion detected, waking screen');
screenManager?.wakeScreen();
mainWindow?.webContents.send('motion:detected');
});
motionDetector.start();
// Photo frame slideshow source
photoManager = new PhotoManager(resolvePhotosDir());
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
@@ -243,7 +224,6 @@ app.on('window-all-closed', () => {
}
presenceDetector?.stop();
frigateStreamer?.stop();
motionDetector?.stop();
if (process.platform !== 'darwin') {
app.quit();

View File

@@ -17,9 +17,6 @@ export interface ElectronAPI {
startStream: (rtspUrl: string) => Promise<boolean>;
stopStream: () => Promise<boolean>;
};
motion: {
onDetected: (callback: () => void) => () => void;
};
app: {
quit: () => void;
toggleFullscreen: () => void;
@@ -61,13 +58,6 @@ const electronAPI: ElectronAPI = {
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
},
motion: {
onDetected: (callback: () => void) => {
const handler = (_event: IpcRendererEvent) => callback();
ipcRenderer.on('motion:detected', handler);
return () => ipcRenderer.removeListener('motion:detected', handler);
},
},
app: {
quit: () => ipcRenderer.invoke('app:quit'),
toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'),

View File

@@ -3,7 +3,6 @@ import { Dashboard } from '@/components/layout';
import { ThermostatOverlay } from '@/components/climate';
import { LightsOverlay } from '@/components/lights';
import { LocksOverlay } from '@/components/locks';
import { AlarmoPanel } from '@/components/alarm';
import { CalendarWidget } from '@/components/calendar';
import { TodoWidget } from '@/components/todo';
import { SettingsPanel, ConnectionModal } from '@/components/settings';
@@ -16,7 +15,7 @@ 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';
import { useHAStore } from '@/stores/haStore';
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
@@ -71,12 +70,6 @@ function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: ()
);
}
// Simple thermostat temp display
function ThermostatTemp({ entityId }: { entityId: string }) {
const currentTemp = useEntityAttribute<number>(entityId, 'current_temperature');
return <>{currentTemp?.toFixed(0) ?? '--'}°</>;
}
function ConnectionPrompt() {
const openSettings = useUIStore((state) => state.openSettings);
@@ -104,72 +97,20 @@ function ConnectionPrompt() {
function DashboardContent() {
const config = useSettingsStore((state) => state.config);
const openLightsOverlay = useUIStore((state) => state.openLightsOverlay);
const openLocksOverlay = useUIStore((state) => state.openLocksOverlay);
const openThermostatsOverlay = useUIStore((state) => state.openThermostatsOverlay);
return (
<>
{/* Left Column - Calendar (spans 2 columns) */}
<div className="col-span-8 flex flex-col gap-4">
{config.calendar && (
<div className="flex-1 min-h-0">
<CalendarWidget />
</div>
)}
</div>
{/* Right Column - Controls, Alarm, Todo */}
<div className="col-span-4 flex flex-col gap-3">
{/* Control Buttons Row - Lights, Locks, Thermostats */}
<div className="grid grid-cols-3 gap-2">
{config.lights.length > 0 && (
<button
onClick={openLightsOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className="text-xs">Lights</span>
</button>
)}
{config.locks.length > 0 && (
<button
onClick={openLocksOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<svg className="w-6 h-6 text-status-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-xs">Locks</span>
</button>
)}
{config.thermostats.map((thermostat) => (
<button
key={thermostat.entityId}
onClick={openThermostatsOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<span className="text-xl font-light text-orange-400">
<ThermostatTemp entityId={thermostat.entityId} />
</span>
<span className="text-xs text-gray-400">{thermostat.name}</span>
</button>
))}
<div className="col-span-12 flex flex-col gap-3 min-h-0">
{config.calendar && (
<div className="flex-[9] min-h-0">
<CalendarWidget />
</div>
{/* Alarm Panel */}
{config.alarm && <AlarmoPanel />}
{/* Todo List */}
{config.todoList && (
<div className="flex-1 min-h-0">
<TodoWidget />
</div>
)}
</div>
</>
)}
{config.todoList && (
<div className="flex-[1] min-h-0">
<TodoWidget />
</div>
)}
</div>
);
}
@@ -191,9 +132,6 @@ export default function App() {
const [alertCamera, setAlertCamera] = useState<string | null>(null);
const alertShownForRef = useRef<Set<string>>(new Set());
// Motion detection now runs in the Electron main process (MotionDetector.ts)
// This prevents browser throttling when the screensaver is active
// Report touch/click activity to main process for screen wake on Wayland
useEffect(() => {
const handleActivity = () => {

View File

@@ -82,13 +82,50 @@ function PackageStatus() {
export function Header() {
const connectionState = useConnectionState();
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const settingsOpen = useUIStore((state) => state.settingsOpen);
const openCameraOverlay = useUIStore((state) => state.openCameraOverlay);
const closeCameraOverlay = useUIStore((state) => state.closeCameraOverlay);
const openMediaOverlay = useUIStore((state) => state.openMediaOverlay);
const closeMediaOverlay = useUIStore((state) => state.closeMediaOverlay);
const openSettings = useUIStore((state) => state.openSettings);
const closeSettings = useUIStore((state) => state.closeSettings);
const people = useSettingsStore((state) => state.config.people);
const cameras = useSettingsStore((state) => state.config.cameras);
const [currentTime, setCurrentTime] = useState(new Date());
const activeTab: 'home' | 'media' | 'cameras' | 'settings' =
settingsOpen ? 'settings' : cameraOverlayOpen ? 'cameras' : mediaOverlayOpen ? 'media' : 'home';
const goHome = () => {
closeCameraOverlay();
closeMediaOverlay();
closeSettings();
};
const goMedia = () => {
closeCameraOverlay();
closeSettings();
openMediaOverlay();
};
const goCameras = () => {
closeMediaOverlay();
closeSettings();
openCameraOverlay();
};
const goSettings = () => {
closeMediaOverlay();
closeCameraOverlay();
openSettings();
};
const tabClass = (name: typeof activeTab) =>
`flex items-center gap-2 px-4 py-2 rounded-2xl font-semibold text-sm transition-all ${
activeTab === name
? 'bg-accent text-white shadow-card'
: 'bg-transparent text-ink-muted hover:bg-dark-hover'
}`;
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
@@ -122,23 +159,18 @@ export function Header() {
};
return (
<header className="h-14 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5">
{/* Left - Time and Date */}
<div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold text-white tracking-tight">
{format(currentTime, 'h:mm')}
</span>
<span className="text-sm text-gray-500">
{format(currentTime, 'EEE, MMM d')}
</span>
</div>
{/* Center - Status Icons */}
<div className="flex items-center gap-4">
{/* Package Status */}
<header className="h-16 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5 gap-4">
{/* Left - Time, Date, People */}
<div className="flex items-center gap-5 min-w-0">
<div className="flex items-baseline gap-3">
<span className="text-2xl font-bold text-ink tracking-tight">
{format(currentTime, 'h:mm')}
</span>
<span className="text-sm font-semibold text-ink-muted">
{format(currentTime, 'EEE, MMM d')}
</span>
</div>
<PackageStatus />
{/* People */}
{people.length > 0 && (
<div className="flex items-center gap-3">
{people.map((person) => (
@@ -153,53 +185,44 @@ export function Header() {
)}
</div>
{/* Right - Connection Status, Cameras, Settings */}
<div className="flex items-center gap-4">
{/* Connection Status */}
<div className="status-badge">
<div className={`status-dot ${getConnectionStatusClass()}`} />
<span>{getConnectionText()}</span>
</div>
{/* Media Button */}
<button
onClick={openMediaOverlay}
className="btn btn-sm"
aria-label="Open media"
>
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{/* Center - Tab Navigation */}
<nav className="flex items-center gap-1 bg-dark-tertiary rounded-2xl p-1">
<button onClick={goHome} className={tabClass('home')}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</button>
<button onClick={goMedia} className={tabClass('media')}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Media
</button>
{/* Camera Button */}
{cameras.length > 0 && (
<button
onClick={() => openCameraOverlay()}
className="btn btn-sm"
aria-label="View cameras"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button onClick={goCameras} className={tabClass('cameras')}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Cameras
</button>
)}
{/* Settings Button */}
<button
onClick={openSettings}
className="btn btn-sm"
aria-label="Settings"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button onClick={goSettings} className={tabClass('settings')}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</nav>
{/* Right - Connection Status */}
<div className="flex items-center">
<div className="status-badge">
<div className={`status-dot ${getConnectionStatusClass()}`} />
<span>{getConnectionText()}</span>
</div>
</div>
</header>
);

View File

@@ -1,14 +1,9 @@
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);
@@ -34,15 +29,11 @@ export function useIdle(timeoutMs: number) {
];
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]);

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

@@ -51,9 +51,6 @@ interface ElectronAPI {
getDir: () => Promise<string>;
getUrl: (relative: string) => Promise<string | null>;
};
motion: {
onDetected: (callback: () => void) => () => void;
};
}
interface Window {