Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d85d58526 | |||
| 68dbf9dc59 | |||
| 4f7d30d3d2 | |||
| cac179754c | |||
| a609ed2165 | |||
| 2ea13fcbaa | |||
| 6c26688244 | |||
| 88d79a9569 | |||
| 1c55aa9418 |
+2
-2
@@ -22,5 +22,5 @@ SHOW_PROGRESS=true
|
|||||||
# ALBUM_ID=
|
# ALBUM_ID=
|
||||||
# SHOW_FAVORITES_ONLY=false
|
# SHOW_FAVORITES_ONLY=false
|
||||||
|
|
||||||
# Server
|
# Server (internal port — Docker maps externally via docker-compose)
|
||||||
PORT=3030
|
PORT=3000
|
||||||
|
|||||||
+2
-2
@@ -11,10 +11,10 @@ RUN npm install --production && npm cache clean --force
|
|||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
COPY public/ ./public/
|
COPY public/ ./public/
|
||||||
|
|
||||||
EXPOSE 3030
|
EXPOSE 3000
|
||||||
|
|
||||||
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:3030/api/config || exit 1
|
CMD wget -qO- http://localhost:3000/api/config || exit 1
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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.
|
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,7 +46,7 @@ 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
|
||||||
|
|
||||||
@@ -48,13 +54,26 @@ Open `http://your-server:3000` in a browser on your tablet/screen.
|
|||||||
docker build -t frambe .
|
docker build -t frambe .
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name frambe \
|
--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 \
|
||||||
frambe
|
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
|
||||||
@@ -103,13 +123,14 @@ All settings are via environment variables:
|
|||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ Browser │ HTTP │ Frambe │ 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.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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
|||||||
container_name: frambe
|
container_name: frambe
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3030:3030"
|
- "3030:3000"
|
||||||
environment:
|
environment:
|
||||||
# REQUIRED
|
# REQUIRED
|
||||||
- IMMICH_URL=http://your-immich-server:2283
|
- IMMICH_URL=http://your-immich-server:2283
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frambe",
|
"name": "frambe",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"description": "Frambe — 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": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
+73
-20
@@ -22,6 +22,16 @@
|
|||||||
return p;
|
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() {
|
async function init() {
|
||||||
document.body.classList.add('setup-mode');
|
document.body.classList.add('setup-mode');
|
||||||
try {
|
try {
|
||||||
@@ -31,13 +41,19 @@
|
|||||||
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
|
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.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
|
||||||
$connectionStatus.classList.add('connected');
|
$connectionStatus.classList.add('connected');
|
||||||
|
|
||||||
|
// Check URL parameters for direct launch (zero-touch)
|
||||||
var params = getUrlParams();
|
var params = getUrlParams();
|
||||||
if (params.album) { urlDriven = true; selectedSource = 'album'; selectedAlbumId = params.album; $btnStart.disabled = false; startSlideshow(); return; }
|
if (params.album) { await autoLaunch('album', params.album, null); return; }
|
||||||
if (params.person) { urlDriven = true; selectedSource = 'person'; selectedPersonId = params.person; $btnStart.disabled = false; startSlideshow(); return; }
|
if (params.person) { await autoLaunch('person', null, params.person); return; }
|
||||||
if (params.favorites === '' || params.favorites === 'true' || params.favorites === '1') { urlDriven = true; selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; }
|
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
|
||||||
if (params.random === '' || params.random === 'true' || params.random === '1') { urlDriven = true; selectedSource = 'random'; $btnStart.disabled = false; startSlideshow(); return; }
|
if ('random' in params) { await autoLaunch('random', null, null); return; }
|
||||||
if (config.albumId) { selectedSource = 'album'; selectedAlbumId = config.albumId; $btnStart.disabled = false; startSlideshow(); return; }
|
|
||||||
if (config.showFavoritesOnly) { selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); 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); }
|
} catch (err) { showError('Failed to initialize: ' + err.message); }
|
||||||
}
|
}
|
||||||
@@ -77,11 +93,27 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function loadAssets() {
|
async function loadAssets() {
|
||||||
if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; }
|
var res;
|
||||||
else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); }
|
if (selectedSource === 'album' && selectedAlbumId) {
|
||||||
else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); }
|
res = await fetch('/api/albums/' + selectedAlbumId);
|
||||||
else { assets = await (await fetch('/api/assets/random?count=100')).json(); }
|
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);
|
if (config.shuffle) shuffleArray(assets);
|
||||||
|
console.log('Frambe: loaded ' + assets.length + ' photo(s) from ' + selectedSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRefreshTimer() {
|
function startRefreshTimer() {
|
||||||
@@ -101,24 +133,45 @@
|
|||||||
}, (config.refreshInterval || 300) * 1000);
|
}, (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;
|
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 () { $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;
|
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';
|
$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.showClock) $clock.style.display = 'none';
|
||||||
if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showProgress) $progressBar.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);
|
updateClock(); setInterval(updateClock, 1000);
|
||||||
currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer();
|
currentIndex = -1;
|
||||||
} catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); }
|
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 () {
|
window.exitSlideshow = function () {
|
||||||
if (urlDriven) { window.location.href = window.location.pathname; return; }
|
if (urlDriven) { window.location.href = window.location.pathname; return; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
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 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 || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user