24 Commits

Author SHA1 Message Date
jessikitty 4d85d58526 fix(1.2.1): fix port mapping and URL param auto-launch 2026-05-19 16:36:40 +10:00
jessikitty 68dbf9dc59 fix: internal port 3000 (Docker maps 3030 externally) 2026-05-19 16:35:50 +10:00
jessikitty 4f7d30d3d2 fix: URL param auto-launch now awaits slideshow start, uses 'in' operator for ?favorites/?random, adds console logging for debug 2026-05-19 16:35:03 +10:00
jessikitty cac179754c fix: internal port back to 3000 (Docker maps externally via compose) 2026-05-19 16:33:06 +10:00
jessikitty a609ed2165 fix: port mapping 3030:3000 (external:internal) 2026-05-19 16:32:37 +10:00
jessikitty 2ea13fcbaa fix: revert internal port to 3000 (Docker maps 3030:3000 externally) 2026-05-19 16:32:03 +10:00
jessikitty 6c26688244 docs: update README with v1.2.0 features — URL params, person support, refresh, icon, port 3030 2026-05-19 16:16:27 +10:00
jessikitty 88d79a9569 Upload files to "public" 2026-05-19 16:14:44 +10:00
jessikitty 1c55aa9418 Upload files to "public/img" 2026-05-19 16:14:32 +10:00
jessikitty 3cfcc3f983 feat: port 3030, add REFRESH_INTERVAL 2026-05-19 16:01:48 +10:00
jessikitty 245766d841 feat: change default port to 3030 2026-05-19 16:01:14 +10:00
jessikitty d32180d352 feat: port 3030, add REFRESH_INTERVAL config 2026-05-19 16:01:01 +10:00
jessikitty 4feaaf30d0 feat: URL params (?album=, ?person=, ?favorites, ?random), periodic refresh, person support 2026-05-19 15:59:43 +10:00
jessikitty be6f0fc30f feat: add app icon, apple-touch-icon, theme-color meta 2026-05-19 15:57:35 +10:00
jessikitty 00bfa926e4 feat: port 3030, person API endpoints, refresh interval config, mapAsset helper 2026-05-19 15:56:43 +10:00
jessikitty ea2828d071 feat(1.2.0): bump version — port 3030, URL params, person support, auto-refresh, app icon 2026-05-19 15:56:04 +10:00
jessikitty 2409ab882e rebrand: rename ImmichFrame to Frambe throughout README 2026-05-19 14:54:07 +10:00
jessikitty 2c4a64c73c rebrand: rename ImmichFrame to Frambe in .env.example header 2026-05-19 14:52:58 +10:00
jessikitty 1756035cf8 rebrand: rename service and container from immich-frame to frambe 2026-05-19 14:52:20 +10:00
jessikitty 96d9c0960a rebrand: rename ImmichFrame to Frambe in Dockerfile labels 2026-05-19 14:51:44 +10:00
jessikitty d31abb68c8 rebrand: rename ImmichFrame to Frambe in app.js header 2026-05-19 14:51:13 +10:00
jessikitty 597c5fd883 rebrand: rename ImmichFrame to Frambe in title and heading 2026-05-19 14:49:27 +10:00
jessikitty 7009c6e3fa rebrand: rename ImmichFrame to Frambe in server startup logs 2026-05-19 14:49:02 +10:00
jessikitty a9c55ba861 rebrand: rename project from ImmichFrame to Frambe 2026-05-19 14:48:15 +10:00
10 changed files with 313 additions and 734 deletions
+4 -3
View File
@@ -1,4 +1,4 @@
# === ImmichFrame Configuration === # === Frambe Configuration ===
# REQUIRED # REQUIRED
IMMICH_URL=http://your-immich-server:2283 IMMICH_URL=http://your-immich-server:2283
@@ -10,6 +10,7 @@ TRANSITION_DURATION=2
IMAGE_FIT=contain IMAGE_FIT=contain
SHUFFLE=true SHUFFLE=true
BACKGROUND_BLUR=true BACKGROUND_BLUR=true
REFRESH_INTERVAL=300
# Overlays # Overlays
SHOW_CLOCK=true SHOW_CLOCK=true
@@ -17,9 +18,9 @@ SHOW_DATE=true
SHOW_EXIF=true SHOW_EXIF=true
SHOW_PROGRESS=true SHOW_PROGRESS=true
# Auto-start (optional) # Auto-start (optional — or use URL params instead)
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server # Server (internal port — Docker maps externally via docker-compose)
PORT=3000 PORT=3000
+2 -9
View File
@@ -1,28 +1,21 @@
FROM node:18-alpine FROM node:18-alpine
LABEL maintainer="ImmichFrame" LABEL maintainer="Frambe"
LABEL description="Lightweight digital photo frame for Immich" LABEL description="Frambe — lightweight digital photo frame for Immich"
WORKDIR /app WORKDIR /app
# Copy package files first for better caching
COPY package.json ./ COPY package.json ./
# Install dependencies
RUN npm install --production && npm cache clean --force RUN npm install --production && npm cache clean --force
# Copy application
COPY server.js ./ COPY server.js ./
COPY public/ ./public/ COPY public/ ./public/
# Expose port
EXPOSE 3000 EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/config || exit 1 CMD wget -qO- http://localhost:3000/api/config || exit 1
# Run as non-root
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup adduser -S appuser -u 1001 -G appgroup
USER appuser USER appuser
+38 -16
View File
@@ -1,4 +1,8 @@
# 🖼️ ImmichFrame # Frambe
<p align="center">
<img src="public/img/icon.png" alt="Frambe" width="180">
</p>
A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames.
@@ -6,6 +10,9 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Immich API Integration** — Connects securely via API key (kept server-side) - **Immich API Integration** — Connects securely via API key (kept server-side)
- **Album Browser** — Select any album, random photos, or favorites only - **Album Browser** — Select any album, random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters
- **Auto-Refresh** — Periodically checks for new photos added to the source album/person
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration - **Smooth Crossfade** — Double-buffered image transitions with configurable duration
- **Background Blur** — Blurred backdrop fills the space behind non-covering images - **Background Blur** — Blurred backdrop fills the space behind non-covering images
- **Clock & Date Overlay** — Always know the time at a glance - **Clock & Date Overlay** — Always know the time at a glance
@@ -14,7 +21,6 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay - **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) - **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
- **Screen Wake Lock** — Prevents screen sleep on supported devices - **Screen Wake Lock** — Prevents screen sleep on supported devices
- **Auto-Start** — Configure an album ID or favorites-only to skip the setup screen
- **Responsive** — Works on any screen size from phone to TV - **Responsive** — Works on any screen size from phone to TV
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint - **Docker Containerised** — Single container, minimal footprint
@@ -30,8 +36,8 @@ A lightweight, self-contained Docker web application that connects to your [Immi
### 2. Run with Docker Compose ### 2. Run with Docker Compose
```bash ```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd immich-frame cd frambe
``` ```
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
@@ -40,21 +46,34 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
docker compose up -d docker compose up -d
``` ```
Open `http://your-server:3000` in a browser on your tablet/screen. Open `http://your-server:3030` in a browser on your tablet/screen.
### 3. Run with Docker directly ### 3. Run with Docker directly
```bash ```bash
docker build -t immich-frame . docker build -t frambe .
docker run -d \ docker run -d \
--name immich-frame \ --name frambe \
-p 3000:3000 \ -p 3030:3030 \
-e IMMICH_URL=http://your-immich-server:2283 \ -e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \ -e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \ --restart unless-stopped \
immich-frame frambe
``` ```
## 🔗 Zero-Touch URL Parameters
Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet:
| URL | What it shows |
|---|---|
| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album |
| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) |
| `http://server:3030/?favorites` | Favorite photos only |
| `http://server:3030/?random` | Random photos from the library |
You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
## ⚙️ Configuration ## ⚙️ Configuration
All settings are via environment variables: All settings are via environment variables:
@@ -72,9 +91,10 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay | | `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar | | `SHOW_PROGRESS` | `true` | Display progress bar |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `PORT` | `3000` | Server port | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `PORT` | `3030` | Server port |
## 🎮 Controls ## 🎮 Controls
@@ -92,7 +112,7 @@ All settings are via environment variables:
## 📱 Tablet Setup Tips ## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
2. Add to Home Screen for a full-screen app experience 2. Add to Home Screen for a full-screen app experience
3. Enable kiosk mode or guided access to lock to the app 3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings 4. Disable screen timeout in your device settings
@@ -101,15 +121,17 @@ All settings are via environment variables:
``` ```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ ImmichFrame │ API │ Immich │ │ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ │ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3000 └──────────────┘ :2283 └──────────────┘ └──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
``` ```
The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
## 📋 Version History ## 📋 Version History
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
- **1.1.0** — Rebrand to Frambe
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment - **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
## 📄 License ## 📄 License
+19 -19
View File
@@ -1,31 +1,31 @@
version: "3.8" version: "3.8"
services: services:
immich-frame: frambe:
build: . build: .
container_name: immich-frame container_name: frambe
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3030:3000"
environment: environment:
# REQUIRED: Your Immich server URL (no trailing slash) # REQUIRED
- IMMICH_URL=http://your-immich-server:2283 - IMMICH_URL=http://your-immich-server:2283
# REQUIRED: Your Immich API key
- IMMICH_API_KEY=your-api-key-here - IMMICH_API_KEY=your-api-key-here
# OPTIONAL: Slideshow settings # Slideshow
- SLIDESHOW_INTERVAL=30 # Seconds between photos - SLIDESHOW_INTERVAL=30
- TRANSITION_DURATION=2 # Crossfade duration in seconds - TRANSITION_DURATION=2
- IMAGE_FIT=contain # 'contain' or 'cover' - IMAGE_FIT=contain
- SHUFFLE=true # Randomise photo order - SHUFFLE=true
- BACKGROUND_BLUR=true # Blurred background behind photos - BACKGROUND_BLUR=true
- REFRESH_INTERVAL=300 # Seconds between album/person refresh checks
# OPTIONAL: Overlay settings # Overlays
- SHOW_CLOCK=true # Show time overlay - SHOW_CLOCK=true
- SHOW_DATE=true # Show date overlay - SHOW_DATE=true
- SHOW_EXIF=true # Show photo location/camera info - SHOW_EXIF=true
- SHOW_PROGRESS=true # Show progress bar - SHOW_PROGRESS=true
# OPTIONAL: Auto-start with specific album or favorites # Auto-start (optional — or use URL params instead)
# - ALBUM_ID= # Auto-start with this album UUID # - ALBUM_ID=
# - SHOW_FAVORITES_ONLY=false # Auto-start showing only favorites # - SHOW_FAVORITES_ONLY=false
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "immich-frame", "name": "frambe",
"version": "1.0.0", "version": "1.2.1",
"description": "A lightweight digital photo frame web app for Immich", "description": "Frambe — a lightweight digital photo frame web app for Immich",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js"
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+10 -29
View File
@@ -6,43 +6,37 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<title>ImmichFrame</title> <meta name="theme-color" content="#0f0f1a">
<title>Frambe</title>
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<!-- Setup Screen -->
<div id="setup-screen" class="screen"> <div id="setup-screen" class="screen">
<div class="setup-container"> <div class="setup-container">
<div class="setup-header"> <div class="setup-header">
<h1>🖼️ ImmichFrame</h1> <img src="/img/icon.png" alt="Frambe" class="setup-logo">
<h1>Frambe</h1>
<p class="subtitle" id="connection-status">Connecting to Immich…</p> <p class="subtitle" id="connection-status">Connecting to Immich…</p>
</div> </div>
<div id="setup-content" class="setup-content"> <div id="setup-content" class="setup-content">
<!-- Album Selection -->
<div class="section"> <div class="section">
<h2>Select Photo Source</h2> <h2>Select Photo Source</h2>
<div class="source-buttons"> <div class="source-buttons">
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"> <button id="btn-all-photos" class="source-btn" onclick="selectSource('random')">
<span class="source-icon">🎲</span> <span class="source-icon">🎲</span><span>Random Photos</span>
<span>Random Photos</span>
</button> </button>
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"> <button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
<span class="source-icon"></span> <span class="source-icon"></span><span>Favorites</span>
<span>Favorites</span>
</button> </button>
</div> </div>
<div id="albums-list" class="albums-list"> <div id="albums-list" class="albums-list">
<p class="loading-text">Loading albums…</p> <p class="loading-text">Loading albums…</p>
</div> </div>
</div> </div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
<!-- Start Button -->
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">
▶ Start Slideshow
</button>
</div> </div>
<div id="setup-error" class="setup-error" style="display:none"> <div id="setup-error" class="setup-error" style="display:none">
<p>⚠️ Cannot connect to Immich</p> <p>⚠️ Cannot connect to Immich</p>
<p class="error-detail" id="error-detail"></p> <p class="error-detail" id="error-detail"></p>
@@ -51,16 +45,10 @@
</div> </div>
</div> </div>
<!-- Slideshow Screen -->
<div id="slideshow-screen" class="screen" style="display:none"> <div id="slideshow-screen" class="screen" style="display:none">
<!-- Background blur layer -->
<div id="bg-blur" class="bg-blur"></div> <div id="bg-blur" class="bg-blur"></div>
<!-- Photo layers (double-buffered for crossfade) -->
<div id="photo-layer-a" class="photo-layer active"></div> <div id="photo-layer-a" class="photo-layer active"></div>
<div id="photo-layer-b" class="photo-layer"></div> <div id="photo-layer-b" class="photo-layer"></div>
<!-- Overlay: Clock & Info -->
<div id="overlay" class="overlay"> <div id="overlay" class="overlay">
<div class="overlay-top-right"> <div class="overlay-top-right">
<div id="clock" class="clock"></div> <div id="clock" class="clock"></div>
@@ -68,21 +56,14 @@
</div> </div>
<div class="overlay-bottom"> <div class="overlay-bottom">
<div id="exif-info" class="exif-info"></div> <div id="exif-info" class="exif-info"></div>
<div id="progress-bar" class="progress-bar"> <div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
<div id="progress-fill" class="progress-fill"></div>
</div>
</div> </div>
</div> </div>
<!-- Touch/Click controls (invisible) -->
<div class="touch-zone touch-left" onclick="prevPhoto()"></div> <div class="touch-zone touch-left" onclick="prevPhoto()"></div>
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div> <div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
<div class="touch-zone touch-right" onclick="nextPhoto()"></div> <div class="touch-zone touch-right" onclick="nextPhoto()"></div>
<!-- Settings button (hidden until overlay shown) -->
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button> <button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button>
</div> </div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>
+158 -477
View File
@@ -1,555 +1,236 @@
// === ImmichFrame - Frontend Application === // === Frambe - Frontend Application ===
(function () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, activeLayer = 'a', slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
// --- State --- var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen');
var config = {}; var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
var assets = []; var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
var currentIndex = -1; var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
var activeLayer = 'a'; var $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b');
var slideshowTimer = null; var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock');
var progressTimer = null; var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info');
var progressStart = 0; var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay');
var overlayVisible = true; var $btnSettings = document.getElementById('btn-settings'), $progressBar = document.getElementById('progress-bar');
var overlayTimeout = null;
var selectedSource = null;
var selectedAlbumId = null;
var isRunning = false;
var preloadedImages = {};
// --- DOM Elements --- function getUrlParams() {
var $setupScreen = document.getElementById('setup-screen'); var p = {}, s = window.location.search.substring(1); if (!s) return p;
var $slideshowScreen = document.getElementById('slideshow-screen'); var pairs = s.split('&');
var $connectionStatus = document.getElementById('connection-status'); for (var i = 0; i < pairs.length; i++) { var kv = pairs[i].split('='); p[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); }
var $setupContent = document.getElementById('setup-content'); return p;
var $setupError = document.getElementById('setup-error'); }
var $errorDetail = document.getElementById('error-detail');
var $albumsList = document.getElementById('albums-list'); // --- Auto-launch helper: sets source and immediately starts slideshow ---
var $btnStart = document.getElementById('btn-start'); async function autoLaunch(source, albumId, personId) {
var $layerA = document.getElementById('photo-layer-a'); urlDriven = true;
var $layerB = document.getElementById('photo-layer-b'); selectedSource = source;
var $bgBlur = document.getElementById('bg-blur'); selectedAlbumId = albumId || null;
var $clock = document.getElementById('clock'); selectedPersonId = personId || null;
var $dateDisplay = document.getElementById('date-display'); console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
var $exifInfo = document.getElementById('exif-info'); await doStartSlideshow();
var $progressFill = document.getElementById('progress-fill'); }
var $overlay = document.getElementById('overlay');
var $btnSettings = document.getElementById('btn-settings');
var $progressBar = document.getElementById('progress-bar');
// --- Initialization ---
async function init() { async function init() {
document.body.classList.add('setup-mode'); document.body.classList.add('setup-mode');
try { try {
// Load config config = await (await fetch('/api/config')).json();
var configRes = await fetch('/api/config'); if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
config = await configRes.json(); var si = await (await fetch('/api/server-info')).json();
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
if (!config.connected) { $connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
showError('API key not configured. Set IMMICH_API_KEY in your environment.');
return;
}
// Test connection
var serverRes = await fetch('/api/server-info');
var serverInfo = await serverRes.json();
if (!serverInfo.ok) {
showError('Cannot reach Immich server: ' + serverInfo.error);
return;
}
$connectionStatus.textContent = 'Connected to Immich v' + serverInfo.version.major + '.' + serverInfo.version.minor + '.' + serverInfo.version.patch;
$connectionStatus.classList.add('connected'); $connectionStatus.classList.add('connected');
// Load albums // Check URL parameters for direct launch (zero-touch)
var params = getUrlParams();
if (params.album) { await autoLaunch('album', params.album, null); return; }
if (params.person) { await autoLaunch('person', null, params.person); return; }
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
if ('random' in params) { await autoLaunch('random', null, null); return; }
// Check env-based auto-start
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
// No auto-start — show setup screen with album picker
await loadAlbums(); await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); }
// If a default album is set, auto-start
if (config.albumId) {
selectedSource = 'album';
selectedAlbumId = config.albumId;
$btnStart.disabled = false;
startSlideshow();
return;
}
if (config.showFavoritesOnly) {
selectedSource = 'favorites';
$btnStart.disabled = false;
startSlideshow();
return;
}
} catch (err) {
showError('Failed to initialize: ' + err.message);
}
} }
function showError(msg) { function showError(msg) { $setupContent.style.display = 'none'; $setupError.style.display = 'block'; $errorDetail.textContent = msg; }
$setupContent.style.display = 'none';
$setupError.style.display = 'block';
$errorDetail.textContent = msg;
}
// --- Albums ---
async function loadAlbums() { async function loadAlbums() {
try { try {
var res = await fetch('/api/albums'); var albums = await (await fetch('/api/albums')).json();
var albums = await res.json(); if (!albums.length) { $albumsList.innerHTML = '<p class="loading-text">No albums found</p>'; return; }
if (!albums.length) {
$albumsList.innerHTML = '<p class="loading-text">No albums found</p>';
return;
}
var html = ''; var html = '';
for (var i = 0; i < albums.length; i++) { for (var i = 0; i < albums.length; i++) {
var a = albums[i]; var a = albums[i], thu = a.albumThumbnailAssetId ? '/api/assets/' + a.albumThumbnailAssetId + '/thumbnail?size=thumbnail' : '';
var thumbUrl = a.albumThumbnailAssetId
? '/api/assets/' + a.albumThumbnailAssetId + '/thumbnail?size=thumbnail'
: '';
html += '<div class="album-item" data-id="' + a.id + '" onclick="selectAlbum(\'' + a.id + '\', this)">'; html += '<div class="album-item" data-id="' + a.id + '" onclick="selectAlbum(\'' + a.id + '\', this)">';
if (thumbUrl) { html += thu ? '<img class="album-thumb" src="' + thu + '" alt="" loading="lazy">' : '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">📁</div>';
html += '<img class="album-thumb" src="' + thumbUrl + '" alt="" loading="lazy">'; html += '<div class="album-info"><div class="album-name">' + escapeHtml(a.albumName) + '</div><div class="album-count">' + a.assetCount + ' photos</div></div></div>';
} else {
html += '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem;">📁</div>';
}
html += '<div class="album-info">';
html += '<div class="album-name">' + escapeHtml(a.albumName) + '</div>';
html += '<div class="album-count">' + a.assetCount + ' photos</div>';
html += '</div></div>';
} }
$albumsList.innerHTML = html; $albumsList.innerHTML = html;
} catch (err) { } catch (e) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; }
$albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>';
}
} }
// --- Source Selection --- window.selectSource = function (src) {
window.selectSource = function (source) { selectedSource = src; selectedAlbumId = null; selectedPersonId = null;
selectedSource = source; document.getElementById('btn-all-photos').classList.toggle('selected', src === 'random');
selectedAlbumId = null; document.getElementById('btn-favorites').classList.toggle('selected', src === 'favorites');
var items = document.querySelectorAll('.album-item');
// Update button states for (var i = 0; i < items.length; i++) items[i].classList.remove('selected');
document.getElementById('btn-all-photos').classList.toggle('selected', source === 'random');
document.getElementById('btn-favorites').classList.toggle('selected', source === 'favorites');
// Deselect albums
var albumItems = document.querySelectorAll('.album-item');
for (var i = 0; i < albumItems.length; i++) {
albumItems[i].classList.remove('selected');
}
$btnStart.disabled = false; $btnStart.disabled = false;
}; };
window.selectAlbum = function (id, el) {
window.selectAlbum = function (albumId, el) { selectedSource = 'album'; selectedAlbumId = id; selectedPersonId = null;
selectedSource = 'album';
selectedAlbumId = albumId;
// Update button states
document.getElementById('btn-all-photos').classList.remove('selected'); document.getElementById('btn-all-photos').classList.remove('selected');
document.getElementById('btn-favorites').classList.remove('selected'); document.getElementById('btn-favorites').classList.remove('selected');
var items = document.querySelectorAll('.album-item');
var albumItems = document.querySelectorAll('.album-item'); for (var i = 0; i < items.length; i++) items[i].classList.remove('selected');
for (var i = 0; i < albumItems.length; i++) { el.classList.add('selected'); $btnStart.disabled = false;
albumItems[i].classList.remove('selected');
}
el.classList.add('selected');
$btnStart.disabled = false;
}; };
// --- Load Assets ---
async function loadAssets() { async function loadAssets() {
var res; var res;
if (selectedSource === 'album' && selectedAlbumId) { if (selectedSource === 'album' && selectedAlbumId) {
res = await fetch('/api/albums/' + selectedAlbumId); res = await fetch('/api/albums/' + selectedAlbumId);
var album = await res.json(); if (!res.ok) throw new Error('Album fetch failed: ' + res.status);
assets = album.assets || []; var al = await res.json();
assets = al.assets || [];
} else if (selectedSource === 'person' && selectedPersonId) {
res = await fetch('/api/people/' + selectedPersonId);
if (!res.ok) throw new Error('Person fetch failed: ' + res.status);
assets = await res.json();
} else if (selectedSource === 'favorites') { } else if (selectedSource === 'favorites') {
res = await fetch('/api/assets/favorites'); res = await fetch('/api/assets/favorites');
if (!res.ok) throw new Error('Favorites fetch failed: ' + res.status);
assets = await res.json(); assets = await res.json();
} else { } else {
res = await fetch('/api/assets/random?count=100'); res = await fetch('/api/assets/random?count=100');
if (!res.ok) throw new Error('Random fetch failed: ' + res.status);
assets = await res.json(); assets = await res.json();
} }
if (config.shuffle) shuffleArray(assets);
if (config.shuffle) { console.log('Frambe: loaded ' + assets.length + ' photo(s) from ' + selectedSource);
shuffleArray(assets);
}
} }
// --- Slideshow Control --- function startRefreshTimer() {
window.startSlideshow = async function () { if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(async function () {
try {
var oldIds = {}; for (var i = 0; i < assets.length; i++) oldIds[assets[i].id] = true;
var nw, r;
if (selectedSource === 'album' && selectedAlbumId) { r = await (await fetch('/api/albums/' + selectedAlbumId)).json(); nw = r.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { nw = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { nw = await (await fetch('/api/assets/favorites')).json(); }
else return;
var added = 0;
for (var j = 0; j < nw.length; j++) { if (!oldIds[nw[j].id]) { assets.push(nw[j]); added++; } }
if (added > 0) console.log('Frambe: added ' + added + ' new photo(s)');
} catch (e) { console.warn('Frambe: refresh failed', e.message); }
}, (config.refreshInterval || 300) * 1000);
}
// --- Core slideshow start (used by both button click and auto-launch) ---
async function doStartSlideshow() {
if (!selectedSource) return; if (!selectedSource) return;
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
$btnStart.disabled = true;
$btnStart.innerHTML = '<span class="spinner"></span> Loading…';
try { try {
await loadAssets(); await loadAssets();
if (!assets.length) { if (!assets.length) {
$btnStart.textContent = 'No photos found'; $btnStart.textContent = 'No photos found';
setTimeout(function () { setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000);
$btnStart.textContent = '▶ Start Slideshow';
$btnStart.disabled = false;
}, 2000);
return; return;
} }
// Switch to slideshow view
// Switch to slideshow
$setupScreen.style.display = 'none'; $setupScreen.style.display = 'none';
$slideshowScreen.style.display = 'block'; $slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode'); document.body.classList.remove('setup-mode');
isRunning = true; isRunning = true;
var t = (config.transitionDuration || 2) * 1000;
// Apply config $layerA.style.transition = 'opacity ' + t + 'ms ease';
var transMs = (config.transitionDuration || 2) * 1000; $layerB.style.transition = 'opacity ' + t + 'ms ease';
$layerA.style.transition = 'opacity ' + transMs + 'ms ease'; $bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
$layerB.style.transition = 'opacity ' + transMs + 'ms ease';
$bgBlur.style.transition = 'opacity ' + (transMs * 0.75) + 'ms ease';
if (!config.showClock) $clock.style.display = 'none'; if (!config.showClock) $clock.style.display = 'none';
if (!config.showDate) $dateDisplay.style.display = 'none'; if (!config.showDate) $dateDisplay.style.display = 'none';
if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showExif) $exifInfo.style.display = 'none';
if (!config.showProgress) $progressBar.style.display = 'none'; if (!config.showProgress) $progressBar.style.display = 'none';
if (!config.backgroundBlur) $bgBlur.style.display = 'none'; if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000);
// Start clock
updateClock();
setInterval(updateClock, 1000);
// Show first photo
currentIndex = -1; currentIndex = -1;
showNextPhoto(); showNextPhoto();
// Auto-hide overlay
scheduleOverlayHide(); scheduleOverlayHide();
startRefreshTimer();
} catch (err) { } catch (err) {
console.error('Frambe: slideshow start failed', err);
$btnStart.textContent = 'Error: ' + err.message; $btnStart.textContent = 'Error: ' + err.message;
setTimeout(function () { setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000);
$btnStart.textContent = '▶ Start Slideshow';
$btnStart.disabled = false;
}, 3000);
} }
}; }
// Exposed for the button onclick
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () { window.exitSlideshow = function () {
isRunning = false; if (urlDriven) { window.location.href = window.location.pathname; return; }
clearTimeout(slideshowTimer); isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer);
clearInterval(progressTimer); $slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode');
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
$slideshowScreen.style.display = 'none'; $layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
$setupScreen.style.display = 'flex'; $layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
document.body.classList.add('setup-mode');
$btnStart.textContent = '▶ Start Slideshow';
$btnStart.disabled = false;
// Reset layers
$layerA.style.backgroundImage = '';
$layerB.style.backgroundImage = '';
$bgBlur.style.backgroundImage = '';
$layerA.classList.add('active');
$layerB.classList.remove('active');
$bgBlur.classList.remove('visible');
activeLayer = 'a';
}; };
// --- Photo Display --- function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
function showNextPhoto() { function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
currentIndex++; function showPhoto(idx) {
if (currentIndex >= assets.length) { if (!assets[idx]) return; clearTimeout(slideshowTimer);
// Reload and reshuffle for infinite loop var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview';
if (config.shuffle) shuffleArray(assets); var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url;
currentIndex = 0; preloadNext(idx + 1);
}
showPhoto(currentIndex);
} }
function showPrevPhoto() {
currentIndex--;
if (currentIndex < 0) currentIndex = assets.length - 1;
showPhoto(currentIndex);
}
function showPhoto(index) {
if (!assets[index]) return;
clearTimeout(slideshowTimer);
clearInterval(progressTimer);
var asset = assets[index];
var imageUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
// Preload the image before showing
var img = new Image();
img.onload = function () {
displayImage(imageUrl, asset);
};
img.onerror = function () {
// Skip broken images
setTimeout(showNextPhoto, 500);
};
img.src = imageUrl;
// Preload next image
preloadNext(index + 1);
}
function displayImage(url, asset) { function displayImage(url, asset) {
var fitStyle = config.imageFit || 'contain'; var fit = config.imageFit || 'contain', inc, out;
var incomingLayer, outgoingLayer; if (activeLayer === 'a') { inc = $layerB; out = $layerA; activeLayer = 'b'; } else { inc = $layerA; out = $layerB; activeLayer = 'a'; }
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit;
if (activeLayer === 'a') { inc.classList.add('active'); out.classList.remove('active');
incomingLayer = $layerB; if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
outgoingLayer = $layerA; updateExifInfo(asset); startProgress();
activeLayer = 'b'; slideshowTimer = setTimeout(showNextPhoto, (config.slideshowInterval || 30) * 1000);
} else {
incomingLayer = $layerA;
outgoingLayer = $layerB;
activeLayer = 'a';
}
// Set the image
incomingLayer.style.backgroundImage = 'url(' + url + ')';
incomingLayer.style.backgroundSize = fitStyle;
// Crossfade
incomingLayer.classList.add('active');
outgoingLayer.classList.remove('active');
// Background blur
if (config.backgroundBlur) {
$bgBlur.style.backgroundImage = 'url(' + url + ')';
$bgBlur.classList.add('visible');
}
// Update EXIF info
updateExifInfo(asset);
// Progress bar
startProgress();
// Schedule next
var interval = (config.slideshowInterval || 30) * 1000;
slideshowTimer = setTimeout(showNextPhoto, interval);
} }
function preloadNext(i) { if (i >= assets.length) i = 0; if (!assets[i]) return; var img = new Image(); img.src = '/api/assets/' + assets[i].id + '/thumbnail?size=preview'; }
function preloadNext(index) { function updateExifInfo(a) {
if (index >= assets.length) index = 0; if (!config.showExif || !a.exifInfo) { $exifInfo.textContent = ''; return; }
if (!assets[index]) return; var p = [], e = a.exifInfo, loc = [e.city, e.state, e.country].filter(Boolean).join(', ');
var img = new Image(); if (loc) p.push('📍 ' + loc);
img.src = '/api/assets/' + assets[index].id + '/thumbnail?size=preview'; if (e.dateTimeOriginal) p.push(formatDate(new Date(e.dateTimeOriginal)));
else if (a.fileCreatedAt) p.push(formatDate(new Date(a.fileCreatedAt)));
if (e.make || e.model) p.push('📷 ' + [e.make, e.model].filter(Boolean).join(' '));
$exifInfo.textContent = p.join(' • ');
} }
// --- EXIF Info ---
function updateExifInfo(asset) {
if (!config.showExif || !asset.exifInfo) {
$exifInfo.textContent = '';
return;
}
var parts = [];
var exif = asset.exifInfo;
// Location
var location = [exif.city, exif.state, exif.country].filter(Boolean).join(', ');
if (location) parts.push('📍 ' + location);
// Date
if (exif.dateTimeOriginal) {
var d = new Date(exif.dateTimeOriginal);
parts.push(formatDate(d));
} else if (asset.fileCreatedAt) {
var d2 = new Date(asset.fileCreatedAt);
parts.push(formatDate(d2));
}
// Camera
if (exif.make || exif.model) {
var camera = [exif.make, exif.model].filter(Boolean).join(' ');
parts.push('📷 ' + camera);
}
$exifInfo.textContent = parts.join(' • ');
}
// --- Progress Bar ---
function startProgress() { function startProgress() {
if (!config.showProgress) return; if (!config.showProgress) return;
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth;
$progressFill.style.transition = 'none'; $progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + 'ms linear'; $progressFill.style.width = '100%';
$progressFill.style.width = '0%';
progressStart = Date.now();
var duration = (config.slideshowInterval || 30) * 1000;
// Force reflow
$progressFill.offsetWidth;
$progressFill.style.transition = 'width ' + duration + 'ms linear';
$progressFill.style.width = '100%';
} }
// --- Clock ---
function updateClock() { function updateClock() {
var now = new Date(); var n = new Date();
if (config.showClock) $clock.textContent = padZero(n.getHours()) + ':' + padZero(n.getMinutes());
if (config.showClock) { if (config.showDate) $dateDisplay.textContent = n.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
var h = now.getHours();
var m = now.getMinutes();
$clock.textContent = padZero(h) + ':' + padZero(m);
}
if (config.showDate) {
var options = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' };
$dateDisplay.textContent = now.toLocaleDateString(undefined, options);
}
} }
window.toggleOverlay = function () { overlayVisible = !overlayVisible; if (overlayVisible) { $overlay.classList.remove('hidden'); $btnSettings.classList.add('visible'); scheduleOverlayHide(); } else { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); } };
// --- Overlay Toggle --- function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
window.toggleOverlay = function () { window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
overlayVisible = !overlayVisible; window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
document.addEventListener('keydown', function (e) { if (!isRunning) return; switch (e.key) { case 'ArrowRight': case ' ': e.preventDefault(); nextPhoto(); break; case 'ArrowLeft': e.preventDefault(); prevPhoto(); break; case 'Escape': exitSlideshow(); break; case 'f': toggleFullscreen(); break; case 'i': toggleOverlay(); break; } });
if (overlayVisible) { function toggleFullscreen() { if (!document.fullscreenElement && !document.webkitFullscreenElement) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } }
$overlay.classList.remove('hidden'); function shuffleArray(arr) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } }
$btnSettings.classList.add('visible'); function padZero(n) { return n < 10 ? '0' + n : '' + n; }
scheduleOverlayHide(); function formatDate(d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return d.getDate() + ' ' + m[d.getMonth()] + ' ' + d.getFullYear(); }
} else { function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
$overlay.classList.add('hidden'); async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
$btnSettings.classList.remove('visible'); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && isRunning) requestWakeLock(); });
} function preventSleep() { try { var v = document.createElement('video'); v.setAttribute('playsinline',''); v.setAttribute('muted',''); v.setAttribute('loop',''); v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0.01'; v.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA'; document.body.appendChild(v); v.play().catch(function(){}); } catch(e){} }
}; init(); requestWakeLock(); preventSleep();
function scheduleOverlayHide() {
clearTimeout(overlayTimeout);
overlayTimeout = setTimeout(function () {
$overlay.classList.add('hidden');
$btnSettings.classList.remove('visible');
overlayVisible = false;
}, 8000);
}
// --- Navigation ---
window.nextPhoto = function () {
showNextPhoto();
if (overlayVisible) scheduleOverlayHide();
};
window.prevPhoto = function () {
showPrevPhoto();
if (overlayVisible) scheduleOverlayHide();
};
// --- Keyboard Controls ---
document.addEventListener('keydown', function (e) {
if (!isRunning) return;
switch (e.key) {
case 'ArrowRight':
case ' ':
e.preventDefault();
nextPhoto();
break;
case 'ArrowLeft':
e.preventDefault();
prevPhoto();
break;
case 'Escape':
exitSlideshow();
break;
case 'f':
toggleFullscreen();
break;
case 'i':
toggleOverlay();
break;
}
});
// --- Fullscreen ---
function toggleFullscreen() {
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
var el = document.documentElement;
if (el.requestFullscreen) el.requestFullscreen();
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
}
}
// --- Utility ---
function shuffleArray(arr) {
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
function padZero(n) {
return n < 10 ? '0' + n : '' + n;
}
function formatDate(d) {
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return d.getDate() + ' ' + months[d.getMonth()] + ' ' + d.getFullYear();
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// --- Keep screen awake (where supported) ---
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
await navigator.wakeLock.request('screen');
}
} catch (e) {
// Not supported or permission denied - that's fine
}
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && isRunning) {
requestWakeLock();
}
});
// --- Prevent screen sleep on older devices via video trick ---
function preventSleep() {
try {
var noSleep = document.createElement('video');
noSleep.setAttribute('playsinline', '');
noSleep.setAttribute('muted', '');
noSleep.setAttribute('loop', '');
noSleep.style.position = 'absolute';
noSleep.style.width = '1px';
noSleep.style.height = '1px';
noSleep.style.opacity = '0.01';
// Tiny transparent video data URI
noSleep.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAA' +
'htZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAA' +
'AAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hk' +
'AAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAA' +
'AAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';
document.body.appendChild(noSleep);
noSleep.play().catch(function () {});
} catch (e) {}
}
// --- Boot ---
init();
requestWakeLock();
preventSleep();
})(); })();
+79 -178
View File
@@ -6,7 +6,6 @@ require('dotenv').config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// --- Configuration ---
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || ''; const API_KEY = process.env.IMMICH_API_KEY || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30; const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
@@ -20,230 +19,132 @@ const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
const SHUFFLE = process.env.SHUFFLE !== 'false'; const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || ''; const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
// --- Shared headers for Immich API ---
function immichHeaders() { function immichHeaders() {
return { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
'x-api-key': API_KEY,
'Accept': 'application/json',
'Content-Type': 'application/json',
};
} }
// --- Middleware ---
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json()); app.use(express.json());
// --- API: Config endpoint (sends safe config to frontend) ---
app.get('/api/config', (_req, res) => { app.get('/api/config', (_req, res) => {
res.json({ res.json({
slideshowInterval: SLIDESHOW_INTERVAL, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
showClock: SHOW_CLOCK, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
showDate: SHOW_DATE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL,
showExif: SHOW_EXIF,
showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT,
backgroundBlur: BACKGROUND_BLUR,
shuffle: SHUFFLE,
albumId: ALBUM_ID,
showFavoritesOnly: SHOW_FAVORITES_ONLY,
connected: !!API_KEY, connected: !!API_KEY,
}); });
}); });
// --- API: Server info / connectivity check ---
app.get('/api/server-info', async (_req, res) => { app.get('/api/server-info', async (_req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/server/version`, { const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); res.json({ ok: true, version: await r.json() });
if (!response.ok) throw new Error(`Immich returned ${response.status}`); } catch (err) { res.status(502).json({ ok: false, error: err.message }); }
const data = await response.json();
res.json({ ok: true, version: data });
} catch (err) {
res.status(502).json({ ok: false, error: err.message });
}
}); });
// --- API: List albums ---
app.get('/api/albums', async (_req, res) => { app.get('/api/albums', async (_req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/albums`, { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); const albums = await r.json();
if (!response.ok) throw new Error(`Immich returned ${response.status}`); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
const albums = await response.json(); } catch (err) { res.status(502).json({ error: err.message }); }
// Return simplified album list
const simplified = albums.map((a) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount,
albumThumbnailAssetId: a.albumThumbnailAssetId,
updatedAt: a.updatedAt,
}));
res.json(simplified);
} catch (err) {
res.status(502).json({ error: err.message });
}
}); });
// --- API: Get album assets --- function mapAsset(a) {
return {
id: a.id, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null,
};
}
app.get('/api/albums/:id', async (req, res) => { app.get('/api/albums/:id', async (req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); const album = await r.json();
if (!response.ok) throw new Error(`Immich returned ${response.status}`); const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
const album = await response.json(); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
// Filter to images only, return simplified asset data } catch (err) { res.status(502).json({ error: err.message }); }
const assets = (album.assets || []) });
.filter((a) => a.type === 'IMAGE')
.map((a) => ({ app.get('/api/people', async (_req, res) => {
id: a.id, try {
originalFileName: a.originalFileName, const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
fileCreatedAt: a.fileCreatedAt, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
exifInfo: a.exifInfo const data = await r.json();
? { res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
make: a.exifInfo.make, } catch (err) { res.status(502).json({ error: err.message }); }
model: a.exifInfo.model, });
city: a.exifInfo.city,
state: a.exifInfo.state, app.get('/api/people/:id', async (req, res) => {
country: a.exifInfo.country, try {
description: a.exifInfo.description, const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
dateTimeOriginal: a.exifInfo.dateTimeOriginal, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
} const assets = await r.json();
: null, res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
})); } catch (err) { res.status(502).json({ error: err.message }); }
res.json({ });
id: album.id,
albumName: album.albumName, app.get('/api/people/:id/thumbnail', async (req, res) => {
assetCount: assets.length, try {
assets, const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } });
}); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
} catch (err) { res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.status(502).json({ error: err.message }); res.set('Cache-Control', 'public, max-age=86400');
} r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
// --- API: Get random assets (when no album selected) ---
app.get('/api/assets/random', async (req, res) => { app.get('/api/assets/random', async (req, res) => {
try { try {
const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
const response = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
if (!response.ok) throw new Error(`Immich returned ${response.status}`); } catch (err) { res.status(502).json({ error: err.message }); }
const assets = await response.json();
const images = assets
.filter((a) => a.type === 'IMAGE')
.map((a) => ({
id: a.id,
originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt,
isFavorite: a.isFavorite,
exifInfo: a.exifInfo
? {
make: a.exifInfo.make,
model: a.exifInfo.model,
city: a.exifInfo.city,
state: a.exifInfo.state,
country: a.exifInfo.country,
description: a.exifInfo.description,
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
}
: null,
}));
res.json(images);
} catch (err) {
res.status(502).json({ error: err.message });
}
}); });
// --- API: Get favorites ---
app.get('/api/assets/favorites', async (_req, res) => { app.get('/api/assets/favorites', async (_req, res) => {
try { try {
const body = JSON.stringify({ const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
isFavorite: true, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
type: 'IMAGE', const data = await r.json();
size: 250, res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
page: 1, } catch (err) { res.status(502).json({ error: err.message }); }
});
const response = await fetch(`${IMMICH_URL}/api/search/metadata`, {
method: 'POST',
headers: immichHeaders(),
body,
});
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
const data = await response.json();
const images = (data.assets?.items || []).map((a) => ({
id: a.id,
originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt,
isFavorite: true,
exifInfo: a.exifInfo
? {
make: a.exifInfo.make,
model: a.exifInfo.model,
city: a.exifInfo.city,
state: a.exifInfo.state,
country: a.exifInfo.country,
description: a.exifInfo.description,
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
}
: null,
}));
res.json(images);
} catch (err) {
res.status(502).json({ error: err.message });
}
}); });
// --- API: Proxy image (thumbnail) ---
app.get('/api/assets/:id/thumbnail', async (req, res) => { app.get('/api/assets/:id/thumbnail', async (req, res) => {
try { try {
const size = req.query.size || 'preview'; // 'thumbnail' or 'preview' const size = req.query.size || 'preview';
const response = await fetch( const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } });
`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
{ headers: { 'x-api-key': API_KEY } } res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
);
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
const contentType = response.headers.get('content-type');
res.set('Content-Type', contentType || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
response.body.pipe(res); r.body.pipe(res);
} catch (err) { } catch (err) { res.status(502).json({ error: err.message }); }
res.status(502).json({ error: err.message });
}
}); });
// --- API: Proxy full-size image ---
app.get('/api/assets/:id/original', async (req, res) => { app.get('/api/assets/:id/original', async (req, res) => {
try { try {
const response = await fetch( const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } });
`${IMMICH_URL}/api/assets/${req.params.id}/original`, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
{ headers: { 'x-api-key': API_KEY } } res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
);
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
const contentType = response.headers.get('content-type');
res.set('Content-Type', contentType || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
response.body.pipe(res); r.body.pipe(res);
} catch (err) { } catch (err) { res.status(502).json({ error: err.message }); }
res.status(502).json({ error: err.message });
}
}); });
// --- Fallback: serve index.html for SPA --- app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// --- Start ---
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ ImmichFrame running on http://0.0.0.0:${PORT}`); console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`);
console.log(`📡 Immich server: ${IMMICH_URL}`); console.log(`📡 Immich server: ${IMMICH_URL}`);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
console.log(`⏱️ Slideshow interval: ${SLIDESHOW_INTERVAL}s`); console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`);
}); });