15 Commits

Author SHA1 Message Date
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
8 changed files with 258 additions and 753 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
PORT=3000 PORT=3030
+4 -11
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 3030
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:3030/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
+6 -5
View File
@@ -1,4 +1,4 @@
# 🖼️ ImmichFrame # 🖼️ Frambe
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.
@@ -45,14 +45,14 @@ Open `http://your-server:3000` 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 3000:3000 \
-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
``` ```
## ⚙️ Configuration ## ⚙️ Configuration
@@ -101,7 +101,7 @@ 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 └──────────────┘ └──────────────┘ :3000 └──────────────┘ :2283 └──────────────┘
``` ```
@@ -110,6 +110,7 @@ The Node.js backend acts as a secure proxy — your Immich API key never reaches
## 📋 Version History ## 📋 Version History
- **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:3030"
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.0",
"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"
+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>
+132 -504
View File
@@ -1,555 +1,183 @@
// === 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');
var $btnStart = document.getElementById('btn-start');
var $layerA = document.getElementById('photo-layer-a');
var $layerB = document.getElementById('photo-layer-b');
var $bgBlur = document.getElementById('bg-blur');
var $clock = document.getElementById('clock');
var $dateDisplay = document.getElementById('date-display');
var $exifInfo = document.getElementById('exif-info');
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');
var params = getUrlParams();
// Load albums if (params.album) { urlDriven = true; selectedSource = 'album'; selectedAlbumId = params.album; $btnStart.disabled = false; startSlideshow(); return; }
if (params.person) { urlDriven = true; selectedSource = 'person'; selectedPersonId = params.person; $btnStart.disabled = false; startSlideshow(); return; }
if (params.favorites === '' || params.favorites === 'true' || params.favorites === '1') { urlDriven = true; selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; }
if (params.random === '' || params.random === 'true' || params.random === '1') { urlDriven = true; selectedSource = 'random'; $btnStart.disabled = false; startSlideshow(); return; }
if (config.albumId) { selectedSource = 'album'; selectedAlbumId = config.albumId; $btnStart.disabled = false; startSlideshow(); return; }
if (config.showFavoritesOnly) { selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; }
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; if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; }
if (selectedSource === 'album' && selectedAlbumId) { else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); }
res = await fetch('/api/albums/' + selectedAlbumId); else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); }
var album = await res.json(); else { assets = await (await fetch('/api/assets/random?count=100')).json(); }
assets = album.assets || []; if (config.shuffle) shuffleArray(assets);
} else if (selectedSource === 'favorites') { }
res = await fetch('/api/assets/favorites');
assets = await res.json(); function startRefreshTimer() {
} else { if (refreshTimer) clearInterval(refreshTimer);
res = await fetch('/api/assets/random?count=100'); refreshTimer = setInterval(async function () {
assets = await res.json(); try {
} var oldIds = {}; for (var i = 0; i < assets.length; i++) oldIds[assets[i].id] = true;
var nw, r;
if (config.shuffle) { if (selectedSource === 'album' && selectedAlbumId) { r = await (await fetch('/api/albums/' + selectedAlbumId)).json(); nw = r.assets || []; }
shuffleArray(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);
} }
// --- Slideshow Control ---
window.startSlideshow = async function () { window.startSlideshow = async function () {
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) { $btnStart.textContent = 'No photos found'; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000); return; }
if (!assets.length) { $setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
$btnStart.textContent = 'No photos found'; document.body.classList.remove('setup-mode'); isRunning = true;
setTimeout(function () { var t = (config.transitionDuration || 2) * 1000;
$btnStart.textContent = '▶ Start Slideshow'; $layerA.style.transition = 'opacity ' + t + 'ms ease'; $layerB.style.transition = 'opacity ' + t + 'ms ease';
$btnStart.disabled = false; $bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
}, 2000); if (!config.showClock) $clock.style.display = 'none'; if (!config.showDate) $dateDisplay.style.display = 'none';
return; if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showProgress) $progressBar.style.display = 'none';
}
// Switch to slideshow
$setupScreen.style.display = 'none';
$slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode');
isRunning = true;
// Apply config
var transMs = (config.transitionDuration || 2) * 1000;
$layerA.style.transition = 'opacity ' + transMs + '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.showDate) $dateDisplay.style.display = 'none';
if (!config.showExif) $exifInfo.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 currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer();
updateClock(); } catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); }
setInterval(updateClock, 1000);
// Show first photo
currentIndex = -1;
showNextPhoto();
// Auto-hide overlay
scheduleOverlayHide();
} catch (err) {
$btnStart.textContent = 'Error: ' + err.message;
setTimeout(function () {
$btnStart.textContent = '▶ Start Slideshow';
$btnStart.disabled = false;
}, 3000);
}
}; };
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();
})(); })();
+80 -179
View File
@@ -4,9 +4,8 @@ const path = require('path');
require('dotenv').config(); require('dotenv').config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3030;
// --- 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`);
}); });