56 Commits

Author SHA1 Message Date
jessikitty 5fa785e9ab v1.4.4 - fix syntax error in persistentId IIFE (bash history expansion mangled !v) 2026-06-10 10:07:19 +00:00
jessikitty c7b3b5e4a1 v1.4.4 - fix syntax error in persistentId (stray shell text + duplicate declaration broke app.js) 2026-06-10 09:35:47 +00:00
jessikitty 581f6ee87b Merge pull request 'v1.4.2 - Aged/faded pile photos + washed-out background' (#3) from dev into main
Reviewed-on: #3
2026-06-10 16:53:40 +10:00
jessikitty c2fd34f04c v1.4.2 - aged/faded effect on canvas pile photos 2026-06-10 06:33:06 +00:00
jessikitty c85b75996e v1.4.2 - aged/faded effect on canvas pile photos (desaturate + sepia + warm overlay) 2026-06-10 16:19:44 +10:00
jessikitty b8516630ab v1.4.1 - bump version 2026-06-10 16:02:11 +10:00
jessikitty e1488d2f5e v1.4.1 - reduce background blur saturation/brightness for less distracting backdrop 2026-06-10 16:01:59 +10:00
jessikitty 4bed4aa6da fix: proper WebSocket role routing, device IP shown in admin, sleep/wake working 2026-06-10 01:11:05 +00:00
jessikitty e7344a0e4f Merge pull request 'v1.4.1 — Shared Albums' (#2) from dev into main
Reviewed-on: #2
2026-06-09 16:20:26 +10:00
jessikitty 1c5bbbaf91 fix: CSS class names consistent with index.html, restore touch zones and float animation (v1.4.1) 2026-06-09 16:06:00 +10:00
jessikitty d871cb8145 fix: rewrite index.html to match CSS class names (v1.4.1) 2026-06-09 16:05:28 +10:00
jessikitty 6fef7b75cf fix: add unprotected /admin/login route, fix auth redirect loop 2026-06-09 15:56:48 +10:00
jessikitty daa2bb2f96 Update .gitignore 2026-06-09 15:35:53 +10:00
jessikitty 37c13d3ff4 docs: add v1.4.1 changelog entry for shared albums 2026-06-09 15:32:19 +10:00
jessikitty 7d680f068f feat: add .album-shared-badge CSS style (v1.4.1) 2026-06-09 15:30:51 +10:00
jessikitty 914dd32ac9 feat: show shared album badges in album picker (v1.4.1) 2026-06-09 15:30:12 +10:00
jessikitty 7e54ba4923 feat: fetch shared albums from Immich API (v1.4.1) 2026-06-09 15:12:42 +10:00
jessikitty c067ff237e feat: comprehensive admin dashboard with HA YAML generator, online/offline badges, device info 2026-06-02 10:44:59 +10:00
jessikitty 243396c93a feat: server-side image optimization with sharp, persistent frame registry with source tracking 2026-06-02 10:42:53 +10:00
jessikitty 886cd3b016 feat: add sharp for server-side image optimization 2026-06-02 10:41:22 +10:00
jessikitty 0e2ec3eb37 feat: persistent client list with online/offline status, REST API reference panel with copy buttons 2026-06-01 23:02:08 +10:00
jessikitty 6f85791302 feat: persistent frame registry with online/offline tracking, DELETE endpoint 2026-06-01 23:00:24 +10:00
jessikitty 0c279ad00a docs: expanded README with deployment, upgrade, and versioning sections 2026-05-26 16:28:11 +10:00
jessikitty 39aff8c839 fix: re-upload README with correct encoding 2026-05-26 15:15:59 +10:00
jessikitty 213a7bd737 fix: re-upload server.js with correct encoding 2026-05-26 15:13:59 +10:00
jessikitty e770332032 fix: re-upload admin dashboard with correct encoding 2026-05-26 15:11:59 +10:00
jessikitty 1d209b5daa fix: re-upload login.html with correct encoding 2026-05-26 15:10:43 +10:00
jessikitty 249162fabb fix: re-upload docker-compose.yml with correct encoding 2026-05-26 15:10:11 +10:00
jessikitty 79d9cae986 fix: re-upload .env.example with correct encoding 2026-05-26 15:09:45 +10:00
jessikitty b8ea8eb150 docs: update README with v1.4.0 auth and REST API documentation 2026-05-25 16:30:58 +10:00
jessikitty 402b6a0def docs: add auth env vars to docker-compose.yml 2026-05-25 16:25:58 +10:00
jessikitty 355f26eaf1 docs: add auth env vars to .env.example 2026-05-25 16:24:58 +10:00
jessikitty a269456f41 feat: add logout button and auth check to admin dashboard 2026-05-25 16:23:58 +10:00
jessikitty ec79ac4752 feat: admin login, API token auth, REST client endpoints for v1.4.0 2026-05-25 10:28:15 +10:00
jessikitty 83b8b78f0c feat: add admin login page for v1.4.0 2026-05-25 10:21:48 +10:00
jessikitty cc36a6fed4 feat(1.4.0): add ws dependency for WebSocket server/client communication 2026-05-22 19:51:44 +10:00
jessikitty e2be524b2a feat(1.4.0): WebSocket client with remote control — sleep/wake, setSource, setConfig, start/stop/next/prev 2026-05-22 19:50:53 +10:00
jessikitty 4b9db2af5a feat(1.4.0): admin dashboard with client cards, album/person selector, playback/power/config controls 2026-05-22 19:48:23 +10:00
jessikitty 1863da1a28 feat(1.4.0): WebSocket server, client registry, admin command routing, client naming 2026-05-22 19:47:09 +10:00
jessikitty 5a6536c6ca Merge pull request 'v1.3.0 — Vintage Polaroid Pile Theme' (#1) from dev into main
Reviewed-on: #1
2026-05-22 11:59:49 +10:00
jessikitty fc470f4df2 version 1.3.0 — Vintage Polaroid Pile theme 2026-05-22 09:18:46 +10:00
jessikitty 7f7ea978b9 fix: pile uses shared FRAME_PAD_RATIO/FRAME_BOTTOM_RATIO/FRAME_COLOR to match main, stronger sepia wash on pile 2026-05-22 09:17:58 +10:00
jessikitty dc1f3402d6 fix: flexbox centering for all resolutions, 1.5deg rotation, larger frame (93vw), full sepia bg, proportional polaroid padding (vmin) 2026-05-22 09:15:38 +10:00
jessikitty c1ebbaef98 fix: wrap main-frame in flexbox centering wrapper for reliable centering on all resolutions 2026-05-22 09:14:33 +10:00
jessikitty 1b45afb6f4 fix: pile photos fade in with sepia wash, bg transitions smoothly, drift is single-direction 2026-05-20 10:38:59 +10:00
jessikitty d1163511fa fix: portrait frame hugs image, heavier sepia wash, slow single-direction drift, bg fade transition 2026-05-20 10:37:00 +10:00
jessikitty 804c6cfd86 feat: canvas-based cumulative photo pile, unified polaroid for photos+videos, larger main frame 2026-05-20 09:48:15 +10:00
jessikitty 65993839a7 feat: replace pile divs with canvas, remove filmstrip elements, unified polaroid frame 2026-05-20 09:46:24 +10:00
jessikitty d6b6464171 feat: enlarge main frame to ~90% screen, remove filmstrip, add canvas pile, unified polaroid style 2026-05-20 09:45:56 +10:00
jessikitty 0f4a995cdc feat(1.3.0): Polaroid pile slideshow engine, video playback with filmstrip frame, floating animation 2026-05-19 21:48:53 +10:00
jessikitty ad7a2b8250 feat(1.3.0): vintage Polaroid pile theme, filmstrip video frame, floating animation, warm vignette 2026-05-19 21:46:19 +10:00
jessikitty 9ab2dc7578 feat(1.3.0): new DOM structure with photo pile, main polaroid/filmstrip frame, video element 2026-05-19 21:44:28 +10:00
jessikitty c2b40db843 feat(1.3.0): video support, INCLUDE_VIDEOS config, video streaming endpoint, asset type in mapAsset 2026-05-19 21:43:24 +10:00
jessikitty 6943d0c0dd fix(1.2.2): request logging, cache-busting, clean logs 2026-05-19 16:55:27 +10:00
jessikitty 4f2f6ee555 fix: add request logging, no-cache on HTML/JS/CSS, clean startup log, version identifier 2026-05-19 16:55:06 +10:00
jessikitty f2cc8b4413 fix: correct port mapping in README docker examples (3030:3000), add v1.2.1 to changelog 2026-05-19 16:38:34 +10:00
11 changed files with 873 additions and 854 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
+292 -14
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,42 +28,229 @@ 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 .
docker run -d \
--name frambe \
-p 3030:3030 \
-p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \
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,10 +283,16 @@ 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) |
| `PORT` | `3030` | Server port |
| `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
@@ -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,22 +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.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
+13 -5
View File
@@ -9,12 +9,15 @@ services:
- "3030:3000"
environment:
# REQUIRED
- IMMICH_URL=http://your-immich-server:2283
- IMMICH_API_KEY=your-api-key-here
- IMMICH_URL=http://10.0.0.40:2283
- IMMICH_API_KEY=0G1iJ4ydmA0ghjMk1OTcdFhxUbhAgfti2higMKFmU
- ADMIN_USERNAME=jessikitty
- ADMIN_PASSWORD=23Pinkpr!ncesses
- FRAMBE_API_TOKEN=whosethatgirl-itsjess
# Slideshow
- SLIDESHOW_INTERVAL=30
- TRANSITION_DURATION=2
- SLIDESHOW_INTERVAL=300
- TRANSITION_DURATION=5
- IMAGE_FIT=contain
- SHUFFLE=true
- BACKGROUND_BLUR=true
@@ -23,9 +26,14 @@ services:
# Overlays
- SHOW_CLOCK=true
- SHOW_DATE=true
- SHOW_EXIF=true
- SHOW_EXIF=false
- SHOW_PROGRESS=true
# - 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.2.1",
"description": "Frambe — a lightweight digital photo frame web app for Immich",
"version": "1.4.1",
"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>
+56 -446
View File
@@ -1,449 +1,59 @@
/* === Reset & Base === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { 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; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
cursor: none;
}
/* === 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: 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; }
.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; }
body.setup-mode {
cursor: default;
}
/* === 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.25) saturate(0.4); opacity: 0; transition: opacity 1.5s ease; }
.bg-blur.visible { opacity: 1; }
.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; }
#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; }
.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; }
/* === Screens === */
.screen {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
/* === Setup Screen === */
#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;
}
.subtitle {
font-size: 0.95rem;
color: #888;
}
.subtitle.connected {
color: #4ade80;
}
.subtitle.error {
color: #f87171;
}
/* === Source Buttons === */
.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 === */
.albums-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 1.5rem;
}
.loading-text {
text-align: center;
color: #666;
padding: 1rem;
}
.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;
}
.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-count {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
/* === Start Button === */
.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;
letter-spacing: 0.03em;
}
.start-btn:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
}
.start-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* === Setup Error === */
.setup-error {
text-align: center;
padding: 2rem;
}
.setup-error p {
margin-bottom: 0.75rem;
}
.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;
}
/* === Slideshow === */
#slideshow-screen {
background: #000;
}
.bg-blur {
position: absolute;
top: -20px; left: -20px;
width: calc(100% + 40px);
height: calc(100% + 40px);
background-size: cover;
background-position: center;
filter: blur(30px) brightness(0.35);
opacity: 0;
transition: opacity 1.5s ease;
z-index: 1;
}
.bg-blur.visible {
opacity: 1;
}
.photo-layer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
transition: opacity 2s ease;
z-index: 2;
}
.photo-layer.active {
opacity: 1;
}
/* === 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;
}
.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.8), 0 0 20px rgba(0, 0, 0, 0.5);
}
.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;
}
/* === Touch Zones === */
.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 Button === */
.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;
}
/* === Scrollbar Styling === */
.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;
}
/* === 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;
}
}
/* === Loading Spinner === */
.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); }
}
/* === Fade-in Animation === */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.album-item {
animation: fadeIn 0.3s ease forwards;
}
@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); } }
+53 -41
View File
@@ -6,64 +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="#0f0f1a">
<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">
<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>
</div>
<div id="setup-content" class="setup-content">
<div class="section">
<h2>Select Photo Source</h2>
<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>
</div>
<div id="albums-list" class="albums-list">
<p class="loading-text">Loading albums…</p>
</div>
<!-- Setup Screen -->
<div id="setup-screen" class="setup-screen">
<div class="setup-header">
<img src="/img/icon.png" alt="Frambe" class="setup-logo">
<h1>Fram<span>be</span></h1>
<p id="connection-status">Connecting to Immich…</p>
</div>
<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="btn-source" onclick="selectSource('random')">🎲 Random</button>
<button id="btn-favorites" class="btn-source" onclick="selectSource('favorites')">⭐ Favorites</button>
</div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div>
<div id="setup-error" class="setup-error" style="display:none">
<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>
<div id="photo-layer-a" class="photo-layer active"></div>
<div id="photo-layer-b" class="photo-layer"></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>
<canvas id="pile-canvas"></canvas>
<div class="main-frame-wrap">
<div id="main-frame" class="main-frame">
<img id="main-photo" alt="">
<video id="main-video" muted playsinline style="display:none"></video>
</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 id="overlay" class="overlay">
<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-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>
+55 -227
View File
@@ -1,236 +1,64 @@
// === Frambe - Frontend Application ===
// === Frambe v1.4.1 - Client with WebSocket Remote Control ===
(function () {
'use strict';
var config = {}, assets = [], currentIndex = -1, activeLayer = 'a', slideshowTimer = null;
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, pileCanvas, pileCtx;
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
var wsConn = null, clientId = null, isSleeping = false;
var persistentId = (function(){ var k='frambe_pid'; var v=localStorage.getItem(k); if(!v){ v='fp-'+Math.random().toString(36).substr(2,9)+'-'+Date.now().toString(36); localStorage.setItem(k,v); } return v; })();
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 $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b');
var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock');
var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info');
var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay');
var $btnSettings = document.getElementById('btn-settings'), $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();}
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;
}
// === 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-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+'" 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);}
// --- Auto-launch helper: sets source and immediately starts slideshow ---
async function autoLaunch(source, albumId, personId) {
urlDriven = true;
selectedSource = source;
selectedAlbumId = albumId || null;
selectedPersonId = personId || null;
console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
await doStartSlideshow();
}
// === 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.filter='saturate(0.45) sepia(0.25) brightness(0.88)';pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.filter='none';pileCtx.fillStyle='rgba(140,110,60,0.35)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
async function init() {
document.body.classList.add('setup-mode');
try {
config = await (await fetch('/api/config')).json();
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');
// Check URL parameters for direct launch (zero-touch)
var params = getUrlParams();
if (params.album) { await autoLaunch('album', params.album, null); return; }
if (params.person) { await autoLaunch('person', null, params.person); return; }
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
if ('random' in params) { await autoLaunch('random', null, null); return; }
// Check env-based auto-start
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
// No auto-start — show setup screen with album picker
await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + 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 + ' photos</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 fetch failed: ' + res.status);
var al = await res.json();
assets = al.assets || [];
} else if (selectedSource === 'person' && selectedPersonId) {
res = await fetch('/api/people/' + selectedPersonId);
if (!res.ok) throw new Error('Person fetch failed: ' + res.status);
assets = await res.json();
} else if (selectedSource === 'favorites') {
res = await fetch('/api/assets/favorites');
if (!res.ok) throw new Error('Favorites fetch failed: ' + res.status);
assets = await res.json();
} else {
res = await fetch('/api/assets/random?count=100');
if (!res.ok) throw new Error('Random fetch failed: ' + res.status);
assets = await res.json();
}
if (config.shuffle) shuffleArray(assets);
console.log('Frambe: loaded ' + assets.length + ' photo(s) from ' + selectedSource);
}
function startRefreshTimer() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(async function () {
try {
var oldIds = {}; for (var i = 0; i < assets.length; i++) oldIds[assets[i].id] = true;
var nw, r;
if (selectedSource === 'album' && selectedAlbumId) { r = await (await fetch('/api/albums/' + selectedAlbumId)).json(); nw = r.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { nw = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { nw = await (await fetch('/api/assets/favorites')).json(); }
else return;
var added = 0;
for (var j = 0; j < nw.length; j++) { if (!oldIds[nw[j].id]) { assets.push(nw[j]); added++; } }
if (added > 0) console.log('Frambe: added ' + added + ' new photo(s)');
} catch (e) { console.warn('Frambe: refresh failed', e.message); }
}, (config.refreshInterval || 300) * 1000);
}
// --- Core slideshow start (used by both button click and auto-launch) ---
async function doStartSlideshow() {
if (!selectedSource) return;
$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;
}
// Switch to slideshow view
$setupScreen.style.display = 'none';
$slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode');
isRunning = true;
var t = (config.transitionDuration || 2) * 1000;
$layerA.style.transition = 'opacity ' + t + 'ms ease';
$layerB.style.transition = 'opacity ' + t + 'ms ease';
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
if (!config.showClock) $clock.style.display = 'none';
if (!config.showDate) $dateDisplay.style.display = 'none';
if (!config.showExif) $exifInfo.style.display = 'none';
if (!config.showProgress) $progressBar.style.display = 'none';
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000);
currentIndex = -1;
showNextPhoto();
scheduleOverlayHide();
startRefreshTimer();
} catch (err) {
console.error('Frambe: slideshow start failed', err);
$btnStart.textContent = 'Error: ' + err.message;
setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000);
}
}
// Exposed for the button onclick
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; }
isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer);
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode');
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
};
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
function showPhoto(idx) {
if (!assets[idx]) return; clearTimeout(slideshowTimer);
var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview';
var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url;
preloadNext(idx + 1);
}
function displayImage(url, asset) {
var fit = config.imageFit || 'contain', inc, out;
if (activeLayer === 'a') { inc = $layerB; out = $layerA; activeLayer = 'b'; } else { inc = $layerA; out = $layerB; activeLayer = 'a'; }
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit;
inc.classList.add('active'); out.classList.remove('active');
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
updateExifInfo(asset); startProgress();
slideshowTimer = setTimeout(showNextPhoto, (config.slideshowInterval || 30) * 1000);
}
function preloadNext(i) { if (i >= assets.length) i = 0; if (!assets[i]) return; var img = new Image(); img.src = '/api/assets/' + assets[i].id + '/thumbnail?size=preview'; }
function 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(' '));
$exifInfo.textContent = p.join(' • ');
}
function startProgress() {
if (!config.showProgress) return;
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth;
$progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + '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' });
}
window.toggleOverlay = function () { overlayVisible = !overlayVisible; if (overlayVisible) { $overlay.classList.remove('hidden'); $btnSettings.classList.add('visible'); scheduleOverlayHide(); } else { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); } };
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
document.addEventListener('keydown', function (e) { if (!isRunning) return; switch (e.key) { case 'ArrowRight': case ' ': e.preventDefault(); nextPhoto(); break; case 'ArrowLeft': e.preventDefault(); prevPhoto(); break; case 'Escape': exitSlideshow(); break; case 'f': toggleFullscreen(); break; case 'i': toggleOverlay(); break; } });
function toggleFullscreen() { if (!document.fullscreenElement && !document.webkitFullscreenElement) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } }
function shuffleArray(arr) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } }
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
function formatDate(d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return d.getDate() + ' ' + m[d.getMonth()] + ' ' + d.getFullYear(); }
function 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();
// === 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;}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(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'});}
window.toggleOverlay=function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}};
function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
window.nextPhoto=function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
window.prevPhoto=function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case'ArrowRight':case' ':e.preventDefault();nextPhoto();break;case'ArrowLeft':e.preventDefault();prevPhoto();break;case'Escape':exitSlideshow();break;case'f':toggleFullscreen();break;case'i':toggleOverlay();break;}});
function toggleFullscreen(){if(!document.fullscreenElement&&!document.webkitFullscreenElement){var el=document.documentElement;if(el.requestFullscreen)el.requestFullscreen();else if(el.webkitRequestFullscreen)el.webkitRequestFullscreen();}else{if(document.exitFullscreen)document.exitFullscreen();else if(document.webkitExitFullscreen)document.webkitExitFullscreen();}}
function shuffleArray(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}}
function padZero(n){return n<10?'0'+n:''+n;}
function formatDate(d){var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d.getDate()+' '+m[d.getMonth()]+' '+d.getFullYear();}
function 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();});
init();requestWakeLock();
})();
+132 -118
View File
@@ -1,11 +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.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;
@@ -20,131 +25,140 @@ const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || '';
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';
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;
const imageCache = new Map();
function cacheGet(key) { const e=imageCache.get(key);if(!e)return null;imageCache.delete(key);imageCache.set(key,e);return e; }
function cacheSet(key,value) { if(imageCache.size>=IMAGE_CACHE_MAX){imageCache.delete(imageCache.keys().next().value);}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'),now=Date.now();sessions.set(token,{username,createdAt:now,expiresAt:now+SESSION_TTL});return token; }
function validateSession(token) { if(!token)return false;const s=sessions.get(token);if(!s)return false;if(Date.now()>s.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']||'',hdr=req.headers['x-api-token']||'',qry=req.query.token||'',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;}); }
function mapAsset(a) { return {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 immichHeaders() {
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
const wss = new WebSocketServer({ server });
const clients = new Map();
const adminSockets = new Set();
function getClientList() {
const now = Date.now();
return Array.from(clients.values()).map(c => ({
id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
status: c.status, online: (now - c.lastSeen) < 35000,
config: c.config||{}, source: c.source||null,
}));
}
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
function broadcastAdminClients() {
const msg = JSON.stringify({ type: 'clientList', clients: getClientList() });
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
}
app.get('/api/config', (_req, res) => {
res.json({
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,
connected: !!API_KEY,
function getClientIp(req) {
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
}
wss.on('connection', (ws, req) => {
const id = crypto.randomBytes(8).toString('hex');
const now = Date.now();
const ua = req.headers['user-agent'] || 'unknown';
const ip = getClientIp(req);
let isAdmin = false;
ws.send(JSON.stringify({ type: 'hello', clientId: id }));
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw);
if (msg.type === 'register') {
if (msg.role === 'admin') {
isAdmin = true;
adminSockets.add(ws);
log('WS admin: ' + ip);
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
} else {
clients.set(id, { id, ws, name:'', ip, userAgent:ua, connectedAt:now, firstSeen:now, lastSeen:Date.now(), status:msg.status||'idle', config:msg.config||{}, source:msg.source||null });
log('WS frame: ' + id + ' (' + ip + ')');
broadcastAdminClients();
}
} else if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
} else if (msg.type === 'status') {
const c = clients.get(id);
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=msg.config; if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
} else if (msg.type === 'adminCommand') {
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target && target.ws && target.ws.readyState === WebSocket.OPEN) {
target.ws.send(JSON.stringify({ type:'command', action:msg.action, payload:msg.payload||{} }));
log('Cmd "' + msg.action + '" -> ' + msg.targetId);
} else {
ws.send(JSON.stringify({ type:'error', message:'Client not found or offline' }));
}
} else if (msg.type === 'renameClient') {
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.name = msg.name; broadcastAdminClients(); }
} else if (msg.type === 'removeClient') {
const entry = Array.from(clients.entries()).find(([,c]) => c.id === msg.targetId);
if (entry) { clients.delete(entry[0]); broadcastAdminClients(); }
}
} catch(e) { logErr('WS: ' + e.message); }
});
ws.on('close', () => {
if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
else { const c=clients.get(id); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+id); }
});
});
app.get('/api/server-info', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json({ ok: true, version: await r.json() });
} catch (err) { res.status(502).json({ ok: false, error: err.message }); }
});
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();
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
function mapAsset(a) {
return {
id: a.id, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null,
};
function sendToClient(clientId, payload) {
const c = Array.from(clients.values()).find(x => x.id === clientId);
if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) return false;
c.ws.send(JSON.stringify(payload)); return true;
}
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(`Immich returned ${r.status}`);
const album = await r.json();
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { res.status(502).json({ error: err.message }); }
});
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.post('/api/auth/logout',(req,res)=>{const cookie=req.headers.cookie||'',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/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(''+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: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),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('Albums: '+result.length);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(),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),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)=>{try{const id=req.params.id,ck=id+':'+IMAGE_MAX_WIDTH+':'+IMAGE_QUALITY,hit=cacheGet(ck);if(hit){res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');return res.send(hit);}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(),opt=await sharp(buf).resize({width:IMAGE_MAX_WIDTH,withoutEnlargement:true}).jpeg({quality:IMAGE_QUALITY}).toBuffer();cacheSet(ck,opt);res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');res.send(opt);}catch(e){res.status(502).json({error:e.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.delete('/api/clients/:id',requireApiToken,(req,res)=>{const entry=Array.from(clients.entries()).find(([,c])=>c.id===req.params.id);if(!entry)return res.status(404).json({error:'Not found'});clients.delete(entry[0]);broadcastAdminClients();res.json({ok:true});});
app.get('*',(_req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});
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();
res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
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(`Immich returned ${r.status}`);
const assets = await r.json();
res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
});
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}`);
res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { 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, type: 'IMAGE', size: 250, page: 1 }) });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
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/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.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`);
console.log(`📡 Immich server: ${IMMICH_URL}`);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`);
server.listen(PORT,()=>{
log('--- Frambe v'+VERSION+' ---');
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
});