v1.4.1 — Shared Albums #2

Merged
jessikitty merged 30 commits from dev into main 2026-06-09 16:20:26 +10:00
11 changed files with 929 additions and 462 deletions
+9
View File
@@ -22,5 +22,14 @@ SHOW_PROGRESS=true
# ALBUM_ID=
# SHOW_FAVORITES_ONLY=false
# Admin Authentication (optional — leave ADMIN_PASSWORD blank to disable)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
# ADMIN_PASSWORD=changeme
# API Token for external access (Home Assistant, scripts, etc.)
# When set, REST endpoints require this token via Bearer auth or x-api-token header
# FRAMBE_API_TOKEN=your-secret-token-here
# Server (internal port — Docker maps externally via docker-compose)
PORT=3000
+1
View File
@@ -2,3 +2,4 @@ node_modules/
.env
npm-debug.log
.DS_Store
docker-compose.yml
+290 -13
View File
@@ -9,8 +9,11 @@ A lightweight, self-contained Docker web application that connects to your [Immi
## ✨ Features
- **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 (owned or shared), random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
- **Admin Dashboard** — Real-time WebSocket-based control panel for all connected frames
- **Admin Authentication** — Optional username/password login to protect the admin dashboard
- **REST API with Token Auth** — Control frames from Home Assistant, scripts, or external tools
- **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
@@ -25,30 +28,49 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint
## 🚀 Quick Start
---
## 🚀 Deployment
### Prerequisites
- A running [Immich](https://immich.app/) server
- An Immich API key (see below)
- Docker and Docker Compose (recommended) — or Node.js 18+ for running from source
### 1. Get your Immich API Key
1. Open your Immich web interface
2. Click your profile picture → **Account Settings****API Keys**
3. Create a new key with `asset.read` and `album.read` permissions
4. Copy the key — you'll need it for the next step
### 2. Run with Docker Compose
### 2. Deploy with Docker Compose (recommended)
```bash
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:
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`:
```yaml
environment:
- IMMICH_URL=http://your-immich-server:2283
- IMMICH_API_KEY=your-api-key-here
```
Then start the container:
```bash
docker compose up -d
```
Open `http://your-server:3030` in a browser on your tablet/screen.
Frambe is now running at `http://your-server:3030`.
### 3. Run with Docker directly
### 3. Deploy with Docker Run
If you prefer not to use Compose:
```bash
docker build -t frambe .
@@ -61,6 +83,174 @@ docker run -d \
frambe
```
### 4. Run from Source (development)
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd frambe
npm install
```
Create a `.env` file from the example:
```bash
cp .env.example .env
```
Edit `.env` with your Immich URL and API key, then start the server:
```bash
npm start
```
Frambe will be available at `http://localhost:3000`.
### Port Mapping
The internal server runs on port **3000**. The Docker Compose config maps this to external port **3030** by default. You can change this in `docker-compose.yml`:
```yaml
ports:
- "8080:3000" # Access Frambe on port 8080 instead
```
---
## 🔄 Upgrading
### Docker Compose (recommended)
```bash
cd frambe
git pull
docker compose build
docker compose up -d
```
Your configuration in `docker-compose.yml` is preserved — only the application code is rebuilt.
### Docker Run
```bash
cd frambe
git pull
docker stop frambe
docker rm frambe
docker build -t frambe .
docker run -d \
--name frambe \
-p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \
frambe
```
### From Source
```bash
cd frambe
git pull
npm install
npm start
```
### Switching to a Specific Version
Frambe uses git tags for releases. To pin to a specific version:
```bash
git fetch --tags
git checkout v1.4.1 # Replace with desired version
docker compose build && docker compose up -d
```
To switch back to the latest:
```bash
git checkout main
git pull
docker compose build && docker compose up -d
```
### Upgrade Notes
- **All upgrades are non-destructive** — Frambe stores no persistent data on disk. All configuration is via environment variables.
- **No database migrations** — there is no database. Session tokens are in-memory and will reset on restart (users simply log in again).
- **Check the changelog below** before upgrading major versions for any new required environment variables.
---
## 🔑 Authentication
### Admin Dashboard Login
Protect the admin dashboard with a username and password:
```yaml
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=your-secure-password
```
When `ADMIN_PASSWORD` is set, accessing `/admin` requires signing in. When not set, the dashboard is open (useful for trusted local networks).
### API Token for External Access
Enable token-authenticated REST API access for Home Assistant, scripts, or other external tools:
```yaml
environment:
- FRAMBE_API_TOKEN=your-secret-token-here
```
---
## 🔌 REST API
When `FRAMBE_API_TOKEN` is configured, the following endpoints are available:
### List Connected Frames
```
GET /api/clients
Authorization: Bearer your-secret-token-here
```
Returns all connected frame clients with their status, IP, name, and config.
### Send Command to a Frame
```
POST /api/clients/:id/command
Authorization: Bearer your-secret-token-here
Content-Type: application/json
{
"action": "next",
"payload": {}
}
```
Available actions: `start`, `stop`, `next`, `prev`, `sleep`, `wake`, `refresh`, `setSource`, `setConfig`
### Home Assistant Example
```yaml
rest_command:
frambe_next_photo:
url: "http://frambe-server:3030/api/clients/{{ client_id }}/command"
method: POST
headers:
Authorization: "Bearer your-secret-token-here"
Content-Type: "application/json"
payload: '{"action": "next"}'
```
Authentication can also be provided via `x-api-token` header or `?token=` query parameter.
---
## 🔗 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:
@@ -74,9 +264,11 @@ Skip the setup screen entirely by passing query parameters. This is ideal for de
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:
All settings are via environment variables. Set them in `docker-compose.yml`, pass with `docker run -e`, or put them in a `.env` file when running from source.
| Variable | Default | Description |
|---|---|---|
@@ -91,11 +283,17 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar |
| `INCLUDE_VIDEOS` | `true` | Include video assets in slideshow |
| `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) |
| `ADMIN_USERNAME` | `admin` | Admin dashboard login username |
| `ADMIN_PASSWORD` | *(empty)* | Admin dashboard password (leave empty to disable auth) |
| `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
---
## 🎮 Controls
### Touch / Mouse
@@ -110,6 +308,8 @@ All settings are via environment variables:
- `I` — Toggle info overlay
- `Esc` — Exit to album selection
---
## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
@@ -117,23 +317,100 @@ All settings are via environment variables:
3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings
---
## 🏗️ Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
┌─────────┴─────────┐
│ REST API / WS │
│ (Home Assistant) │
└───────────────────┘
```
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
## 🏷️ Versioning
Frambe follows [Semantic Versioning](https://semver.org/):
- **Major** (`X.0.0`) — Large feature overhauls, breaking changes, or major UI redesigns
- **Minor** (`0.X.0`) — New features, functionality additions, or significant improvements
- **Patch** (`0.0.X`) — Bug fixes, small tweaks, and minor corrections
### Branches
| Branch | Purpose |
|---|---|
| `main` | Stable releases — production-ready code |
| `dev` | Development — latest features, may be unstable |
### Changelog
#### v1.4.1 — Shared Albums
- ✅ Album picker now shows **shared albums** alongside owned albums
- ✅ Shared albums are visually marked with a "Shared" badge in the picker
- ✅ Shared album icons use 🔗 to distinguish from owned 📁 albums
- ✅ Deduplication ensures albums shared with yourself don't appear twice
#### v1.4.0 — Admin Auth & REST API
- ✅ Admin dashboard login with username/password authentication (env-based)
- ✅ Session management with HttpOnly cookies (24-hour expiry, automatic cleanup)
- ✅ API token authentication for external access (Home Assistant, scripts, curl)
- ✅ REST endpoint: `GET /api/clients` — list all connected frames
- ✅ REST endpoint: `POST /api/clients/:id/command` — send commands to frames
- ✅ Multiple auth methods: Bearer token, `x-api-token` header, `?token=` query param
- ✅ Auth status endpoint: `GET /api/auth/status`
- ✅ Backwards compatible — auth is opt-in, disabled by default
- 🆕 New env vars: `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `FRAMBE_API_TOKEN`
#### v1.3.0 — Admin Dashboard & Video Support
- ✅ Real-time admin dashboard at `/admin` with WebSocket communication
- ✅ Live frame management: start, stop, next, prev, sleep, wake, refresh
- ✅ Remote source switching: change album, person, random, or favorites per frame
- ✅ Remote config: adjust slideshow interval, toggle clock/date/EXIF/progress per frame
- ✅ Frame naming and rename support (persists by IP)
- ✅ Video playback support in slideshow (with `INCLUDE_VIDEOS` toggle)
- ✅ Person / face recognition photo source via Immich's people API
- ✅ Connection status indicators and auto-reconnect
#### v1.2.1 — Bug Fixes
- 🐛 Fixed port mapping (3030:3000 external:internal)
- 🐛 Fixed URL parameter auto-launch not starting the slideshow
#### v1.2.0 — Zero-Touch Launch & Auto-Refresh
- ✅ URL query parameters for zero-touch launch (`?album=`, `?person=`, `?favorites`, `?random`)
- ✅ Person / face support — display photos of a specific person
- ✅ Periodic auto-refresh — new photos appear without restarting
- ✅ App icon for home screen bookmarks
- ✅ Default external port changed to 3030
- 🆕 New env var: `REFRESH_INTERVAL`
#### v1.1.0 — Rebrand
- ✅ Rebranded to Frambe
#### v1.0.0 — Initial Release
- ✅ Album browser with Immich API integration
- ✅ Full-screen slideshow with smooth crossfade transitions
- ✅ Double-buffered image loading for seamless display
- ✅ Background blur behind non-covering images
- ✅ Clock, date, and EXIF metadata overlays
- ✅ Progress bar showing time until next photo
- ✅ Touch controls (left/centre/right tap zones)
- ✅ Keyboard controls (arrows, space, F, I, Esc)
- ✅ Screen wake lock to prevent display sleep
- ✅ Configurable via environment variables
- ✅ Docker containerised deployment
- ✅ Vanilla HTML/CSS/JS frontend — no frameworks, works on older devices
---
## 📄 License
+7
View File
@@ -26,6 +26,13 @@ services:
- SHOW_EXIF=true
- SHOW_PROGRESS=true
# Admin Authentication (leave ADMIN_PASSWORD blank to disable login)
- ADMIN_USERNAME=admin
# - ADMIN_PASSWORD=changeme
# API Token for external access (Home Assistant, scripts, etc.)
# - FRAMBE_API_TOKEN=your-secret-token-here
# Auto-start (optional — or use URL params instead)
# - ALBUM_ID=
# - SHOW_FAVORITES_ONLY=false
+5 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frambe",
"version": "1.3.0",
"description": "Frambe — a lightweight digital photo frame web app for Immich",
"version": "1.4.0",
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
"main": "server.js",
"scripts": {
"start": "node server.js"
@@ -9,7 +9,9 @@
"dependencies": {
"express": "^4.21.0",
"node-fetch": "^2.7.0",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"ws": "^8.18.0",
"sharp": "^0.33.0"
},
"engines": {
"node": ">=18"
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frambe Admin</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
body{background:#0f0f1a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:1.5rem;min-height:100vh}
h1{font-size:1.6rem;font-weight:300;margin-bottom:.25rem}
.header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;border-bottom:1px solid rgba(255,255,255,.1);padding-bottom:1rem}
.header img{width:48px;height:48px;border-radius:10px}
.header .version{font-size:.8rem;color:#666}
.header-right{margin-left:auto;display:flex;align-items:center;gap:.75rem}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.online{background:#4ade80} .dot.sleeping{background:#fbbf24} .dot.playing{background:#60a5fa} .dot.offline{background:#555}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:1rem}
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:1.25rem;transition:all .2s}
.card:hover{border-color:rgba(99,102,241,.3);background:rgba(255,255,255,.06)}
.card.offline{opacity:.45}.card.offline:hover{opacity:.65}
.card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.card-name{font-size:1.1rem;font-weight:500;display:flex;align-items:center;gap:.5rem}
.card-ip{font-size:.72rem;color:#777;font-family:monospace}
.card-meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.badge{font-size:.65rem;padding:2px 8px;border-radius:10px;text-transform:uppercase;font-weight:600;letter-spacing:.3px}
.badge.online{background:rgba(74,222,128,.15);color:#86efac} .badge.offline{background:rgba(255,255,255,.06);color:#777}
.badge.playing{background:rgba(96,165,250,.15);color:#93c5fd} .badge.sleeping{background:rgba(251,191,36,.15);color:#fcd34d}
.name-input{background:transparent;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:.85rem;padding:3px 7px;width:130px}
.name-input:focus{outline:none;border-color:#6366f1}
.info-row{font-size:.72rem;color:#666;margin-bottom:.75rem;display:flex;gap:1rem;flex-wrap:wrap}
.info-row span{white-space:nowrap}
.controls{display:flex;flex-direction:column;gap:.6rem}
.crow{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.clbl{font-size:.78rem;color:#777;min-width:55px}
.btn{padding:5px 12px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;transition:all .12s;white-space:nowrap}
.btn:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)}
.btn.red{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}.btn.red:hover{background:rgba(239,68,68,.25)}
.btn.grn{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.4);color:#86efac}.btn.grn:hover{background:rgba(34,197,94,.25)}
.btn.blue{background:rgba(99,102,241,.12);border-color:rgba(99,102,241,.4);color:#a5b4fc}.btn.blue:hover{background:rgba(99,102,241,.25)}
.btn.sm{font-size:.68rem;padding:2px 7px}
.btn.logout{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.25);color:#fca5a5;font-size:.72rem;padding:3px 10px}
select{padding:5px 8px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;max-width:200px}
select:focus{outline:none;border-color:#6366f1}
option{background:#1a1a2e;color:#e0e0e0}
input[type=range]{width:110px;accent-color:#6366f1}
.rval{font-size:.78rem;color:#999;min-width:28px}
.tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none}
.tgl-s{position:absolute;inset:0;background:rgba(255,255,255,.08);border-radius:10px;transition:.2s}
.tgl-s::before{content:'';position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#777;border-radius:50%;transition:.2s}
.tgl input:checked+.tgl-s{background:rgba(99,102,241,.35)}.tgl input:checked+.tgl-s::before{transform:translateX(16px);background:#a5b4fc}
.empty{text-align:center;padding:4rem 2rem;color:#555}
.empty h2{font-size:1.1rem;font-weight:400;margin-bottom:.4rem;color:#777}
.ws-pill{font-size:.72rem;padding:3px 9px;border-radius:20px}
.ws-pill.on{background:rgba(34,197,94,.12);color:#86efac}.ws-pill.off{background:rgba(239,68,68,.12);color:#fca5a5}
.divider{border:none;border-top:1px solid rgba(255,255,255,.05);margin:.4rem 0}
.offline-msg{font-size:.78rem;color:#666;text-align:center;padding:.6rem}
.sec-head{font-size:1.15rem;font-weight:300;margin:2rem 0 .75rem;padding-top:1.25rem;border-top:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;cursor:pointer}
.sec-head:hover{color:#fff}
.sec-head .arr{font-size:.75rem;color:#555;transition:transform .2s}.sec-head .arr.open{transform:rotate(90deg)}
.api-card{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.05);border-radius:10px;padding:1rem;margin-bottom:.6rem}
.api-card h3{font-size:.85rem;font-weight:500;color:#a5b4fc;margin-bottom:.4rem}
.mtd{display:inline-block;font-size:.65rem;font-weight:700;padding:1px 5px;border-radius:3px;margin-right:5px}
.mtd.get{background:rgba(34,197,94,.18);color:#86efac}.mtd.post{background:rgba(59,130,246,.18);color:#93c5fd}.mtd.del{background:rgba(239,68,68,.18);color:#fca5a5}
.ep{font-family:monospace;font-size:.82rem;color:#bbb}
.api-card p{font-size:.75rem;color:#777;margin:.3rem 0}
pre{background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.06);border-radius:7px;padding:8px 10px;font-size:.72rem;color:#bbb;overflow-x:auto;margin-top:.4rem;white-space:pre-wrap;word-break:break-all;position:relative}
.cpb{position:absolute;top:5px;right:5px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:3px;color:#999;font-size:.6rem;padding:1px 5px;cursor:pointer}
.cpb:hover{background:rgba(255,255,255,.12);color:#fff}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:none;align-items:center;justify-content:center;z-index:100;padding:1rem}
.modal-overlay.open{display:flex}
.modal{background:#16162a;border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:1.5rem;max-width:700px;width:100%;max-height:85vh;overflow-y:auto}
.modal h2{font-size:1.2rem;font-weight:400;margin-bottom:.25rem}
.modal .sub{font-size:.78rem;color:#777;margin-bottom:1rem}
.modal-close{float:right;background:none;border:none;color:#777;font-size:1.2rem;cursor:pointer;padding:4px 8px}.modal-close:hover{color:#fff}
.yaml-block{background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:12px;font-size:.72rem;color:#ccc;white-space:pre;overflow-x:auto;line-height:1.5;position:relative;margin-bottom:.75rem}
.input-row{display:flex;gap:.75rem;margin-bottom:1rem;flex-wrap:wrap}
.input-row label{font-size:.78rem;color:#888;display:block;margin-bottom:.25rem}
.input-row input{padding:6px 10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#ddd;font-size:.82rem;width:100%}
.input-row input:focus{outline:none;border-color:#6366f1}
.input-row .field{flex:1;min-width:140px}
</style>
</head>
<body>
<div class="header">
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<div><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
<div class="header-right">
<span class="ws-pill off" id="ws-pill">Disconnected</span>
<button class="btn logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button>
</div>
</div>
<div class="grid" id="grid"><div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div></div>
<div class="sec-head" onclick="toggle('api-sec','api-arr')">REST API Reference <span class="arr" id="api-arr">&#9654;</span></div>
<div id="api-sec" style="display:none">
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/clients</span></h3><p>List all known frames with status, IP, name, and config.</p><pre id="c-list">curl -s -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients<span class="cpb" onclick="cc('c-list')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd post">POST</span><span class="ep">/api/clients/:id/command</span></h3><p>Send a command to a frame. Actions: <code>start</code> <code>stop</code> <code>next</code> <code>prev</code> <code>sleep</code> <code>wake</code> <code>refresh</code> <code>setSource</code> <code>setConfig</code></p><pre id="c-cmd">curl -s -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"action":"next"}' http://YOUR_HOST:3030/api/clients/CLIENT_ID/command<span class="cpb" onclick="cc('c-cmd')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd del">DELETE</span><span class="ep">/api/clients/:id</span></h3><p>Remove a frame from the registry.</p><pre id="c-del">curl -s -X DELETE -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients/CLIENT_ID<span class="cpb" onclick="cc('c-del')">Copy</span></pre></div>
</div>
<div class="modal-overlay" id="ha-modal">
<div class="modal">
<button class="modal-close" onclick="closeModal()">&times;</button>
<h2>Home Assistant YAML Generator</h2>
<div class="sub" id="ha-client-name"></div>
<div class="input-row">
<div class="field"><label>Frambe Host</label><input id="ha-host" oninput="genYaml()" placeholder="frambe-server:3030"></div>
<div class="field"><label>API Token</label><input id="ha-token" oninput="genYaml()" placeholder="your-secret-token"></div>
<div class="field"><label>Client ID</label><input id="ha-cid" readonly></div>
</div>
<h3 style="font-size:.85rem;color:#a5b4fc;margin-bottom:.5rem">Generated YAML — paste into your configuration.yaml</h3>
<div class="yaml-block" id="ha-yaml"></div>
<button class="btn blue" onclick="cc('ha-yaml')" style="width:100%">Copy YAML to Clipboard</button>
</div>
</div>
<script>
var ws=null,D={},albums=[],people=[],auth=false;
(async function(){try{var r=await(await fetch('/api/auth/status')).json();auth=r.authEnabled;if(auth)document.getElementById('logout-btn').style.display='';}catch(e){}})();
async function doLogout(){try{await fetch('/api/auth/logout',{method:'POST'});}catch(e){}location.href='/admin/login';}
function toggle(sid,aid){var s=document.getElementById(sid),a=document.getElementById(aid);if(s.style.display==='none'){s.style.display='block';a.classList.add('open');}else{s.style.display='none';a.classList.remove('open');}}
function cc(id){var el=document.getElementById(id);var t=el.textContent.replace(/Copy|Copied!/g,'').trim();navigator.clipboard.writeText(t).then(function(){var b=el.querySelector('.cpb');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy';},1500);}});}
function ago(iso){if(!iso)return'never';var d=Math.floor((Date.now()-new Date(iso).getTime())/1000);if(d<5)return'just now';if(d<60)return d+'s ago';if(d<3600)return Math.floor(d/60)+'m ago';if(d<86400)return Math.floor(d/3600)+'h ago';return Math.floor(d/86400)+'d ago';}
function esc(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML;}
function connect(){
var p=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(p+'//'+location.host+'/ws');
ws.onopen=function(){document.getElementById('ws-pill').textContent='Connected';document.getElementById('ws-pill').className='ws-pill on';ws.send(JSON.stringify({type:'register',role:'admin'}));loadMeta();};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='clientList'){D={};m.clients.forEach(function(c){D[c.id]=c;});render();}else if(m.type==='clientUpdate'){D[m.clientId]=m.client;render();}};
ws.onclose=function(){document.getElementById('ws-pill').textContent='Disconnected';document.getElementById('ws-pill').className='ws-pill off';setTimeout(connect,3000);};
}
async function loadMeta(){try{var c=await(await fetch('/api/config')).json();document.getElementById('ver').textContent='v'+(c.version||'?');albums=await(await fetch('/api/albums')).json();people=await(await fetch('/api/people')).json();}catch(e){}}
function cmd(id,a,pl){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:a,payload:pl||{}}));}
function ren(id,n){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'renameClient',targetId:id,name:n}));}
function rem(id,n){if(confirm('Remove "'+(n||id)+'" from client list?'))if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'removeClient',targetId:id}));}
function src(id,v){if(!v)return;if(v==='random')cmd(id,'setSource',{source:'random'});else if(v==='favorites')cmd(id,'setSource',{source:'favorites'});else if(v.indexOf('album:')===0)cmd(id,'setSource',{source:'album',albumId:v.substring(6)});else if(v.indexOf('person:')===0)cmd(id,'setSource',{source:'person',personId:v.substring(7)});}
var haClientId='';
function openHaModal(id,name){haClientId=id;document.getElementById('ha-client-name').textContent='Generating for: '+(name||id);document.getElementById('ha-cid').value=id;document.getElementById('ha-host').value=location.host;document.getElementById('ha-modal').classList.add('open');genYaml();}
function closeModal(){document.getElementById('ha-modal').classList.remove('open');}
function genYaml(){
var host=document.getElementById('ha-host').value||'frambe:3030';var token=document.getElementById('ha-token').value||'YOUR_TOKEN';var cid=haClientId;
var proto=location.protocol==='https:'?'https':'http';var url=proto+'://'+host+'/api/clients/'+cid+'/command';
var hdr=' headers:\n Authorization: "Bearer '+token+'"\n Content-Type: "application/json"';
var y='rest_command:\n # --- Frambe: '+(D[cid]?D[cid].name||cid:cid)+' ---\n\n';
var actions=[['next','Next photo'],['prev','Previous photo'],['start','Start slideshow'],['stop','Stop slideshow'],['sleep','Sleep display'],['wake','Wake display'],['refresh','Refresh photo source']];
actions.forEach(function(a){y+=' frambe_'+cid+'_'+a[0]+':\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "'+a[0]+'"}\'\n\n';});
y+=' frambe_'+cid+'_set_album:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "album", "albumId": "REPLACE_ALBUM_UUID"}}\'\n\n';
y+=' frambe_'+cid+'_set_random:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "random"}}\'\n\n';
y+=' frambe_'+cid+'_set_favorites:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "favorites"}}\'';
document.getElementById('ha-yaml').textContent=y;
}
function render(){
var g=document.getElementById('grid'),ids=Object.keys(D);
if(!ids.length){g.innerHTML='<div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div>';return;}
ids.sort(function(a,b){return(D[a].status==='offline'?1:0)-(D[b].status==='offline'?1:0);});
var h='';
ids.forEach(function(id){var c=D[id],off=c.status==='offline';
var sc=off?'offline':c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online';var cfg=c.config||{};
h+='<div class="card'+(off?' offline':'')+'">';
h+='<div class="card-head"><div><div class="card-name"><span class="dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="ren(\''+id+'\',this.value)"/></div><div class="card-ip">'+esc(c.ip)+' &middot; ID: <strong>'+esc(id)+'</strong></div></div>';
h+='<div class="card-meta"><span class="badge '+sc+'">'+esc(c.status||'unknown')+'</span>';
if(off)h+='<button class="btn sm red" onclick="rem(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Remove</button>';
h+='</div></div>';
h+='<div class="info-row">';
if(c.firstSeen)h+='<span>First seen: '+ago(c.firstSeen)+'</span>';
h+='<span>Last seen: '+ago(c.lastSeen)+'</span>';
if(c.connectedAt&&!off)h+='<span>Connected: '+ago(c.connectedAt)+'</span>';
h+='</div>';
if(off){
h+='<div class="crow" style="justify-content:center"><button class="btn blue sm" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
} else {
h+='<div class="controls">';
h+='<div class="crow"><span class="clbl">Source</span><select onchange="src(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albums.forEach(function(a){h+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
people.filter(function(p){return p.name;}).forEach(function(p){h+='<option value="person:'+p.id+'">'+esc(p.name)+'</option>';});
h+='</select></div><hr class="divider">';
h+='<div class="crow"><span class="clbl">Playback</span><button class="btn grn" onclick="cmd(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="cmd(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="cmd(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="cmd(\''+id+'\',\'prev\')">Prev</button></div>';
h+='<div class="crow"><span class="clbl">Power</span><button class="btn red" onclick="cmd(\''+id+'\',\'sleep\')">Sleep</button><button class="btn grn" onclick="cmd(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="cmd(\''+id+'\',\'refresh\')">Refresh</button></div>';
h+='<hr class="divider">';
h+='<div class="crow"><span class="clbl">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="cmd(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="rval">'+(cfg.slideshowInterval||30)+'s</span></div>';
h+='<div class="crow"><span class="clbl">Clock</span><label class="tgl"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">Date</span><label class="tgl"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">EXIF</span><label class="tgl"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="tgl-s"></span></label></div>';
h+='<hr class="divider">';
h+='<div class="crow"><button class="btn blue" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
h+='</div>';
}
h+='</div>';
});
g.innerHTML=h;
}
connect();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frambe Admin — Login</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 2.5rem; width: 100%; max-width: 380px; }
.login-header { text-align: center; margin-bottom: 2rem; }
.login-header img { width: 56px; height: 56px; border-radius: 12px; margin-bottom: 1rem; }
.login-header h1 { font-size: 1.4rem; font-weight: 300; }
.login-header p { font-size: 0.8rem; color: #666; margin-top: 0.25rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 0.4rem; }
.form-group input { width: 100%; padding: 10px 14px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: #e0e0e0; font-size: 0.95rem; outline: none; transition: border-color 0.15s; }
.form-group input:focus { border-color: #6366f1; }
.login-btn { width: 100%; padding: 11px; background: rgba(99,102,241,0.2); border: 1px solid #6366f1; border-radius: 8px; color: #a5b4fc; font-size: 0.95rem; cursor: pointer; transition: all 0.15s; }
.login-btn:hover { background: rgba(99,102,241,0.35); }
.login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error-msg { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; color: #fca5a5; font-size: 0.85rem; padding: 8px 12px; margin-bottom: 1rem; display: none; }
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<h1>Frambe Admin</h1>
<p>Sign in to manage your frames</p>
</div>
<div class="error-msg" id="error-msg"></div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password">
</div>
<button class="login-btn" id="login-btn" onclick="doLogin()">Sign In</button>
</div>
<script>
document.getElementById('password').addEventListener('keydown', function(e) { if (e.key === 'Enter') doLogin(); });
document.getElementById('username').addEventListener('keydown', function(e) { if (e.key === 'Enter') document.getElementById('password').focus(); });
async function doLogin() {
var btn = document.getElementById('login-btn');
var errEl = document.getElementById('error-msg');
btn.disabled = true;
errEl.style.display = 'none';
var username = document.getElementById('username').value.trim();
var password = document.getElementById('password').value;
if (!username || !password) { errEl.textContent = 'Please enter both username and password'; errEl.style.display = 'block'; btn.disabled = false; return; }
try {
var r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, password: password }) });
var d = await r.json();
if (d.ok) { window.location.href = '/admin'; }
else { errEl.textContent = d.error || 'Login failed'; errEl.style.display = 'block'; }
} catch (e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; }
btn.disabled = false;
}
</script>
</body>
</html>
+49 -137
View File
@@ -1,147 +1,59 @@
/* === Reset === */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; }
body.setup-mode { cursor: default; }
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; color: #e8e6f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
/* === SETUP === */
#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; }
.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; }
.setup-header { text-align: center; margin-bottom: 2rem; }
.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; }
.subtitle { font-size: 0.95rem; color: #888; }
.subtitle.connected { color: #4ade80; }
.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; }
.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); }
.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; }
.source-icon { font-size: 1.5rem; }
.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; }
.loading-text { text-align: center; color: #666; padding: 1rem; }
/* === SETUP SCREEN === */
.setup-screen { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.setup-header { padding: 1.5rem 2rem 1rem; flex-shrink: 0; text-align: center; }
.setup-logo { width: 64px; height: 64px; margin-bottom: 0.5rem; }
.setup-header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; }
.setup-header h1 span { color: #6366f1; }
#connection-status { font-size: 0.85rem; color: #666; margin-top: 0.3rem; transition: color 0.3s; }
#connection-status.connected { color: #4ade80; }
.setup-body { flex: 1; overflow-y: auto; padding: 0 2rem 1rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 0; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #555; margin-bottom: 0.6rem; }
.source-buttons { display: flex; gap: 0.5rem; }
.btn-source { flex: 1; padding: 0.6rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 8px; color: #ccc; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; }
.btn-source:hover { background: rgba(255,255,255,0.08); color: #fff; }
.btn-source.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; color: #fff; }
.album-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 10px; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s ease; animation: fadeIn 0.3s ease forwards; }
.album-item:hover { background: rgba(255,255,255,0.08); }
.album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; }
.album-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; background: #222; }
.album-info { flex: 1; }
.album-name { font-size: 1rem; font-weight: 500; }
.album-thumb { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.06); }
.album-info { flex: 1; min-width: 0; }
.album-name { font-size: 1rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.album-count { font-size: 0.8rem; color: #888; margin-top: 2px; }
.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; }
.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); }
.start-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.setup-error { text-align: center; padding: 2rem; }
.setup-error p { margin-bottom: 0.75rem; }
.setup-error .error-detail { color: #888; font-size: 0.85rem; }
.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; }
.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.album-shared-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; background: rgba(99,102,241,0.25); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.4); border-radius: 4px; padding: 1px 5px; margin-left: 6px; vertical-align: middle; text-transform: uppercase; letter-spacing: 0.04em; }
.setup-error { padding: 0 2rem; }
.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 1rem; color: #fca5a5; }
.loading-text { color: #555; font-size: 0.9rem; text-align: center; padding: 2rem; }
.setup-footer { padding: 1rem 2rem 1.5rem; flex-shrink: 0; }
.btn-start { width: 100%; padding: 0.9rem; background: #6366f1; border: none; border-radius: 10px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
.btn-start:hover:not(:disabled) { background: #4f46e5; }
.btn-start:disabled { opacity: 0.5; cursor: not-allowed; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; }
/* =============================================
SLIDESHOW - VINTAGE POLAROID PILE
============================================= */
#slideshow-screen { background: #1e1a14; overflow: hidden; }
/* Background — near-full sepia */
.bg-blur {
position: absolute; top: -30px; left: -30px;
width: calc(100% + 60px); height: calc(100% + 60px);
background-size: cover; background-position: center;
filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
opacity: 0; transition: opacity 3s ease; z-index: 1;
}
/* === SLIDESHOW SCREEN === */
.slideshow-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; }
#pile-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.bg-blur { position: absolute; top: -20px; left: -20px; right: -20px; bottom: -20px; background-size: cover; background-position: center; filter: blur(20px) brightness(0.35) saturate(0.8); opacity: 0; transition: opacity 1.5s ease; }
.bg-blur.visible { opacity: 1; }
/* Canvas pile */
#pile-canvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 2; pointer-events: none;
transition: opacity 2s ease;
}
/* Vignette */
.bg-vignette {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%);
z-index: 3; pointer-events: none;
}
/* --- Centering wrapper (flexbox — works on all resolutions) --- */
.main-frame-wrapper {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
z-index: 5; pointer-events: none; overflow: visible;
}
/* --- Main frame — no transform centering, flexbox does it --- */
.main-frame {
opacity: 0;
transition: opacity 1.2s ease;
animation: float 90s linear infinite;
pointer-events: auto;
}
.main-frame-wrap { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.8s ease; animation: floatFrame 6s ease-in-out infinite; }
.main-frame.visible { opacity: 1; }
/* Slow drift + slight constant rotation */
@keyframes float {
0% { transform: translate(0, 0) rotate(1.5deg); }
100% { transform: translate(8px, -5px) rotate(1.5deg); }
}
/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
.main-frame .frame-border {
background: #ede8df;
padding: 1.2vmin 1.2vmin 4vmin 1.2vmin;
box-shadow:
0 6px 40px rgba(0,0,0,0.6),
0 2px 6px rgba(0,0,0,0.3),
inset 0 0 0 1px rgba(0,0,0,0.05);
border-radius: 2px;
position: relative;
}
/* Large main image — allowed to overhang slightly */
.main-frame .frame-media {
display: block;
max-width: 93vw;
max-height: 85vh;
width: auto; height: auto;
object-fit: contain;
background: #2a2520;
}
/* === OVERLAY === */
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; }
#main-photo { display: block; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
#main-video { display: none; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
.touch-zones { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; }
.touch-zones > div { flex: 1; cursor: pointer; }
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s; }
.overlay.hidden { opacity: 0; }
.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); }
.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; }
.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; }
.overlay-bottom { position: absolute; bottom: 0; left: 0; width: 100%; padding: 1.5rem 2rem 1rem; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); }
.exif-info { font-size: 0.85rem; font-weight: 300; color: rgba(255,255,255,0.75); margin-bottom: 0.75rem; text-shadow: 0 1px 4px rgba(0,0,0,0.8); }
.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; }
.clock { position: absolute; top: 1.5rem; right: 2rem; font-size: 3rem; font-weight: 200; color: rgba(255,255,255,0.9); text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.02em; }
.date-display { position: absolute; top: 6rem; right: 2rem; font-size: 0.9rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.5); text-align: right; }
.exif-info { position: absolute; bottom: 3rem; left: 50%; transform: translateX(-50%); font-size: 0.8rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.6); white-space: nowrap; }
.progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: rgba(255,255,255,0.1); }
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.4); }
.btn-settings { position: absolute; bottom: 1.5rem; right: 1.5rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 44px; height: 44px; color: rgba(255,255,255,0.8); font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s; pointer-events: all; }
.btn-settings.visible { opacity: 1; }
/* === CONTROLS === */
.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; }
.touch-left { left: 0; width: 20%; }
.touch-center { left: 20%; width: 60%; }
.touch-right { right: 0; width: 20%; }
.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
.settings-btn.visible { opacity: 1; pointer-events: auto; }
/* === RESPONSIVE === */
@media (max-width: 600px) {
.setup-container { padding: 1.25rem; }
.setup-header h1 { font-size: 1.6rem; }
.clock { font-size: 1.8rem; }
.overlay-top-right { top: 1rem; right: 1rem; }
.overlay-bottom { padding: 1rem 1rem 0.5rem; }
.source-buttons { flex-direction: column; }
.main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
.main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
}
.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes floatFrame { 0%, 100% { transform: translateY(0px) rotate(-0.3deg); } 50% { transform: translateY(-8px) rotate(0.3deg); } }
+39 -33
View File
@@ -6,70 +6,76 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1510">
<meta name="theme-color" content="#0a0a0f">
<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">
</head>
<body>
<div id="setup-screen" class="screen">
<div class="setup-container">
<!-- Setup Screen -->
<div id="setup-screen" class="setup-screen">
<div class="setup-header">
<img src="/img/icon.png" alt="Frambe" class="setup-logo">
<h1>Frambe</h1>
<p class="subtitle" id="connection-status">Connecting to Immich…</p>
<h1>Fram<span>be</span></h1>
<p id="connection-status">Connecting to Immich…</p>
</div>
<div id="setup-content" class="setup-content">
<div class="section">
<h2>Select Photo Source</h2>
<div id="setup-content" class="setup-body">
<div>
<div class="section-title">Photo Source</div>
<div class="source-buttons">
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon"></span><span>Favorites</span></button>
<button id="btn-all-photos" class="btn-source" onclick="selectSource('random')">🎲 Random</button>
<button id="btn-favorites" class="btn-source" onclick="selectSource('favorites')">⭐ Favorites</button>
</div>
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
</div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
<div>
<div class="section-title">Albums</div>
<div id="albums-list"><p class="loading-text">Loading albums…</p></div>
</div>
</div>
<div id="setup-error" class="setup-error" style="display:none">
<div class="error-box">
<p>⚠️ Cannot connect to Immich</p>
<p class="error-detail" id="error-detail"></p>
<button onclick="location.reload()">Retry</button>
<p id="error-detail" style="margin-top:0.5rem;font-size:0.85rem;opacity:0.8"></p>
<button onclick="location.reload()" style="margin-top:0.75rem;padding:0.4rem 1rem;background:#6366f1;border:none;border-radius:6px;color:#fff;cursor:pointer">Retry</button>
</div>
</div>
<div class="setup-footer">
<button id="btn-start" class="btn-start" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div>
</div>
<div id="slideshow-screen" class="screen" style="display:none">
<!-- Slideshow Screen -->
<div id="slideshow-screen" class="slideshow-screen" style="display:none">
<div id="bg-blur" class="bg-blur"></div>
<canvas id="pile-canvas"></canvas>
<div class="bg-vignette"></div>
<!-- Flexbox wrapper handles centering; animation lives on inner frame -->
<div class="main-frame-wrapper">
<div class="main-frame-wrap">
<div id="main-frame" class="main-frame">
<div class="frame-border">
<img id="main-photo" class="frame-media" alt="">
<video id="main-video" class="frame-media" muted playsinline style="display:none"></video>
</div>
<img id="main-photo" alt="">
<video id="main-video" muted playsinline style="display:none"></video>
</div>
</div>
<div id="overlay" class="overlay">
<div class="overlay-top-right">
<div id="clock" class="clock"></div>
<div id="date-display" class="date-display"></div>
</div>
<div class="overlay-bottom">
<div id="exif-info" class="exif-info"></div>
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
<div class="clock" id="clock"></div>
<div class="date-display" id="date-display"></div>
<div class="exif-info" id="exif-info"></div>
<div class="progress-bar" id="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button>
<div class="touch-zones">
<div onclick="prevPhoto()"></div>
<div onclick="toggleOverlay()"></div>
<div onclick="nextPhoto()"></div>
</div>
<button id="btn-settings" class="btn-settings" onclick="exitSlideshow()"></button>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+30 -175
View File
@@ -1,193 +1,49 @@
// === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
// === Frambe v1.4.1 - Client with WebSocket Remote Control ===
(function () {
'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false;
var pileCanvas, pileCtx;
// Shared polaroid proportions (match main frame CSS: ~3% sides, ~10% bottom)
var FRAME_PAD_RATIO = 0.03;
var FRAME_BOTTOM_RATIO = 0.10;
var FRAME_COLOR = '#ede8df';
var currentVideoPlaying = false, pileCanvas, pileCtx;
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
var wsConn = null, clientId = null, isSleeping = false;
var $setupScreen=document.getElementById('setup-screen'),$slideshowScreen=document.getElementById('slideshow-screen'),$connectionStatus=document.getElementById('connection-status'),$setupContent=document.getElementById('setup-content'),$setupError=document.getElementById('setup-error'),$errorDetail=document.getElementById('error-detail'),$albumsList=document.getElementById('albums-list'),$btnStart=document.getElementById('btn-start'),$bgBlur=document.getElementById('bg-blur'),$mainFrame=document.getElementById('main-frame'),$mainPhoto=document.getElementById('main-photo'),$mainVideo=document.getElementById('main-video'),$clock=document.getElementById('clock'),$dateDisplay=document.getElementById('date-display'),$exifInfo=document.getElementById('exif-info'),$progressFill=document.getElementById('progress-fill'),$overlay=document.getElementById('overlay'),$btnSettings=document.getElementById('btn-settings'),$progressBar=document.getElementById('progress-bar');
var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen');
var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame');
var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video');
var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display');
var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill');
var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings');
var $progressBar = document.getElementById('progress-bar');
// === WEBSOCKET ===
function connectWebSocket(){var proto=location.protocol==='https:'?'wss:':'ws:';wsConn=new WebSocket(proto+'//'+location.host+'/ws');wsConn.onopen=function(){console.log('[Frambe] WebSocket connected');wsConn.send(JSON.stringify({type:'register',role:'frame',status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}}catch(err){}};wsConn.onclose=function(){setTimeout(connectWebSocket,5000);};}
function sendStatus(s){if(wsConn&&wsConn.readyState===WebSocket.OPEN)wsConn.send(JSON.stringify({type:'status',status:s,currentAlbum:selectedAlbumId,config:getCurrentConfig()}));}
function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};}
function handleRemoteCommand(action,payload){console.log('[Frambe] Remote: '+action);switch(action){case'setSource':selectedSource=payload.source;selectedAlbumId=payload.albumId||null;selectedPersonId=payload.personId||null;if(isSleeping)wakeUp();if(isRunning){clearTimeout(slideshowTimer);stopVideo();}doStartSlideshow();break;case'start':if(isSleeping)wakeUp();if(!isRunning&&selectedSource)doStartSlideshow();break;case'stop':if(isRunning)exitSlideshowInternal();sendStatus('idle');break;case'next':if(isRunning)showNextAsset();break;case'prev':if(isRunning)showPrevAsset();break;case'sleep':goToSleep();break;case'wake':wakeUp();break;case'refresh':location.reload();break;case'setConfig':applyConfigChange(payload);break;}}
function goToSleep(){isSleeping=true;document.body.style.background='#000';if($slideshowScreen)$slideshowScreen.style.display='none';if($setupScreen)$setupScreen.style.display='none';var s=document.getElementById('sleep-overlay');if(!s){s=document.createElement('div');s.id='sleep-overlay';s.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:#000;z-index:9999;';document.body.appendChild(s);}s.style.display='block';if(isRunning){clearTimeout(slideshowTimer);stopVideo();}sendStatus('sleeping');}
function wakeUp(){isSleeping=false;document.body.style.background='';var s=document.getElementById('sleep-overlay');if(s)s.style.display='none';if(isRunning)$slideshowScreen.style.display='block';else $setupScreen.style.display='flex';sendStatus(isRunning?'playing':'idle');}
function applyConfigChange(c){if('slideshowInterval'in c)config.slideshowInterval=c.slideshowInterval;if('showClock'in c){config.showClock=c.showClock;$clock.style.display=c.showClock?'':'none';}if('showDate'in c){config.showDate=c.showDate;$dateDisplay.style.display=c.showDate?'':'none';}if('showExif'in c){config.showExif=c.showExif;$exifInfo.style.display=c.showExif?'':'none';}if('showProgress'in c){config.showProgress=c.showProgress;$progressBar.style.display=c.showProgress?'':'none';}sendStatus(isRunning?'playing':'idle');}
function exitSlideshowInternal(){isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();}
// === INIT ===
function getUrlParams(){var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p;}
async function autoLaunch(src, aid, pid) { urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launching: source='+src+(aid?' album='+aid:'')+(pid?' person='+pid:''));await doStartSlideshow(); }
async function init() {
document.body.classList.add('setup-mode');
try {
config = await (await fetch('/api/config')).json();
console.log('[Frambe] Running version ' + (config.version || 'unknown'));
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
var si = await (await fetch('/api/server-info')).json();
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');
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; }
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); }
}
async function autoLaunch(src,aid,pid){urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launch: '+src);await doStartSlideshow();}
async function init(){document.body.classList.add('setup-mode');connectWebSocket();try{config=await(await fetch('/api/config')).json();console.log('[Frambe] v'+(config.version||'?'));if(!config.connected){showError('API key not configured.');return;}var si=await(await fetch('/api/server-info')).json();if(!si.ok){showError('Cannot reach Immich: '+si.error);return;}$connectionStatus.textContent='Connected to Immich v'+si.version.major+'.'+si.version.minor+'.'+si.version.patch;$connectionStatus.classList.add('connected');var p=getUrlParams();if(p.album){await autoLaunch('album',p.album,null);return;}if(p.person){await autoLaunch('person',null,p.person);return;}if('favorites'in p){await autoLaunch('favorites',null,null);return;}if('random'in p){await autoLaunch('random',null,null);return;}if(config.albumId){await autoLaunch('album',config.albumId,null);return;}if(config.showFavoritesOnly){await autoLaunch('favorites',null,null);return;}await loadAlbums();}catch(err){showError('Init failed: '+err.message);}}
function showError(msg){$setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg;}
async function loadAlbums() { try { var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" onclick="selectAlbum(\''+a.id+'\', this)">';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+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';} }
async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" data-shared="'+(a.shared?'true':'false')+'" onclick="selectAlbum(\''+a.id+'\', this)">';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">'+(a.shared?'🔗':'📁')+'</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+(a.shared?' <span class="album-shared-badge">Shared</span>':'')+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';}}
window.selectSource=function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i<items.length;i++)items[i].classList.remove('selected');$btnStart.disabled=false;};
window.selectAlbum=function(id,el){selectedSource='album';selectedAlbumId=id;selectedPersonId=null;document.getElementById('btn-all-photos').classList.remove('selected');document.getElementById('btn-favorites').classList.remove('selected');var items=document.querySelectorAll('.album-item');for(var i=0;i<items.length;i++)items[i].classList.remove('selected');el.classList.add('selected');$btnStart.disabled=false;};
async function loadAssets(){var res;if(selectedSource==='album'&&selectedAlbumId){res=await fetch('/api/albums/'+selectedAlbumId);if(!res.ok)throw new Error('Album: '+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: '+res.status);assets=await res.json();}else if(selectedSource==='favorites'){res=await fetch('/api/assets/favorites');if(!res.ok)throw new Error('Favorites: '+res.status);assets=await res.json();}else{res=await fetch('/api/assets/random?count=100');if(!res.ok)throw new Error('Random: '+res.status);assets=await res.json();}if(config.shuffle)shuffleArray(assets);console.log('[Frambe] Loaded '+assets.length+' assets');}
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+' assets');}catch(e){}},(config.refreshInterval||300)*1000);}
async function loadAssets() { 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+' assets'); }
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] Refresh added '+added+' new asset(s)');}catch(e){console.warn('[Frambe] Refresh failed: '+e.message);}}, (config.refreshInterval||300)*1000); }
// =========================
// CANVAS PILE
// =========================
function initPileCanvas() {
pileCanvas = document.getElementById('pile-canvas');
var dpr = window.devicePixelRatio || 1;
pileCanvas.width = window.innerWidth * dpr;
pileCanvas.height = window.innerHeight * dpr;
pileCanvas.style.width = window.innerWidth + 'px';
pileCanvas.style.height = window.innerHeight + 'px';
pileCtx = pileCanvas.getContext('2d');
pileCtx.scale(dpr, dpr);
}
function clearPileCanvas() {
if (pileCtx) { pileCtx.setTransform(1,0,0,1,0,0); pileCtx.clearRect(0, 0, pileCanvas.width, pileCanvas.height); pileCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); }
}
function dropPhotoPile(imgSrc) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
var vw = window.innerWidth, vh = window.innerHeight;
// Pile polaroid size: 18-25% of screen width
var polaroidW = vw * (0.18 + Math.random() * 0.07);
// Use shared proportions to match main frame
var pad = polaroidW * FRAME_PAD_RATIO;
var bottomPad = polaroidW * FRAME_BOTTOM_RATIO;
var innerW = polaroidW - pad * 2;
var innerH = innerW * (img.height / img.width);
var totalH = innerH + pad + bottomPad;
var cx = Math.random() * vw, cy = Math.random() * vh;
var rot = (Math.random() - 0.5) * 30;
var startTime = null, fadeDuration = 1200;
function drawFrame(timestamp) {
if (!startTime) startTime = timestamp;
var alpha = Math.min((timestamp - startTime) / fadeDuration, 1);
pileCtx.save();
pileCtx.globalAlpha = alpha;
pileCtx.translate(cx, cy);
pileCtx.rotate(rot * Math.PI / 180);
pileCtx.shadowColor = 'rgba(0,0,0,0.45)';
pileCtx.shadowBlur = 18;
pileCtx.shadowOffsetX = 3;
pileCtx.shadowOffsetY = 6;
pileCtx.fillStyle = FRAME_COLOR;
pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH);
pileCtx.shadowColor = 'transparent'; pileCtx.shadowBlur = 0; pileCtx.shadowOffsetX = 0; pileCtx.shadowOffsetY = 0;
pileCtx.drawImage(img, -polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
// Sepia wash
pileCtx.fillStyle = 'rgba(150, 120, 70, 0.2)';
pileCtx.fillRect(-polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
pileCtx.restore();
if (alpha < 1) requestAnimationFrame(drawFrame);
}
requestAnimationFrame(drawFrame);
};
img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
img.src = imgSrc;
}
// =========================
// SLIDESHOW ENGINE
// =========================
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;
initPileCanvas();
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;
showNextAsset(); scheduleOverlayHide(); startRefreshTimer();
} catch (err) { console.error('[Frambe] Start failed: '+err.message); $btnStart.textContent='Error: '+err.message; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000); }
}
// === CANVAS PILE ===
function initPileCanvas(){pileCanvas=document.getElementById('pile-canvas');var d=window.devicePixelRatio||1;pileCanvas.width=window.innerWidth*d;pileCanvas.height=window.innerHeight*d;pileCanvas.style.width=window.innerWidth+'px';pileCanvas.style.height=window.innerHeight+'px';pileCtx=pileCanvas.getContext('2d');pileCtx.scale(d,d);}
function clearPileCanvas(){if(pileCtx){pileCtx.setTransform(1,0,0,1,0,0);pileCtx.clearRect(0,0,pileCanvas.width,pileCanvas.height);pileCtx.scale(window.devicePixelRatio||1,window.devicePixelRatio||1);}}
function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.fillStyle='rgba(150,120,70,0.2)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
// === SLIDESHOW ===
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);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();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;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}}
window.startSlideshow=function(){doStartSlideshow();};
window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; }
isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();
$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');
$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;
$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();
};
window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');};
function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);}
function showPrevAsset(){currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex);}
function showAsset(index) {
if (!assets[index]) return;
clearTimeout(slideshowTimer); stopVideo();
var asset = assets[index], isVideo = asset.type === 'VIDEO';
var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id));
if (currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) dropPhotoPile('/api/assets/' + assets[pi].id + '/thumbnail?size=thumbnail'); }
$mainFrame.classList.remove('visible');
var img = new Image();
img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); };
img.onerror = function () { setTimeout(showNextAsset, 500); };
img.src = thumbUrl;
var ni = index + 1; if (ni >= assets.length) ni = 0;
if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; }
}
function displayAsset(asset, thumbUrl, isVideo) {
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); }
$mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none';
if (isVideo) {
$mainVideo.style.display = 'block';
$mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl;
$mainVideo.load();
$mainVideo.play().then(function(){ currentVideoPlaying=true; }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); });
$mainVideo.onended = function () { currentVideoPlaying=false; showNextAsset(); };
slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){showNextAsset();} }, Math.max((config.slideshowInterval||30)*3, 120)*1000);
} else {
$mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl;
slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000);
}
requestAnimationFrame(function () { $mainFrame.classList.add('visible'); });
updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000);
}
function showAsset(idx){if(!assets[idx])return;clearTimeout(slideshowTimer);stopVideo();var a=assets[idx],isV=a.type==='VIDEO',thu='/api/assets/'+a.id+'/thumbnail?size=preview';if(currentIndex>0){var pi=currentIndex-1;if(pi<0)pi=assets.length-1;if(assets[pi])dropPhotoPile('/api/assets/'+assets[pi].id+'/thumbnail?size=thumbnail');}$mainFrame.classList.remove('visible');var img=new Image();img.onload=function(){setTimeout(function(){displayAsset(a,thu,isV);},500);};img.onerror=function(){setTimeout(showNextAsset,500);};img.src=thu;var ni=idx+1;if(ni>=assets.length)ni=0;if(assets[ni]){var pre=new Image();pre.src='/api/assets/'+assets[ni].id+'/thumbnail?size=preview';}}
function displayAsset(a,thu,isV){if(config.backgroundBlur){$bgBlur.style.backgroundImage='url('+thu+')';$bgBlur.classList.add('visible');}$mainVideo.style.display='none';$mainPhoto.style.display='none';if(isV){$mainVideo.style.display='block';$mainVideo.src='/api/assets/'+a.id+'/video';$mainVideo.poster=thu;$mainVideo.load();$mainVideo.play().then(function(){currentVideoPlaying=true;}).catch(function(){});$mainVideo.onended=function(){currentVideoPlaying=false;showNextAsset();};slideshowTimer=setTimeout(function(){if(currentVideoPlaying)showNextAsset();},Math.max((config.slideshowInterval||30)*3,120)*1000);}else{$mainPhoto.style.display='block';$mainPhoto.src=thu;slideshowTimer=setTimeout(showNextAsset,(config.slideshowInterval||30)*1000);}requestAnimationFrame(function(){$mainFrame.classList.add('visible');});updateExifInfo(a);startProgress(isV?null:(config.slideshowInterval||30)*1000);}
function stopVideo(){if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;}}
function updateExifInfo(a){if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · ');}
function startProgress(ms){if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';}}
function updateClock(){var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'});}
@@ -203,6 +59,5 @@
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){}}
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
function preventSleep(){try{var v=document.createElement('video');v.setAttribute('playsinline','');v.setAttribute('muted','');v.setAttribute('loop','');v.style.cssText='position:absolute;width:1px;height:1px;opacity:0.01';v.src='data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';document.body.appendChild(v);v.play().catch(function(){});}catch(e){}}
init();requestWakeLock();preventSleep();
init();requestWakeLock();
})();
+219 -78
View File
@@ -1,12 +1,16 @@
const express = require('express');
const fetch = require('node-fetch');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const sharp = require('sharp');
const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config();
const VERSION = '1.3.0';
const VERSION = '1.4.1';
const app = express();
const server = http.createServer(app);
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 || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
@@ -23,103 +27,240 @@ const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
function immichHeaders() {
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
}
function log(msg) { console.log('[Frambe] ' + msg); }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
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');
const imageCache = new Map();
function cacheGet(key) {
const entry = imageCache.get(key);
if (!entry) return null;
imageCache.delete(key);
imageCache.set(key, entry);
return entry;
}
function cacheSet(key, value) {
if (imageCache.size >= IMAGE_CACHE_MAX) {
const oldest = imageCache.keys().next().value;
imageCache.delete(oldest);
}
imageCache.set(key, value);
}
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
const AUTH_ENABLED = !!ADMIN_PASSWORD;
const sessions = new Map();
const SESSION_TTL = 24 * 60 * 60 * 1000;
function createSession(username) {
const token = crypto.randomBytes(32).toString('hex');
const now = Date.now();
sessions.set(token, { username, createdAt: now, expiresAt: now + SESSION_TTL });
return token;
}
function validateSession(token) {
if (!token) return false;
const session = sessions.get(token);
if (!session) return false;
if (Date.now() > session.expiresAt) { sessions.delete(token); return false; }
return true;
}
function cleanupSessions() { const now = Date.now(); sessions.forEach((s, t) => { if (now > s.expiresAt) sessions.delete(t); }); }
setInterval(cleanupSessions, 60 * 60 * 1000);
function requireAdminAuth(req, res, next) {
if (!AUTH_ENABLED) return next();
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match && validateSession(match[1])) return next();
if (req.headers['accept'] && req.headers['accept'].includes('application/json')) {
return res.status(401).json({ error: 'Not authenticated' });
}
return res.redirect('/admin/login');
}
function requireApiToken(req, res, next) {
if (!FRAMBE_API_TOKEN) return next();
const auth = req.headers['authorization'] || '';
const hdr = req.headers['x-api-token'] || '';
const qry = req.query.token || '';
const provided = auth.startsWith('Bearer ') ? auth.slice(7) : (hdr || qry);
if (provided === FRAMBE_API_TOKEN) return next();
return res.status(401).json({ error: 'Invalid API token' });
}
function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; }
function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); }
function logErr(msg) { console.error('[' + new Date().toISOString() + '] ERROR ' + msg); }
function filterAssets(assets) {
return assets.filter(a => {
if (a.isTrashed) return false;
if (!INCLUDE_VIDEOS && a.type === 'VIDEO') return false;
return true;
});
}
}));
app.use(express.json());
function mapAsset(a) {
return {
id: a.id, type: a.type, 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,
id: a.id,
type: a.type,
fileCreatedAt: a.fileCreatedAt,
fileModifiedAt: a.fileModifiedAt,
isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? {
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
city: a.exifInfo.city,
state: a.exifInfo.state,
country: a.exifInfo.country,
make: a.exifInfo.make,
model: a.exifInfo.model,
} : null,
originalMimeType: a.originalMimeType,
duration: a.duration,
};
}
function filterAssets(assets) {
if (INCLUDE_VIDEOS) return assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO');
return assets.filter(a => a.type === 'IMAGE');
const wss = new WebSocketServer({ server });
const clients = new Map();
function getClientList() {
const now = Date.now();
return Array.from(clients.values()).map(c => ({
id: c.id,
userAgent: c.userAgent,
connectedAt: c.connectedAt,
firstSeen: c.firstSeen,
lastSeen: c.lastSeen,
status: c.status,
online: (now - c.lastSeen) < 35000,
}));
}
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, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY });
wss.on('connection', (ws, req) => {
const id = crypto.randomBytes(8).toString('hex');
const now = Date.now();
const ua = req.headers['user-agent'] || 'unknown';
clients.set(id, { id, ws, userAgent: ua, connectedAt: now, firstSeen: now, lastSeen: now, status: 'connected' });
log('WS connect: ' + id + ' (' + ua.substring(0, 60) + ')');
ws.send(JSON.stringify({ type: 'hello', clientId: id }));
broadcastAdminClients();
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw);
const c = clients.get(id);
if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; }
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); }
else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); }
} catch (e) { }
});
ws.on('close', () => {
const c = clients.get(id);
if (c) { c.online = false; c.lastSeen = Date.now(); }
log('WS close: ' + id);
broadcastAdminClients();
});
});
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}`); 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 }); }
function broadcastAdminClients() {
const list = getClientList();
const msg = JSON.stringify({ type: 'clients', clients: list });
wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { } } });
}
function sendToClient(clientId, payload) {
const c = clients.get(clientId);
if (!c || c.ws.readyState !== WebSocket.OPEN) return false;
c.ws.send(JSON.stringify(payload));
return true;
}
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); });
app.get('/admin/login', (_req, res) => { if (!AUTH_ENABLED) return res.redirect('/admin'); res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html')); });
app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin')));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); });
app.post('/api/auth/login', (req, res) => {
if (!AUTH_ENABLED) return res.json({ ok: true });
const { username, password } = req.body;
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const token = createSession(username);
res.setHeader('Set-Cookie', 'frambe_session=' + token + '; HttpOnly; Path=/; Max-Age=' + (SESSION_TTL / 1000));
return res.json({ ok: true });
}
return res.status(401).json({ ok: false, error: 'Invalid credentials' });
});
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) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
app.post('/api/auth/logout', (req, res) => {
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match) sessions.delete(match[1]);
res.setHeader('Set-Cookie', 'frambe_session=; HttpOnly; Path=/; Max-Age=0');
res.json({ ok: true });
});
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 = filterAssets(album.assets || []).map(mapAsset); const vids = assets.filter(a => a.type === 'VIDEO').length; log('Album "' + album.albumName + '" returned ' + assets.length + ' assets (' + (assets.length - vids) + ' photos, ' + vids + ' videos)'); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
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, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED, imageMaxWidth: IMAGE_MAX_WIDTH, imageQuality: IMAGE_QUALITY }); });
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); const v = await r.json(); res.json({ ok: true, version: v }); } catch (e) { res.status(502).json({ ok: false, error: e.message }); } });
app.get('/api/albums', async (_req, res) => { try { const [rOwn, rShared] = await Promise.all([fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }), fetch(IMMICH_URL + '/api/albums?shared=true', { headers: immichHeaders() })]); if (!rOwn.ok) throw new Error('Own albums: ' + rOwn.status); const aOwn = await rOwn.json(); const sharedRaw = rShared.ok ? await rShared.json() : []; const seen = new Set(); const result = []; for (const x of aOwn) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: false }); } } for (const x of (Array.isArray(sharedRaw) ? sharedRaw : [])) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: true }); } } log('Listed ' + result.length + ' albums (' + (result.length - aOwn.length) + ' shared)'); res.json(result); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.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('' + 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 (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + 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 (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/optimized', async (req, res) => {
const id = req.params.id;
const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY;
const cached = cacheGet(cacheKey);
if (cached) { res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); return res.send(cached); }
try {
const r = await fetch(IMMICH_URL + '/api/assets/' + id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error('' + r.status);
const buf = await r.buffer();
const optimized = await sharp(buf).resize({ width: IMAGE_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: IMAGE_QUALITY }).toBuffer();
cacheSet(cacheKey, optimized);
res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); res.send(optimized);
} catch (e) { res.status(502).json({ error: e.message }); }
});
app.get('/api/people', async (_req, res) => {
try { 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(); 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/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + 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 (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); });
app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
const { action, payload } = req.body;
if (!action) return res.status(400).json({ error: 'action required' });
const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} });
if (!sent) return res.status(404).json({ error: 'Client not found or offline' });
res.json({ ok: true });
});
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 raw = await r.json(); const assets = filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset); log('Person returned ' + assets.length + ' assets'); res.json(assets);
} catch (err) { logErr('Person fetch failed: ' + 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 }); }
});
app.get('/api/assets/random', async (req, res) => {
try { 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}`); const assets = filterAssets(await r.json()).map(mapAsset); log('Random returned ' + assets.length + ' assets'); res.json(assets);
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/favorites', async (_req, res) => {
try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const assets = filterAssets(data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })); log('Favorites returned ' + assets.length + ' assets'); res.json(assets);
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/thumbnail', async (req, res) => {
try { const size = req.query.size || 'preview'; const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { 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 }); }
});
app.get('/api/assets/:id/video', async (req, res) => {
try { log('Streaming video ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/video/playback`, { 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') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res);
} catch (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/original', async (req, res) => {
try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { 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 }); }
app.delete('/api/clients/:id', requireApiToken, (req, res) => {
const c = clients.get(req.params.id);
if (!c) return res.status(404).json({ error: 'Not found' });
clients.delete(req.params.id);
broadcastAdminClients();
res.json({ ok: true });
});
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => {
server.listen(PORT, () => {
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');
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
log('Waiting for requests...');
log('Listening on port ' + PORT);
log('Immich: ' + IMMICH_URL);
log('API key: ' + (API_KEY ? 'set' : 'NOT SET'));
log('Auth: ' + (AUTH_ENABLED ? 'enabled' : 'disabled'));
log('API token: ' + (FRAMBE_API_TOKEN ? 'set' : 'not set'));
});