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:
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
86
src/App.tsx
86
src/App.tsx
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
3
src/vite-env.d.ts
vendored
@@ -51,9 +51,6 @@ interface ElectronAPI {
|
||||
getDir: () => Promise<string>;
|
||||
getUrl: (relative: string) => Promise<string | null>;
|
||||
};
|
||||
motion: {
|
||||
onDetected: (callback: () => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user