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:
@@ -5,14 +5,23 @@ 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;
|
||||
|
||||
// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard
|
||||
function resolvePhotosDir(): string {
|
||||
const env = process.env.PHOTOS_PATH;
|
||||
if (env && env.trim()) return env.trim();
|
||||
return path.join(app.getPath('home'), 'Pictures', 'dashboard');
|
||||
}
|
||||
|
||||
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server
|
||||
// When running from unpacked folder, app.isPackaged is false but we still want production behavior
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
@@ -30,7 +39,7 @@ function createWindow(): void {
|
||||
kiosk: !isDev, // Kiosk mode in production
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0a0a0a',
|
||||
backgroundColor: '#faf6f0',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -71,6 +80,10 @@ function createWindow(): void {
|
||||
|
||||
motionDetector.start();
|
||||
|
||||
// Photo frame slideshow source
|
||||
photoManager = new PhotoManager(resolvePhotosDir());
|
||||
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
@@ -174,6 +187,24 @@ function setupIpcHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Photo frame
|
||||
ipcMain.handle('photos:list', async () => {
|
||||
return photoManager?.list() ?? [];
|
||||
});
|
||||
|
||||
ipcMain.handle('photos:getDir', async () => {
|
||||
return photoManager?.getDir() ?? '';
|
||||
});
|
||||
|
||||
// Given a relative filename returned by photos:list, return an absolute
|
||||
// file:// URL the renderer can drop into an <img src>. Returns null if the
|
||||
// file is missing or resolves outside the photos dir (traversal guard).
|
||||
ipcMain.handle('photos:getUrl', async (_event, rel: string) => {
|
||||
const full = photoManager?.resolve(rel);
|
||||
if (!full || !fs.existsSync(full)) return null;
|
||||
return 'file://' + full.split(path.sep).map((p, i) => (i === 0 && p === '' ? '' : encodeURIComponent(p))).join('/');
|
||||
});
|
||||
|
||||
// App control
|
||||
ipcMain.handle('app:quit', () => {
|
||||
app.quit();
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface ElectronAPI {
|
||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||
stopStream: () => Promise<boolean>;
|
||||
};
|
||||
motion: {
|
||||
onDetected: (callback: () => void) => () => void;
|
||||
};
|
||||
app: {
|
||||
quit: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
@@ -26,6 +29,11 @@ export interface ElectronAPI {
|
||||
getStoredToken: () => Promise<string | null>;
|
||||
getJellyfinApiKey: () => Promise<string | null>;
|
||||
};
|
||||
photos: {
|
||||
list: () => Promise<string[]>;
|
||||
getDir: () => Promise<string>;
|
||||
getUrl: (relative: string) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
@@ -53,6 +61,13 @@ 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'),
|
||||
@@ -62,6 +77,11 @@ const electronAPI: ElectronAPI = {
|
||||
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
|
||||
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
|
||||
},
|
||||
photos: {
|
||||
list: () => ipcRenderer.invoke('photos:list'),
|
||||
getDir: () => ipcRenderer.invoke('photos:getDir'),
|
||||
getUrl: (relative: string) => ipcRenderer.invoke('photos:getUrl', relative),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
53
electron/services/PhotoManager.ts
Normal file
53
electron/services/PhotoManager.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
|
||||
|
||||
/**
|
||||
* Lists image files from the configured photos directory.
|
||||
* Recurses one level deep so users can organise photos into subfolders.
|
||||
*/
|
||||
export class PhotoManager {
|
||||
constructor(private photosDir: string) {}
|
||||
|
||||
setDir(dir: string): void {
|
||||
this.photosDir = dir;
|
||||
}
|
||||
|
||||
getDir(): string {
|
||||
return this.photosDir;
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
if (!this.photosDir || !fs.existsSync(this.photosDir)) return [];
|
||||
const files: string[] = [];
|
||||
try {
|
||||
this.walk(this.photosDir, files, 0);
|
||||
} catch (err) {
|
||||
console.error('PhotoManager: failed to list photos', err);
|
||||
}
|
||||
// Return paths relative to photosDir so the renderer can build photo:// URLs
|
||||
return files.map((f) => path.relative(this.photosDir, f));
|
||||
}
|
||||
|
||||
private walk(dir: string, out: string[], depth: number): void {
|
||||
if (depth > 2) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
this.walk(full, out, depth + 1);
|
||||
} else if (entry.isFile() && IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(relative: string): string | null {
|
||||
if (!this.photosDir) return null;
|
||||
const safe = path.normalize(relative).replace(/^(\.\.[\/\\])+/, '');
|
||||
const full = path.join(this.photosDir, safe);
|
||||
if (!full.startsWith(this.photosDir)) return null;
|
||||
return full;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user