Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6943d0c0dd | |||
| 4f2f6ee555 | |||
| f2cc8b4413 | |||
| 4d85d58526 | |||
| 68dbf9dc59 | |||
| 4f7d30d3d2 | |||
| cac179754c | |||
| a609ed2165 | |||
| 2ea13fcbaa | |||
| 6c26688244 | |||
| 88d79a9569 | |||
| 1c55aa9418 | |||
| 3cfcc3f983 | |||
| 245766d841 | |||
| d32180d352 | |||
| 4feaaf30d0 | |||
| be6f0fc30f | |||
| 00bfa926e4 | |||
| ea2828d071 |
+3
-2
@@ -10,6 +10,7 @@ TRANSITION_DURATION=2
|
|||||||
IMAGE_FIT=contain
|
IMAGE_FIT=contain
|
||||||
SHUFFLE=true
|
SHUFFLE=true
|
||||||
BACKGROUND_BLUR=true
|
BACKGROUND_BLUR=true
|
||||||
|
REFRESH_INTERVAL=300
|
||||||
|
|
||||||
# Overlays
|
# Overlays
|
||||||
SHOW_CLOCK=true
|
SHOW_CLOCK=true
|
||||||
@@ -17,9 +18,9 @@ SHOW_DATE=true
|
|||||||
SHOW_EXIF=true
|
SHOW_EXIF=true
|
||||||
SHOW_PROGRESS=true
|
SHOW_PROGRESS=true
|
||||||
|
|
||||||
# Auto-start (optional)
|
# Auto-start (optional — or use URL params instead)
|
||||||
# ALBUM_ID=
|
# ALBUM_ID=
|
||||||
# SHOW_FAVORITES_ONLY=false
|
# SHOW_FAVORITES_ONLY=false
|
||||||
|
|
||||||
# Server
|
# Server (internal port — Docker maps externally via docker-compose)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|||||||
@@ -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: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 \
|
||||||
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` | `3000` | Internal server port (Docker maps externally via compose) |
|
||||||
|
|
||||||
## 🎮 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,15 @@ 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.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.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
|
||||||
|
|
||||||
|
|||||||
+17
-17
@@ -6,26 +6,26 @@ services:
|
|||||||
container_name: frambe
|
container_name: frambe
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3030:3000"
|
||||||
environment:
|
environment:
|
||||||
# REQUIRED: Your Immich server URL (no trailing slash)
|
# REQUIRED
|
||||||
- IMMICH_URL=http://your-immich-server:2283
|
- IMMICH_URL=http://your-immich-server:2283
|
||||||
# REQUIRED: Your Immich API key
|
|
||||||
- IMMICH_API_KEY=your-api-key-here
|
- IMMICH_API_KEY=your-api-key-here
|
||||||
|
|
||||||
# OPTIONAL: Slideshow settings
|
# Slideshow
|
||||||
- SLIDESHOW_INTERVAL=30 # Seconds between photos
|
- SLIDESHOW_INTERVAL=30
|
||||||
- TRANSITION_DURATION=2 # Crossfade duration in seconds
|
- TRANSITION_DURATION=2
|
||||||
- IMAGE_FIT=contain # 'contain' or 'cover'
|
- IMAGE_FIT=contain
|
||||||
- SHUFFLE=true # Randomise photo order
|
- SHUFFLE=true
|
||||||
- BACKGROUND_BLUR=true # Blurred background behind photos
|
- BACKGROUND_BLUR=true
|
||||||
|
- REFRESH_INTERVAL=300 # Seconds between album/person refresh checks
|
||||||
|
|
||||||
# OPTIONAL: Overlay settings
|
# Overlays
|
||||||
- SHOW_CLOCK=true # Show time overlay
|
- SHOW_CLOCK=true
|
||||||
- SHOW_DATE=true # Show date overlay
|
- SHOW_DATE=true
|
||||||
- SHOW_EXIF=true # Show photo location/camera info
|
- SHOW_EXIF=true
|
||||||
- SHOW_PROGRESS=true # Show progress bar
|
- SHOW_PROGRESS=true
|
||||||
|
|
||||||
# OPTIONAL: Auto-start with specific album or favorites
|
# Auto-start (optional — or use URL params instead)
|
||||||
# - ALBUM_ID= # Auto-start with this album UUID
|
# - ALBUM_ID=
|
||||||
# - SHOW_FAVORITES_ONLY=false # Auto-start showing only favorites
|
# - SHOW_FAVORITES_ONLY=false
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frambe",
|
"name": "frambe",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"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 |
+9
-28
@@ -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">
|
||||||
|
<meta name="theme-color" content="#0f0f1a">
|
||||||
<title>Frambe</title>
|
<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>🖼️ Frambe</h1>
|
<img src="/img/icon.png" alt="Frambe" class="setup-logo">
|
||||||
|
<h1>Frambe</h1>
|
||||||
<p class="subtitle" id="connection-status">Connecting to Immich…</p>
|
<p class="subtitle" id="connection-status">Connecting to Immich…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="setup-content" class="setup-content">
|
<div id="setup-content" class="setup-content">
|
||||||
<!-- Album Selection -->
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Select Photo Source</h2>
|
<h2>Select Photo Source</h2>
|
||||||
<div class="source-buttons">
|
<div class="source-buttons">
|
||||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')">
|
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')">
|
||||||
<span class="source-icon">🎲</span>
|
<span class="source-icon">🎲</span><span>Random Photos</span>
|
||||||
<span>Random Photos</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
|
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
|
||||||
<span class="source-icon">⭐</span>
|
<span class="source-icon">⭐</span><span>Favorites</span>
|
||||||
<span>Favorites</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="albums-list" class="albums-list">
|
<div id="albums-list" class="albums-list">
|
||||||
<p class="loading-text">Loading albums…</p>
|
<p class="loading-text">Loading albums…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
|
||||||
<!-- Start Button -->
|
|
||||||
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">
|
|
||||||
▶ Start Slideshow
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="setup-error" class="setup-error" style="display:none">
|
<div id="setup-error" class="setup-error" style="display:none">
|
||||||
<p>⚠️ Cannot connect to Immich</p>
|
<p>⚠️ Cannot connect to Immich</p>
|
||||||
<p class="error-detail" id="error-detail"></p>
|
<p class="error-detail" id="error-detail"></p>
|
||||||
@@ -51,16 +45,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slideshow Screen -->
|
|
||||||
<div id="slideshow-screen" class="screen" style="display:none">
|
<div id="slideshow-screen" class="screen" style="display:none">
|
||||||
<!-- Background blur layer -->
|
|
||||||
<div id="bg-blur" class="bg-blur"></div>
|
<div id="bg-blur" class="bg-blur"></div>
|
||||||
|
|
||||||
<!-- Photo layers (double-buffered for crossfade) -->
|
|
||||||
<div id="photo-layer-a" class="photo-layer active"></div>
|
<div id="photo-layer-a" class="photo-layer active"></div>
|
||||||
<div id="photo-layer-b" class="photo-layer"></div>
|
<div id="photo-layer-b" class="photo-layer"></div>
|
||||||
|
|
||||||
<!-- Overlay: Clock & Info -->
|
|
||||||
<div id="overlay" class="overlay">
|
<div id="overlay" class="overlay">
|
||||||
<div class="overlay-top-right">
|
<div class="overlay-top-right">
|
||||||
<div id="clock" class="clock"></div>
|
<div id="clock" class="clock"></div>
|
||||||
@@ -68,21 +56,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="overlay-bottom">
|
<div class="overlay-bottom">
|
||||||
<div id="exif-info" class="exif-info"></div>
|
<div id="exif-info" class="exif-info"></div>
|
||||||
<div id="progress-bar" class="progress-bar">
|
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
|
||||||
<div id="progress-fill" class="progress-fill"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Touch/Click controls (invisible) -->
|
|
||||||
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
|
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
|
||||||
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
|
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
|
||||||
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
|
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
|
||||||
|
|
||||||
<!-- Settings button (hidden until overlay shown) -->
|
|
||||||
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()">✕</button>
|
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+158
-159
@@ -1,56 +1,60 @@
|
|||||||
// === Frambe - Frontend Application ===
|
// === Frambe - Frontend Application ===
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
var config = {}, assets = [], currentIndex = -1, activeLayer = 'a', slideshowTimer = null;
|
||||||
|
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
|
||||||
|
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
|
||||||
|
|
||||||
// --- State ---
|
var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen');
|
||||||
var config = {};
|
var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
|
||||||
var assets = [];
|
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
|
||||||
var currentIndex = -1;
|
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
|
||||||
var activeLayer = 'a';
|
var $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b');
|
||||||
var slideshowTimer = null;
|
var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock');
|
||||||
var progressTimer = null;
|
var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info');
|
||||||
var progressStart = 0;
|
var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay');
|
||||||
var overlayVisible = true;
|
var $btnSettings = document.getElementById('btn-settings'), $progressBar = document.getElementById('progress-bar');
|
||||||
var overlayTimeout = null;
|
|
||||||
var selectedSource = null;
|
|
||||||
var selectedAlbumId = null;
|
|
||||||
var isRunning = false;
|
|
||||||
var preloadedImages = {};
|
|
||||||
|
|
||||||
// --- DOM Elements ---
|
function getUrlParams() {
|
||||||
var $setupScreen = document.getElementById('setup-screen');
|
var p = {}, s = window.location.search.substring(1); if (!s) return p;
|
||||||
var $slideshowScreen = document.getElementById('slideshow-screen');
|
var pairs = s.split('&');
|
||||||
var $connectionStatus = document.getElementById('connection-status');
|
for (var i = 0; i < pairs.length; i++) { var kv = pairs[i].split('='); p[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); }
|
||||||
var $setupContent = document.getElementById('setup-content');
|
return p;
|
||||||
var $setupError = document.getElementById('setup-error');
|
}
|
||||||
var $errorDetail = document.getElementById('error-detail');
|
|
||||||
var $albumsList = document.getElementById('albums-list');
|
// --- Auto-launch helper: sets source and immediately starts slideshow ---
|
||||||
var $btnStart = document.getElementById('btn-start');
|
async function autoLaunch(source, albumId, personId) {
|
||||||
var $layerA = document.getElementById('photo-layer-a');
|
urlDriven = true;
|
||||||
var $layerB = document.getElementById('photo-layer-b');
|
selectedSource = source;
|
||||||
var $bgBlur = document.getElementById('bg-blur');
|
selectedAlbumId = albumId || null;
|
||||||
var $clock = document.getElementById('clock');
|
selectedPersonId = personId || null;
|
||||||
var $dateDisplay = document.getElementById('date-display');
|
console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
|
||||||
var $exifInfo = document.getElementById('exif-info');
|
await doStartSlideshow();
|
||||||
var $progressFill = document.getElementById('progress-fill');
|
}
|
||||||
var $overlay = document.getElementById('overlay');
|
|
||||||
var $btnSettings = document.getElementById('btn-settings');
|
|
||||||
var $progressBar = document.getElementById('progress-bar');
|
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
document.body.classList.add('setup-mode');
|
document.body.classList.add('setup-mode');
|
||||||
try {
|
try {
|
||||||
var configRes = await fetch('/api/config');
|
config = await (await fetch('/api/config')).json();
|
||||||
config = await configRes.json();
|
|
||||||
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
|
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
|
||||||
var serverRes = await fetch('/api/server-info');
|
var si = await (await fetch('/api/server-info')).json();
|
||||||
var serverInfo = await serverRes.json();
|
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
|
||||||
if (!serverInfo.ok) { showError('Cannot reach Immich server: ' + serverInfo.error); return; }
|
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
|
||||||
$connectionStatus.textContent = 'Connected to Immich v' + serverInfo.version.major + '.' + serverInfo.version.minor + '.' + serverInfo.version.patch;
|
|
||||||
$connectionStatus.classList.add('connected');
|
$connectionStatus.classList.add('connected');
|
||||||
|
|
||||||
|
// Check URL parameters for direct launch (zero-touch)
|
||||||
|
var params = getUrlParams();
|
||||||
|
if (params.album) { await autoLaunch('album', params.album, null); return; }
|
||||||
|
if (params.person) { await autoLaunch('person', null, params.person); return; }
|
||||||
|
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
|
||||||
|
if ('random' in params) { await autoLaunch('random', null, null); return; }
|
||||||
|
|
||||||
|
// Check env-based auto-start
|
||||||
|
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
|
||||||
|
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
|
||||||
|
|
||||||
|
// No auto-start — show setup screen with album picker
|
||||||
await loadAlbums();
|
await loadAlbums();
|
||||||
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); }
|
} catch (err) { showError('Failed to initialize: ' + err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,180 +62,175 @@
|
|||||||
|
|
||||||
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)">';
|
||||||
html += thumbUrl ? '<img class="album-thumb" src="' + thumbUrl + '" alt="" loading="lazy">' : '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem;">📁</div>';
|
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 += '<div class="album-info"><div class="album-name">' + escapeHtml(a.albumName) + '</div><div class="album-count">' + a.assetCount + ' photos</div></div></div>';
|
html += '<div class="album-info"><div class="album-name">' + escapeHtml(a.albumName) + '</div><div class="album-count">' + a.assetCount + ' photos</div></div></div>';
|
||||||
}
|
}
|
||||||
$albumsList.innerHTML = html;
|
$albumsList.innerHTML = html;
|
||||||
} catch (err) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; }
|
} catch (e) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
window.selectSource = function (source) {
|
window.selectSource = function (src) {
|
||||||
selectedSource = source; selectedAlbumId = null;
|
selectedSource = src; selectedAlbumId = null; selectedPersonId = null;
|
||||||
document.getElementById('btn-all-photos').classList.toggle('selected', source === 'random');
|
document.getElementById('btn-all-photos').classList.toggle('selected', src === 'random');
|
||||||
document.getElementById('btn-favorites').classList.toggle('selected', source === 'favorites');
|
document.getElementById('btn-favorites').classList.toggle('selected', src === 'favorites');
|
||||||
var albumItems = document.querySelectorAll('.album-item');
|
var items = document.querySelectorAll('.album-item');
|
||||||
for (var i = 0; i < albumItems.length; i++) albumItems[i].classList.remove('selected');
|
for (var i = 0; i < items.length; i++) items[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;
|
|
||||||
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 albumItems = document.querySelectorAll('.album-item');
|
var items = document.querySelectorAll('.album-item');
|
||||||
for (var i = 0; i < albumItems.length; i++) albumItems[i].classList.remove('selected');
|
for (var i = 0; i < items.length; i++) items[i].classList.remove('selected');
|
||||||
el.classList.add('selected');
|
el.classList.add('selected'); $btnStart.disabled = false;
|
||||||
$btnStart.disabled = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadAssets() {
|
async function loadAssets() {
|
||||||
var res;
|
var res;
|
||||||
if (selectedSource === 'album' && selectedAlbumId) { res = await fetch('/api/albums/' + selectedAlbumId); var album = await res.json(); assets = album.assets || []; }
|
if (selectedSource === 'album' && selectedAlbumId) {
|
||||||
else if (selectedSource === 'favorites') { res = await fetch('/api/assets/favorites'); assets = await res.json(); }
|
res = await fetch('/api/albums/' + selectedAlbumId);
|
||||||
else { res = await fetch('/api/assets/random?count=100'); assets = await res.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.startSlideshow = async function () {
|
function startRefreshTimer() {
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
refreshTimer = setInterval(async function () {
|
||||||
|
try {
|
||||||
|
var oldIds = {}; for (var i = 0; i < assets.length; i++) oldIds[assets[i].id] = true;
|
||||||
|
var nw, r;
|
||||||
|
if (selectedSource === 'album' && selectedAlbumId) { r = await (await fetch('/api/albums/' + selectedAlbumId)).json(); nw = r.assets || []; }
|
||||||
|
else if (selectedSource === 'person' && selectedPersonId) { nw = await (await fetch('/api/people/' + selectedPersonId)).json(); }
|
||||||
|
else if (selectedSource === 'favorites') { nw = await (await fetch('/api/assets/favorites')).json(); }
|
||||||
|
else return;
|
||||||
|
var added = 0;
|
||||||
|
for (var j = 0; j < nw.length; j++) { if (!oldIds[nw[j].id]) { assets.push(nw[j]); added++; } }
|
||||||
|
if (added > 0) console.log('Frambe: added ' + added + ' new photo(s)');
|
||||||
|
} catch (e) { console.warn('Frambe: refresh failed', e.message); }
|
||||||
|
}, (config.refreshInterval || 300) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core slideshow start (used by both button click and auto-launch) ---
|
||||||
|
async function doStartSlideshow() {
|
||||||
if (!selectedSource) return;
|
if (!selectedSource) return;
|
||||||
$btnStart.disabled = true;
|
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
|
||||||
$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);
|
||||||
var transMs = (config.transitionDuration || 2) * 1000;
|
return;
|
||||||
$layerA.style.transition = 'opacity ' + transMs + 'ms ease';
|
}
|
||||||
$layerB.style.transition = 'opacity ' + transMs + 'ms ease';
|
// Switch to slideshow view
|
||||||
$bgBlur.style.transition = 'opacity ' + (transMs * 0.75) + 'ms ease';
|
$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';
|
||||||
|
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
|
||||||
if (!config.showClock) $clock.style.display = 'none';
|
if (!config.showClock) $clock.style.display = 'none';
|
||||||
if (!config.showDate) $dateDisplay.style.display = 'none';
|
if (!config.showDate) $dateDisplay.style.display = 'none';
|
||||||
if (!config.showExif) $exifInfo.style.display = 'none';
|
if (!config.showExif) $exifInfo.style.display = 'none';
|
||||||
if (!config.showProgress) $progressBar.style.display = 'none';
|
if (!config.showProgress) $progressBar.style.display = 'none';
|
||||||
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
|
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
|
||||||
updateClock(); setInterval(updateClock, 1000);
|
updateClock(); setInterval(updateClock, 1000);
|
||||||
currentIndex = -1; showNextPhoto(); scheduleOverlayHide();
|
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 () {
|
||||||
isRunning = false; clearTimeout(slideshowTimer); clearInterval(progressTimer);
|
if (urlDriven) { window.location.href = window.location.pathname; return; }
|
||||||
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex';
|
isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer);
|
||||||
document.body.classList.add('setup-mode');
|
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode');
|
||||||
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
|
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
|
||||||
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
|
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
|
||||||
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible');
|
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
|
||||||
activeLayer = 'a';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
|
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
|
||||||
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
|
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
|
||||||
|
function showPhoto(idx) {
|
||||||
function showPhoto(index) {
|
if (!assets[idx]) return; clearTimeout(slideshowTimer);
|
||||||
if (!assets[index]) return;
|
var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview';
|
||||||
clearTimeout(slideshowTimer); clearInterval(progressTimer);
|
var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url;
|
||||||
var asset = assets[index];
|
preloadNext(idx + 1);
|
||||||
var imageUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
|
|
||||||
var img = new Image();
|
|
||||||
img.onload = function () { displayImage(imageUrl, asset); };
|
|
||||||
img.onerror = function () { setTimeout(showNextPhoto, 500); };
|
|
||||||
img.src = imageUrl;
|
|
||||||
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'; }
|
||||||
if (activeLayer === 'a') { incomingLayer = $layerB; outgoingLayer = $layerA; activeLayer = 'b'; }
|
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit;
|
||||||
else { incomingLayer = $layerA; outgoingLayer = $layerB; activeLayer = 'a'; }
|
inc.classList.add('active'); out.classList.remove('active');
|
||||||
incomingLayer.style.backgroundImage = 'url(' + url + ')';
|
|
||||||
incomingLayer.style.backgroundSize = fitStyle;
|
|
||||||
incomingLayer.classList.add('active'); outgoingLayer.classList.remove('active');
|
|
||||||
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
|
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
|
||||||
updateExifInfo(asset); startProgress();
|
updateExifInfo(asset); startProgress();
|
||||||
var interval = (config.slideshowInterval || 30) * 1000;
|
slideshowTimer = setTimeout(showNextPhoto, (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) { if (index >= assets.length) index = 0; if (!assets[index]) return; var img = new Image(); img.src = '/api/assets/' + assets[index].id + '/thumbnail?size=preview'; }
|
function updateExifInfo(a) {
|
||||||
|
if (!config.showExif || !a.exifInfo) { $exifInfo.textContent = ''; return; }
|
||||||
function updateExifInfo(asset) {
|
var p = [], e = a.exifInfo, loc = [e.city, e.state, e.country].filter(Boolean).join(', ');
|
||||||
if (!config.showExif || !asset.exifInfo) { $exifInfo.textContent = ''; return; }
|
if (loc) p.push('📍 ' + loc);
|
||||||
var parts = [], exif = asset.exifInfo;
|
if (e.dateTimeOriginal) p.push(formatDate(new Date(e.dateTimeOriginal)));
|
||||||
var location = [exif.city, exif.state, exif.country].filter(Boolean).join(', ');
|
else if (a.fileCreatedAt) p.push(formatDate(new Date(a.fileCreatedAt)));
|
||||||
if (location) parts.push('📍 ' + location);
|
if (e.make || e.model) p.push('📷 ' + [e.make, e.model].filter(Boolean).join(' '));
|
||||||
if (exif.dateTimeOriginal) { parts.push(formatDate(new Date(exif.dateTimeOriginal))); }
|
$exifInfo.textContent = p.join(' • ');
|
||||||
else if (asset.fileCreatedAt) { parts.push(formatDate(new Date(asset.fileCreatedAt))); }
|
|
||||||
if (exif.make || exif.model) { parts.push('📷 ' + [exif.make, exif.model].filter(Boolean).join(' ')); }
|
|
||||||
$exifInfo.textContent = parts.join(' • ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startProgress() {
|
function startProgress() {
|
||||||
if (!config.showProgress) return;
|
if (!config.showProgress) return;
|
||||||
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%';
|
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth;
|
||||||
progressStart = Date.now(); var duration = (config.slideshowInterval || 30) * 1000;
|
$progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + 'ms linear'; $progressFill.style.width = '100%';
|
||||||
$progressFill.offsetWidth;
|
|
||||||
$progressFill.style.transition = 'width ' + duration + 'ms linear'; $progressFill.style.width = '100%';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClock() {
|
function updateClock() {
|
||||||
var now = new Date();
|
var n = new Date();
|
||||||
if (config.showClock) { $clock.textContent = padZero(now.getHours()) + ':' + padZero(now.getMinutes()); }
|
if (config.showClock) $clock.textContent = padZero(n.getHours()) + ':' + padZero(n.getMinutes());
|
||||||
if (config.showDate) { $dateDisplay.textContent = now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); }
|
if (config.showDate) $dateDisplay.textContent = n.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
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'); } };
|
||||||
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'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
|
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
|
||||||
|
|
||||||
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
|
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
|
||||||
window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
|
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; } });
|
||||||
document.addEventListener('keydown', function (e) {
|
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(); } }
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
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; } }
|
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; } }
|
||||||
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
|
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
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(); }
|
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(); }
|
||||||
function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
|
function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
|
||||||
|
|
||||||
async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
|
async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
|
||||||
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && isRunning) requestWakeLock(); });
|
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){} }
|
||||||
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();
|
init(); requestWakeLock(); preventSleep();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ const fetch = require('node-fetch');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const VERSION = '1.2.2';
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
|
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
|
||||||
const API_KEY = process.env.IMMICH_API_KEY || '';
|
const API_KEY = process.env.IMMICH_API_KEY || '';
|
||||||
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
|
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
|
||||||
@@ -20,230 +20,174 @@ 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 ---
|
function log(msg) { console.log('[Frambe] ' + msg); }
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
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());
|
app.use(express.json());
|
||||||
|
|
||||||
// --- API: Config endpoint (sends safe config to frontend) ---
|
// --- API: Config ---
|
||||||
app.get('/api/config', (_req, res) => {
|
app.get('/api/config', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
slideshowInterval: SLIDESHOW_INTERVAL,
|
version: VERSION,
|
||||||
transitionDuration: TRANSITION_DURATION,
|
slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
|
||||||
showClock: SHOW_CLOCK,
|
showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
|
||||||
showDate: SHOW_DATE,
|
imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
|
||||||
showExif: SHOW_EXIF,
|
albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL,
|
||||||
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 ---
|
// --- API: Server info ---
|
||||||
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}`);
|
||||||
});
|
const v = await r.json();
|
||||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
log('Immich connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch);
|
||||||
const data = await response.json();
|
res.json({ ok: true, version: v });
|
||||||
res.json({ ok: true, version: data });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logErr('Immich connection failed: ' + err.message);
|
||||||
res.status(502).json({ ok: false, error: err.message });
|
res.status(502).json({ ok: false, error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- API: List albums ---
|
// --- API: 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}`);
|
log('Listed ' + albums.length + ' albums');
|
||||||
const albums = await response.json();
|
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
|
||||||
// Return simplified album list
|
} catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
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}`, {
|
log('Fetching album ' + req.params.id);
|
||||||
headers: immichHeaders(),
|
const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
|
||||||
});
|
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
const album = await r.json();
|
||||||
const album = await response.json();
|
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||||
// Filter to images only, return simplified asset data
|
log('Album "' + album.albumName + '" returned ' + assets.length + ' images');
|
||||||
const assets = (album.assets || [])
|
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
|
||||||
.filter((a) => a.type === 'IMAGE')
|
} catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
.map((a) => ({
|
});
|
||||||
id: a.id,
|
|
||||||
originalFileName: a.originalFileName,
|
app.get('/api/people', async (_req, res) => {
|
||||||
fileCreatedAt: a.fileCreatedAt,
|
try {
|
||||||
exifInfo: a.exifInfo
|
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
|
||||||
? {
|
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||||
make: a.exifInfo.make,
|
const data = await r.json();
|
||||||
model: a.exifInfo.model,
|
const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }));
|
||||||
city: a.exifInfo.city,
|
log('Listed ' + people.length + ' people');
|
||||||
state: a.exifInfo.state,
|
res.json(people);
|
||||||
country: a.exifInfo.country,
|
} catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
description: a.exifInfo.description,
|
});
|
||||||
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
|
|
||||||
}
|
app.get('/api/people/:id', async (req, res) => {
|
||||||
: null,
|
try {
|
||||||
}));
|
log('Fetching person ' + req.params.id);
|
||||||
res.json({
|
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
|
||||||
id: album.id,
|
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||||
albumName: album.albumName,
|
const assets = await r.json();
|
||||||
assetCount: assets.length,
|
const images = (Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||||
assets,
|
log('Person returned ' + images.length + ' images');
|
||||||
});
|
res.json(images);
|
||||||
} catch (err) {
|
} catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
res.status(502).json({ error: err.message });
|
});
|
||||||
}
|
|
||||||
|
app.get('/api/people/:id/thumbnail', async (req, res) => {
|
||||||
|
try {
|
||||||
|
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}`);
|
||||||
|
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
|
||||||
|
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}`);
|
||||||
});
|
const images = (await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
log('Random returned ' + images.length + ' images');
|
||||||
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);
|
res.json(images);
|
||||||
} catch (err) {
|
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
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,
|
const images = (data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }));
|
||||||
page: 1,
|
log('Favorites returned ' + images.length + ' images');
|
||||||
});
|
|
||||||
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);
|
res.json(images);
|
||||||
} catch (err) {
|
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||||
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(`🖼️ Frambe running on http://0.0.0.0:${PORT}`);
|
log('--- Frambe v' + VERSION + ' ---');
|
||||||
console.log(`📡 Immich server: ${IMMICH_URL}`);
|
log('Server listening on port ' + PORT);
|
||||||
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
|
log('Immich URL: ' + IMMICH_URL);
|
||||||
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
|
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
||||||
console.log(`⏱️ Slideshow interval: ${SLIDESHOW_INTERVAL}s`);
|
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...');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user