12 Commits

9 changed files with 175 additions and 57 deletions
+2 -2
View File
@@ -22,5 +22,5 @@ SHOW_PROGRESS=true
# ALBUM_ID=
# SHOW_FAVORITES_ONLY=false
# Server
PORT=3030
# Server (internal port — Docker maps externally via docker-compose)
PORT=3000
+2 -2
View File
@@ -11,10 +11,10 @@ RUN npm install --production && npm cache clean --force
COPY server.js ./
COPY public/ ./public/
EXPOSE 3030
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3030/api/config || exit 1
CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
+34 -12
View File
@@ -1,4 +1,8 @@
# 🖼️ Frambe
# 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.
@@ -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)
- **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
- **Background Blur** — Blurred backdrop fills the space behind non-covering images
- **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
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
- **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
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **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
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git
cd immich-frame
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd frambe
```
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
@@ -40,7 +46,7 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
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
@@ -48,13 +54,26 @@ Open `http://your-server:3000` in a browser on your tablet/screen.
docker build -t frambe .
docker run -d \
--name frambe \
-p 3000:3000 \
-p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \
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
All settings are via environment variables:
@@ -72,9 +91,10 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites |
| `PORT` | `3000` | Server port |
| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
## 🎮 Controls
@@ -92,7 +112,7 @@ All settings are via environment variables:
## 📱 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
3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings
@@ -103,13 +123,15 @@ All settings are via environment variables:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │
│ (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
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
- **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 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: frambe
restart: unless-stopped
ports:
- "3030:3030"
- "3030:3000"
environment:
# REQUIRED
- IMMICH_URL=http://your-immich-server:2283
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frambe",
"version": "1.2.0",
"version": "1.2.2",
"description": "Frambe — a lightweight digital photo frame web app for Immich",
"main": "server.js",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+73 -20
View File
@@ -22,6 +22,16 @@
return p;
}
// --- Auto-launch helper: sets source and immediately starts slideshow ---
async function autoLaunch(source, albumId, personId) {
urlDriven = true;
selectedSource = source;
selectedAlbumId = albumId || null;
selectedPersonId = personId || null;
console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
await doStartSlideshow();
}
async function init() {
document.body.classList.add('setup-mode');
try {
@@ -31,13 +41,19 @@
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
$connectionStatus.classList.add('connected');
// Check URL parameters for direct launch (zero-touch)
var params = getUrlParams();
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; }
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();
} catch (err) { showError('Failed to initialize: ' + err.message); }
}
@@ -77,11 +93,27 @@
};
async function loadAssets() {
if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); }
else { assets = await (await fetch('/api/assets/random?count=100')).json(); }
var res;
if (selectedSource === 'album' && selectedAlbumId) {
res = await fetch('/api/albums/' + selectedAlbumId);
if (!res.ok) throw new Error('Album fetch failed: ' + res.status);
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') {
res = await fetch('/api/assets/favorites');
if (!res.ok) throw new Error('Favorites fetch failed: ' + res.status);
assets = await res.json();
} else {
res = await fetch('/api/assets/random?count=100');
if (!res.ok) throw new Error('Random fetch failed: ' + res.status);
assets = await res.json();
}
if (config.shuffle) shuffleArray(assets);
console.log('Frambe: loaded ' + assets.length + ' photo(s) from ' + selectedSource);
}
function startRefreshTimer() {
@@ -101,24 +133,45 @@
}, (config.refreshInterval || 300) * 1000);
}
window.startSlideshow = async function () {
// --- Core slideshow start (used by both button click and auto-launch) ---
async function doStartSlideshow() {
if (!selectedSource) return;
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
try {
await loadAssets();
if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000); return; }
$setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode'); isRunning = true;
if (!assets.length) {
$btnStart.textContent = 'No photos found';
setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000);
return;
}
// Switch to slideshow view
$setupScreen.style.display = 'none';
$slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode');
isRunning = true;
var t = (config.transitionDuration || 2) * 1000;
$layerA.style.transition = 'opacity ' + t + 'ms ease'; $layerB.style.transition = 'opacity ' + t + 'ms ease';
$layerA.style.transition = 'opacity ' + t + 'ms ease';
$layerB.style.transition = 'opacity ' + t + 'ms ease';
$bgBlur.style.transition = 'opacity ' + (t * 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.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';
updateClock(); setInterval(updateClock, 1000);
currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer();
} catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); }
};
currentIndex = -1;
showNextPhoto();
scheduleOverlayHide();
startRefreshTimer();
} catch (err) {
console.error('Frambe: slideshow start failed', err);
$btnStart.textContent = 'Error: ' + err.message;
setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000);
}
}
// Exposed for the button onclick
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; }
+62 -19
View File
@@ -3,8 +3,9 @@ const fetch = require('node-fetch');
const path = require('path');
require('dotenv').config();
const VERSION = '1.2.2';
const app = express();
const PORT = process.env.PORT || 3030;
const PORT = process.env.PORT || 3000;
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || '';
@@ -25,11 +26,31 @@ function immichHeaders() {
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
}
app.use(express.static(path.join(__dirname, 'public')));
function log(msg) { console.log('[Frambe] ' + msg); }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
// --- Request logging for API calls ---
app.use('/api', (req, _res, next) => {
log('API ' + req.method + ' ' + req.originalUrl);
next();
});
// --- Static files with no-cache on HTML/JS/CSS (prevents stale browser cache) ---
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}
}));
app.use(express.json());
// --- API: Config ---
app.get('/api/config', (_req, res) => {
res.json({
version: VERSION,
slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
@@ -38,21 +59,29 @@ app.get('/api/config', (_req, res) => {
});
});
// --- API: Server info ---
app.get('/api/server-info', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json({ ok: true, version: await r.json() });
} catch (err) { res.status(502).json({ ok: false, error: err.message }); }
const v = await r.json();
log('Immich connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch);
res.json({ ok: true, version: v });
} catch (err) {
logErr('Immich connection failed: ' + err.message);
res.status(502).json({ ok: false, error: err.message });
}
});
// --- API: Albums ---
app.get('/api/albums', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const albums = await r.json();
log('Listed ' + albums.length + ' albums');
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { res.status(502).json({ error: err.message }); }
} catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
function mapAsset(a) {
@@ -64,12 +93,14 @@ function mapAsset(a) {
app.get('/api/albums/:id', async (req, res) => {
try {
log('Fetching album ' + req.params.id);
const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const album = await r.json();
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
log('Album "' + album.albumName + '" returned ' + assets.length + ' images');
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { res.status(502).json({ error: err.message }); }
} catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/people', async (_req, res) => {
@@ -77,17 +108,22 @@ app.get('/api/people', async (_req, res) => {
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
} catch (err) { res.status(502).json({ error: err.message }); }
const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }));
log('Listed ' + people.length + ' people');
res.json(people);
} catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/people/:id', async (req, res) => {
try {
log('Fetching person ' + req.params.id);
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const assets = await r.json();
res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
const images = (Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset);
log('Person returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/people/:id/thumbnail', async (req, res) => {
@@ -105,8 +141,10 @@ app.get('/api/assets/random', async (req, res) => {
const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
const images = (await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset);
log('Random returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/favorites', async (_req, res) => {
@@ -114,8 +152,10 @@ app.get('/api/assets/favorites', async (_req, res) => {
const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
} catch (err) { res.status(502).json({ error: err.message }); }
const images = (data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }));
log('Favorites returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/thumbnail', async (req, res) => {
@@ -142,9 +182,12 @@ app.get('/api/assets/:id/original', async (req, res) => {
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`);
console.log(`📡 Immich server: ${IMMICH_URL}`);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`);
log('--- Frambe v' + VERSION + ' ---');
log('Server listening on port ' + PORT);
log('Immich URL: ' + IMMICH_URL);
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's');
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
log('Waiting for requests...');
});