48 Commits

Author SHA1 Message Date
jessikitty 31c228b172 v1.5.2 - /api/time endpoint + TZ-aware boot log for sleep schedule 2026-06-15 10:35:33 +10:00
jessikitty 8c5a14c818 v1.5.2 - admin: live server clock + UTC warning, /api/time ref 2026-06-15 10:33:39 +10:00
jessikitty 2042648939 v1.5.2 - document TZ variable 2026-06-15 10:31:28 +10:00
jessikitty cd51cb6d69 v1.5.2 - add tzdata for TZ-aware sleep schedule 2026-06-15 10:31:16 +10:00
jessikitty ce6da1d714 v1.5.1 - version bump 2026-06-15 09:29:31 +10:00
jessikitty 9c0e5dafe8 v1.5.1 - refresh env template with volume/persistence note 2026-06-15 09:27:22 +10:00
jessikitty e444a457f5 v1.5.1 - create writable /app/data for settings volume 2026-06-15 09:27:11 +10:00
jessikitty b9389c683d v1.5.1 - secrets moved to .env, named volume for persistent settings 2026-06-15 09:27:02 +10:00
jessikitty 279a04fce9 v1.5.1 - untrack docker-compose.yml (now secret-free, env_file based) 2026-06-15 09:26:55 +10:00
jessikitty 5e1c9b6d42 v1.5.0 - admin: global settings panel, per-client control/sleep override, settings API ref 2026-06-15 08:54:41 +10:00
jessikitty b7f3dd4645 v1.5.0 - client: server-controlled defaults, persistent ID, hello/welcome fix, sleep handling 2026-06-15 08:52:20 +10:00
jessikitty 3cc236c3d9 v1.5.0 - global settings, sleep scheduler, robust client dedup/pruning, server-controlled defaults 2026-06-15 08:50:26 +10:00
jessikitty 69139a868a v1.5.0 - gitignore runtime data/ settings dir 2026-06-15 08:48:22 +10:00
jessikitty da51a4bc18 v1.4.3 - persistent device ID via localStorage so refresh reuses same client slot 2026-06-10 07:08:05 +00:00
jessikitty 10f9683e81 v1.4.3 - persistent device ID via localStorage so refresh reuses same client slot 2026-06-10 07:04:57 +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
12 changed files with 1108 additions and 381 deletions
+20 -1
View File
@@ -1,6 +1,12 @@
# === Frambe Configuration === # === Frambe Configuration ===
# Copy this file to .env and fill in your real values:
# cp .env.example .env
# .env is gitignored and holds your secrets. docker compose reads it automatically.
# REQUIRED # REQUIRED
# Container timezone — the sleep schedule uses this clock. Find yours at
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=Australia/Melbourne
IMMICH_URL=http://your-immich-server:2283 IMMICH_URL=http://your-immich-server:2283
IMMICH_API_KEY=your-api-key-here IMMICH_API_KEY=your-api-key-here
@@ -22,5 +28,18 @@ SHOW_PROGRESS=true
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server (internal port — Docker maps externally via docker-compose) # Admin Authentication (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 to 3030 via docker-compose)
PORT=3000 PORT=3000
# NOTE: Global settings (default source, sleep schedule, etc.) are saved to
# /app/data/settings.json inside the container, backed by the named volume
# `frambe_data` in docker-compose.yml — they persist across rebuilds.
+1
View File
@@ -2,3 +2,4 @@ node_modules/
.env .env
npm-debug.log npm-debug.log
.DS_Store .DS_Store
data/
+10 -1
View File
@@ -3,6 +3,10 @@ FROM node:18-alpine
LABEL maintainer="Frambe" LABEL maintainer="Frambe"
LABEL description="Frambe — lightweight digital photo frame for Immich" LABEL description="Frambe — lightweight digital photo frame for Immich"
# tzdata lets the TZ env var set the container's local time, which the
# sleep scheduler relies on. Without it Alpine defaults to UTC.
RUN apk add --no-cache tzdata
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
@@ -17,7 +21,12 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/config || exit 1 CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup adduser -S appuser -u 1001 -G appgroup && \
mkdir -p /app/data && \
chown -R appuser:appgroup /app/data
VOLUME ["/app/data"]
USER appuser USER appuser
CMD ["node", "server.js"] CMD ["node", "server.js"]
+290 -13
View File
@@ -9,8 +9,11 @@ A lightweight, self-contained Docker web application that connects to your [Immi
## ✨ Features ## ✨ Features
- **Immich API Integration** — Connects securely via API key (kept server-side) - **Immich API Integration** — Connects securely via API key (kept server-side)
- **Album Browser** — Select any album, random photos, or favorites only - **Album Browser** — Select any album (owned or shared), random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition - **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 - **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 - **Auto-Refresh** — Periodically checks for new photos added to the source album/person
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration - **Smooth Crossfade** — Double-buffered image transitions with configurable duration
@@ -25,30 +28,49 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint - **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. Get your Immich API Key
1. Open your Immich web interface 1. Open your Immich web interface
2. Click your profile picture → **Account Settings****API Keys** 2. Click your profile picture → **Account Settings****API Keys**
3. Create a new key with `asset.read` and `album.read` permissions 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 ```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd frambe 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 ```bash
docker compose up -d 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 ```bash
docker build -t frambe . docker build -t frambe .
@@ -61,6 +83,174 @@ docker run -d \
frambe 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 ## 🔗 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: 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. You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
---
## ⚙️ Configuration ## ⚙️ Configuration
All settings are via environment variables: All settings are via environment variables. 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 | | Variable | Default | Description |
|---|---|---| |---|---|---|
@@ -91,11 +283,17 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay | | `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar | | `SHOW_PROGRESS` | `true` | Display progress bar |
| `INCLUDE_VIDEOS` | `true` | Include video assets in slideshow |
| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `ADMIN_USERNAME` | `admin` | Admin dashboard login username |
| `ADMIN_PASSWORD` | *(empty)* | Admin dashboard password (leave empty to disable auth) |
| `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) | | `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
---
## 🎮 Controls ## 🎮 Controls
### Touch / Mouse ### Touch / Mouse
@@ -110,6 +308,8 @@ All settings are via environment variables:
- `I` — Toggle info overlay - `I` — Toggle info overlay
- `Esc` — Exit to album selection - `Esc` — Exit to album selection
---
## 📱 Tablet Setup Tips ## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch) 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
@@ -117,23 +317,100 @@ All settings are via environment variables:
3. Enable kiosk mode or guided access to lock to the app 3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings 4. Disable screen timeout in your device settings
---
## 🏗️ Architecture ## 🏗️ Architecture
``` ```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │ │ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ │ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘ └──────────────┘ :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. The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
## 📋 Version History ---
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow ## 🏷️ Versioning
- **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 Frambe follows [Semantic Versioning](https://semver.org/):
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
- **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 ## 📄 License
+9 -21
View File
@@ -7,25 +7,13 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3030:3000" - "3030:3000"
environment: volumes:
# REQUIRED # Persists global settings (data/settings.json) across rebuilds.
- IMMICH_URL=http://your-immich-server:2283 - frambe_data:/app/data
- IMMICH_API_KEY=your-api-key-here # All configuration & secrets live in .env (see .env.example).
# .env is gitignored — copy .env.example to .env and fill in your values.
env_file:
- .env
# Slideshow volumes:
- SLIDESHOW_INTERVAL=30 frambe_data:
- TRANSITION_DURATION=2
- IMAGE_FIT=contain
- SHUFFLE=true
- BACKGROUND_BLUR=true
- REFRESH_INTERVAL=300 # Seconds between album/person refresh checks
# Overlays
- SHOW_CLOCK=true
- SHOW_DATE=true
- SHOW_EXIF=true
- SHOW_PROGRESS=true
# Auto-start (optional — or use URL params instead)
# - ALBUM_ID=
# - SHOW_FAVORITES_ONLY=false
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "frambe", "name": "frambe",
"version": "1.4.0", "version": "1.4.1",
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard", "description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@@ -10,7 +10,8 @@
"express": "^4.21.0", "express": "^4.21.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"ws": "^8.18.0" "ws": "^8.18.0",
"sharp": "^0.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+295 -86
View File
@@ -5,99 +5,308 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frambe Admin</title> <title>Frambe Admin</title>
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *,*::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; } 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: 0.25rem; } 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,0.1); padding-bottom: 1rem; } .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 img{width:48px;height:48px;border-radius:10px}
.header .version { font-size: 0.8rem; color: #666; } .header .version{font-size:.8rem;color:#666}
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .header-right{margin-left:auto;display:flex;align-items:center;gap:.75rem}
.status-dot.online { background: #4ade80; } .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.status-dot.sleeping { background: #fbbf24; } .dot.online{background:#4ade80} .dot.sleeping{background:#fbbf24} .dot.playing{background:#60a5fa} .dot.offline{background:#555}
.status-dot.playing { background: #60a5fa; } .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:1rem}
.clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 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}
.client-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1.25rem; transition: all 0.2s; } .card:hover{border-color:rgba(99,102,241,.3);background:rgba(255,255,255,.06)}
.client-card:hover { border-color: rgba(99,102,241,0.3); background: rgba(255,255,255,0.06); } .card.offline{opacity:.45}.card.offline:hover{opacity:.65}
.client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.client-name { font-size: 1.1rem; font-weight: 500; display: flex; align-items: center; gap: 0.5rem; } .card-name{font-size:1.1rem;font-weight:500;display:flex;align-items:center;gap:.5rem}
.client-ip { font-size: 0.75rem; color: #888; font-family: monospace; } .card-ip{font-size:.72rem;color:#777;font-family:monospace}
.client-status { font-size: 0.8rem; color: #aaa; text-transform: capitalize; } .card-meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.name-input { background: transparent; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: #fff; font-size: 0.9rem; padding: 4px 8px; width: 140px; } .badge{font-size:.65rem;padding:2px 8px;border-radius:10px;text-transform:uppercase;font-weight:600;letter-spacing:.3px}
.name-input:focus { outline: none; border-color: #6366f1; } .badge.online{background:rgba(74,222,128,.15);color:#86efac} .badge.offline{background:rgba(255,255,255,.06);color:#777}
.controls { display: flex; flex-direction: column; gap: 0.75rem; } .badge.playing{background:rgba(96,165,250,.15);color:#93c5fd} .badge.sleeping{background:rgba(251,191,36,.15);color:#fcd34d}
.control-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .badge.auto{background:rgba(168,85,247,.15);color:#d8b4fe} .badge.manual{background:rgba(148,163,184,.15);color:#cbd5e1}
.control-label { font-size: 0.8rem; color: #888; min-width: 60px; } .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}
.btn { padding: 6px 14px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; transition: all 0.15s; white-space: nowrap; } .name-input:focus{outline:none;border-color:#6366f1}
.btn:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25); } .info-row{font-size:.72rem;color:#666;margin-bottom:.75rem;display:flex;gap:1rem;flex-wrap:wrap}
.btn.danger { background: rgba(239,68,68,0.15); border-color: #ef4444; color: #fca5a5; } .info-row span{white-space:nowrap}
.btn.danger:hover { background: rgba(239,68,68,0.3); } .controls{display:flex;flex-direction:column;gap:.6rem}
.btn.success { background: rgba(34,197,94,0.15); border-color: #22c55e; color: #86efac; } .crow{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.btn.success:hover { background: rgba(34,197,94,0.3); } .clbl{font-size:.78rem;color:#777;min-width:55px}
select { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; max-width: 200px; } .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}
select:focus { outline: none; border-color: #6366f1; } .btn:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)}
option { background: #1a1a2e; color: #e0e0e0; } .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)}
input[type=range] { width: 120px; accent-color: #6366f1; } .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)}
.range-value { font-size: 0.8rem; color: #aaa; min-width: 30px; } .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)}
.toggle { position: relative; width: 40px; height: 22px; cursor: pointer; } .btn.sm{font-size:.68rem;padding:2px 7px}
.toggle input { display: none; } .btn.logout{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.25);color:#fca5a5;font-size:.72rem;padding:3px 10px}
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.1); border-radius: 11px; transition: 0.2s; } 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}
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: #888; border-radius: 50%; transition: 0.2s; } select:focus{outline:none;border-color:#6366f1}
.toggle input:checked + .toggle-slider { background: rgba(99,102,241,0.4); } option{background:#1a1a2e;color:#e0e0e0}
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #a5b4fc; } input[type=range]{width:110px;accent-color:#6366f1}
.empty-state { text-align: center; padding: 4rem 2rem; color: #666; } input[type=time]{padding:4px 8px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem}
.empty-state h2 { font-size: 1.2rem; font-weight: 400; margin-bottom: 0.5rem; color: #888; } input[type=time]:focus{outline:none;border-color:#6366f1}
.ws-status { font-size: 0.75rem; padding: 4px 10px; border-radius: 20px; } .rval{font-size:.78rem;color:#999;min-width:28px}
.ws-status.connected { background: rgba(34,197,94,0.15); color: #86efac; } .tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none}
.ws-status.disconnected { background: rgba(239,68,68,0.15); color: #fca5a5; } .tgl-s{position:absolute;inset:0;background:rgba(255,255,255,.08);border-radius:10px;transition:.2s}
.divider { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 0.5rem 0; } .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}
.srv-clock{font-size:.78rem;font-family:monospace;padding:3px 10px;border-radius:20px;background:rgba(255,255,255,.06);color:#cbd5e1;border:1px solid rgba(255,255,255,.1);white-space:nowrap}
.srv-clock .tz{color:#888;font-size:.68rem;margin-left:5px}
.srv-clock.warn{background:rgba(234,179,8,.12);border-color:rgba(234,179,8,.4);color:#fde68a}
.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}.mtd.put{background:rgba(234,179,8,.18);color:#fde68a}
.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}
/* Global settings panel */
.settings-panel{background:rgba(99,102,241,.05);border:1px solid rgba(99,102,241,.2);border-radius:12px;padding:1.25rem;margin-bottom:1rem}
.settings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.25rem}
.settings-block h3{font-size:.82rem;font-weight:600;color:#a5b4fc;text-transform:uppercase;letter-spacing:.4px;margin-bottom:.6rem}
.srow{display:flex;align-items:center;gap:.6rem;margin-bottom:.55rem;flex-wrap:wrap}
.srow .clbl{min-width:80px}
.save-bar{display:flex;align-items:center;gap:1rem;margin-top:1rem;padding-top:1rem;border-top:1px solid rgba(255,255,255,.06)}
.save-msg{font-size:.78rem;color:#86efac;opacity:0;transition:opacity .3s}.save-msg.show{opacity:1}
.btn.save{background:rgba(99,102,241,.25);border-color:rgba(99,102,241,.5);color:#c7d2fe;font-weight:500;padding:7px 18px}
.btn.save:hover{background:rgba(99,102,241,.4)}
.sleep-fields{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.hint{font-size:.68rem;color:#666;margin-top:.3rem;line-height:1.4}
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'"> <img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<div><h1>Frambe Admin</h1><span class="version" id="version-text">Connecting...</span></div> <div><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
<span class="ws-status disconnected" id="ws-status">Disconnected</span> <div class="header-right">
<span class="srv-clock" id="srv-clock" title="Server local time — sleep schedule uses this clock">--:--</span>
<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>
<div class="clients-grid" id="clients-grid">
<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div> <!-- Global Settings -->
<div class="sec-head" onclick="toggle('gs-sec','gs-arr')" style="margin-top:0;padding-top:0;border-top:none">Global Settings <span class="arr open" id="gs-arr">&#9654;</span></div>
<div id="gs-sec">
<div class="settings-panel">
<div class="settings-grid">
<div class="settings-block">
<h3>Default Photo Source</h3>
<div class="srow"><span class="clbl">Source</span>
<select id="gs-source"><option value="random">Random Photos</option><option value="favorites">Favorites</option></select>
</div>
<div class="hint">New frames (and any frame set to follow the server) start with this source automatically.</div>
</div>
<div class="settings-block">
<h3>Display Defaults</h3>
<div class="srow"><span class="clbl">Interval</span><input type="range" id="gs-interval" min="5" max="600" value="30" oninput="document.getElementById('gs-interval-v').textContent=this.value+'s'"><span class="rval" id="gs-interval-v">30s</span></div>
<div class="srow"><span class="clbl">Clock</span><label class="tgl"><input type="checkbox" id="gs-clock" checked><span class="tgl-s"></span></label>
<span class="clbl">Date</span><label class="tgl"><input type="checkbox" id="gs-date" checked><span class="tgl-s"></span></label>
<span class="clbl">EXIF</span><label class="tgl"><input type="checkbox" id="gs-exif"><span class="tgl-s"></span></label>
<span class="clbl">Bar</span><label class="tgl"><input type="checkbox" id="gs-progress" checked><span class="tgl-s"></span></label></div>
</div>
<div class="settings-block">
<h3>Sleep Schedule</h3>
<div class="srow"><span class="clbl">Enabled</span><label class="tgl"><input type="checkbox" id="gs-sleep-on"><span class="tgl-s"></span></label></div>
<div class="srow sleep-fields"><span class="clbl">Sleep at</span><input type="time" id="gs-sleep-at" value="23:00"><span class="clbl">Wake at</span><input type="time" id="gs-wake-at" value="06:00"></div>
<div class="hint">Frames sleep (black screen) during this window. Crosses midnight automatically. Uses the server's local time (shown top-right).</div>
</div>
</div>
<div class="save-bar">
<button class="btn save" onclick="saveSettings()">Save Global Settings</button>
<span class="save-msg" id="gs-saved">Saved &amp; applied to all server-controlled frames</span>
</div>
</div>
</div> </div>
<script>
var ws=null, clientsData={}, albumsCache=[], peopleCache=[]; <div class="sec-head" onclick="toggle('frames-sec','frames-arr')">Frames <span class="arr open" id="frames-arr">&#9654;</span></div>
function connect() { <div id="frames-sec">
var proto = location.protocol==='https:'?'wss:':'ws:'; <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>
ws = new WebSocket(proto+'//'+location.host+'/ws'); </div>
ws.onopen = function(){ document.getElementById('ws-status').textContent='Connected'; document.getElementById('ws-status').className='ws-status connected'; ws.send(JSON.stringify({type:'register',role:'admin'})); loadAlbumsAndPeople(); };
ws.onmessage = function(e){ var msg=JSON.parse(e.data); if(msg.type==='clientList'){clientsData={};msg.clients.forEach(function(c){clientsData[c.id]=c;});renderClients();} else if(msg.type==='clientUpdate'){clientsData[msg.clientId]=msg.client;renderClients();} }; <div class="sec-head" onclick="toggle('api-sec','api-arr')">REST API Reference <span class="arr" id="api-arr">&#9654;</span></div>
ws.onclose = function(){ document.getElementById('ws-status').textContent='Disconnected'; document.getElementById('ws-status').className='ws-status disconnected'; setTimeout(connect,3000); }; <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 class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/settings</span> &middot; <span class="mtd put">PUT</span><span class="ep">/api/settings</span></h3><p>Read or update global settings (default source, interval, display toggles, sleep schedule). PUT requires the API token.</p><pre id="c-set">curl -s -X PUT -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"sleep":{"enabled":true,"sleepAt":"23:00","wakeAt":"06:00"}}' http://YOUR_HOST:3030/api/settings<span class="cpb" onclick="cc('c-set')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/time</span></h3><p>Server local time and timezone (the clock the sleep schedule uses).</p><pre id="c-time">curl -s http://YOUR_HOST:3030/api/time<span class="cpb" onclick="cc('c-time')">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,settings=null,settingsDirty=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;});if(m.settings)applySettingsToForm(m.settings);render();}else if(m.type==='settings'||m.type==='settingsSaved'){applySettingsToForm(m.settings);if(m.type==='settingsSaved')flashSaved();}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();populateGsSource();if(settings)applySettingsToForm(settings);}catch(e){}}
// === SERVER CLOCK ===
// Sleep schedule is evaluated in the server's local timezone, so show it here.
// Anchor to one /api/time fetch, then tick locally; re-sync periodically.
var srvOffsetMs=null,srvTz='';
async function syncServerTime(){try{var t=await(await fetch('/api/time')).json();srvOffsetMs=t.epoch-Date.now();srvTz=t.tz||('UTC'+(t.offsetMinutes>=0?'+':'')+(t.offsetMinutes/60));}catch(e){}}
function p2(n){return n<10?'0'+n:''+n;}
function tickServerClock(){if(srvOffsetMs===null)return;var d=new Date(Date.now()+srvOffsetMs);var el=document.getElementById('srv-clock');if(!el)return;var warn=(srvTz==='UTC'||srvTz==='Etc/UTC');el.innerHTML=p2(d.getUTCHours())+':'+p2(d.getUTCMinutes())+':'+p2(d.getUTCSeconds())+'<span class="tz">'+esc(srvTz)+(warn?' ⚠':'')+'</span>';el.className='srv-clock'+(warn?' warn':'');el.title=warn?'Server is on UTC — sleep times will be offset from your local time. Set TZ in docker-compose.':'Server local time — sleep schedule uses this clock';}
syncServerTime().then(tickServerClock);setInterval(tickServerClock,1000);setInterval(syncServerTime,300000);
// === GLOBAL SETTINGS FORM ===
function populateGsSource(){
var sel=document.getElementById('gs-source'),cur=sel.value;
var h='<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>';});
sel.innerHTML=h;sel.value=cur;
}
function sourceToValue(s){if(!s)return'random';if(s.source==='album'&&s.albumId)return'album:'+s.albumId;if(s.source==='person'&&s.personId)return'person:'+s.personId;return s.source||'random';}
function valueToSource(v){if(v==='random')return{source:'random',albumId:null,personId:null};if(v==='favorites')return{source:'favorites',albumId:null,personId:null};if(v.indexOf('album:')===0)return{source:'album',albumId:v.substring(6),personId:null};if(v.indexOf('person:')===0)return{source:'person',personId:v.substring(7),albumId:null};return{source:'random',albumId:null,personId:null};}
function applySettingsToForm(s){settings=s;if(!s)return;
var srcVal=sourceToValue(s.source);var sel=document.getElementById('gs-source');
if(!Array.prototype.some.call(sel.options,function(o){return o.value===srcVal;}))populateGsSource();
sel.value=srcVal;
document.getElementById('gs-interval').value=s.slideshowInterval||30;document.getElementById('gs-interval-v').textContent=(s.slideshowInterval||30)+'s';
document.getElementById('gs-clock').checked=s.showClock!==false;
document.getElementById('gs-date').checked=s.showDate!==false;
document.getElementById('gs-exif').checked=s.showExif!==false;
document.getElementById('gs-progress').checked=s.showProgress!==false;
document.getElementById('gs-sleep-on').checked=!!(s.sleep&&s.sleep.enabled);
document.getElementById('gs-sleep-at').value=(s.sleep&&s.sleep.sleepAt)||'23:00';
document.getElementById('gs-wake-at').value=(s.sleep&&s.sleep.wakeAt)||'06:00';
}
function saveSettings(){
var patch={source:valueToSource(document.getElementById('gs-source').value),
slideshowInterval:parseInt(document.getElementById('gs-interval').value,10),
showClock:document.getElementById('gs-clock').checked,
showDate:document.getElementById('gs-date').checked,
showExif:document.getElementById('gs-exif').checked,
showProgress:document.getElementById('gs-progress').checked,
sleep:{enabled:document.getElementById('gs-sleep-on').checked,
sleepAt:document.getElementById('gs-sleep-at').value,
wakeAt:document.getElementById('gs-wake-at').value}};
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'updateSettings',settings:patch}));
}
function flashSaved(){var m=document.getElementById('gs-saved');m.classList.add('show');setTimeout(function(){m.classList.remove('show');},2500);}
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 setControl(id,v){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientControl',targetId:id,serverControlled:v}));}
function setSleep(id){var on=document.getElementById('cs-on-'+id).checked,sa=document.getElementById('cs-at-'+id).value,wa=document.getElementById('cs-wake-'+id).value;if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientSleep',targetId:id,sleep:{override:true,enabled:on,sleepAt:sa,wakeAt:wa}}));}
function clearSleepOverride(id){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientSleep',targetId:id,sleep:{override:false}}));}
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||{};
var auto=c.serverControlled!==false;var csleep=cfg.sleep||{};
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><span class="badge '+(auto?'auto':'manual')+'">'+(auto?'Server':'Manual')+'</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">Control</span><label class="tgl"><input type="checkbox" '+(auto?'checked':'')+' onchange="setControl(\''+id+'\',this.checked)"><span class="tgl-s"></span></label><span class="clbl" style="min-width:auto">'+(auto?'Following server defaults':'Manual override')+'</span></div>';
h+='<hr class="divider">';
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="600" 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"><span class="clbl">Sleep</span><label class="tgl"><input type="checkbox" id="cs-on-'+id+'" '+(csleep.override&&csleep.enabled?'checked':'')+' onchange="setSleep(\''+id+'\')"><span class="tgl-s"></span></label>';
h+='<input type="time" id="cs-at-'+id+'" value="'+(csleep.sleepAt||'23:00')+'" onchange="setSleep(\''+id+'\')"><span class="clbl" style="min-width:auto">to</span><input type="time" id="cs-wake-'+id+'" value="'+(csleep.wakeAt||'06:00')+'" onchange="setSleep(\''+id+'\')"></div>';
h+='<div class="crow"><span class="clbl"></span><span style="font-size:.68rem;color:#666">'+(csleep.override?'Per-frame override active.':'Following global schedule.')+'</span>';
if(csleep.override)h+='<button class="btn sm" onclick="clearSleepOverride(\''+id+'\')">Use global</button>';
h+='</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>';
} }
async function loadAlbumsAndPeople(){ try{ var c=await(await fetch('/api/config')).json(); document.getElementById('version-text').textContent='v'+(c.version||'?'); albumsCache=await(await fetch('/api/albums')).json(); peopleCache=await(await fetch('/api/people')).json(); }catch(e){} } h+='</div>';
function sendCommand(id,action,payload){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:action,payload:payload||{}})); } });
function renameClient(id,name){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'renameClient',targetId:id,name:name})); } g.innerHTML=h;
function handleSourceChange(id,val){ if(!val)return; if(val==='random')sendCommand(id,'setSource',{source:'random'}); else if(val==='favorites')sendCommand(id,'setSource',{source:'favorites'}); else if(val.startsWith('album:'))sendCommand(id,'setSource',{source:'album',albumId:val.substring(6)}); else if(val.startsWith('person:'))sendCommand(id,'setSource',{source:'person',personId:val.substring(7)}); } }
function esc(s){ var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML; } connect();
function renderClients(){ </script>
var grid=document.getElementById('clients-grid'),ids=Object.keys(clientsData);
if(!ids.length){grid.innerHTML='<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div>';return;}
var html='';
ids.forEach(function(id){ var c=clientsData[id],sc=c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online',cfg=c.config||{};
html+='<div class="client-card">';
html+='<div class="client-header"><div><div class="client-name"><span class="status-dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="renameClient(\''+id+'\',this.value)"/></div><div class="client-ip">'+esc(c.ip)+'</div></div><div class="client-status">'+esc(c.status||'connected')+'</div></div>';
html+='<div class="controls">';
html+='<div class="control-row"><span class="control-label">Source</span><select onchange="handleSourceChange(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albumsCache.forEach(function(a){html+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
peopleCache.filter(function(p){return p.name;}).forEach(function(p){html+='<option value="person:'+p.id+'">'+esc(p.name)+' (person)</option>';});
html+='</select></div><hr class="divider">';
html+='<div class="control-row"><span class="control-label">Playback</span><button class="btn success" onclick="sendCommand(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="sendCommand(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="sendCommand(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="sendCommand(\''+id+'\',\'prev\')">Prev</button></div>';
html+='<div class="control-row"><span class="control-label">Power</span><button class="btn danger" onclick="sendCommand(\''+id+'\',\'sleep\')">Sleep</button><button class="btn success" onclick="sendCommand(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="sendCommand(\''+id+'\',\'refresh\')">Refresh</button></div>';
html+='<hr class="divider">';
html+='<div class="control-row"><span class="control-label">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="sendCommand(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="range-value">'+(cfg.slideshowInterval||30)+'s</span></div>';
html+='<div class="control-row"><span class="control-label">Clock</span><label class="toggle"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Date</span><label class="toggle"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='<div class="control-row"><span class="control-label">EXIF</span><label class="toggle"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Progress</span><label class="toggle"><input type="checkbox" '+(cfg.showProgress!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showProgress:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='</div></div>';
});
grid.innerHTML=html;
}
connect();
</script>
</body> </body>
</html> </html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frambe Admin — Login</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 2.5rem; width: 100%; max-width: 380px; }
.login-header { text-align: center; margin-bottom: 2rem; }
.login-header img { width: 56px; height: 56px; border-radius: 12px; margin-bottom: 1rem; }
.login-header h1 { font-size: 1.4rem; font-weight: 300; }
.login-header p { font-size: 0.8rem; color: #666; margin-top: 0.25rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 0.4rem; }
.form-group input { width: 100%; padding: 10px 14px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: #e0e0e0; font-size: 0.95rem; outline: none; transition: border-color 0.15s; }
.form-group input:focus { border-color: #6366f1; }
.login-btn { width: 100%; padding: 11px; background: rgba(99,102,241,0.2); border: 1px solid #6366f1; border-radius: 8px; color: #a5b4fc; font-size: 0.95rem; cursor: pointer; transition: all 0.15s; }
.login-btn:hover { background: rgba(99,102,241,0.35); }
.login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error-msg { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; color: #fca5a5; font-size: 0.85rem; padding: 8px 12px; margin-bottom: 1rem; display: none; }
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<h1>Frambe Admin</h1>
<p>Sign in to manage your frames</p>
</div>
<div class="error-msg" id="error-msg"></div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password">
</div>
<button class="login-btn" id="login-btn" onclick="doLogin()">Sign In</button>
</div>
<script>
document.getElementById('password').addEventListener('keydown', function(e) { if (e.key === 'Enter') doLogin(); });
document.getElementById('username').addEventListener('keydown', function(e) { if (e.key === 'Enter') document.getElementById('password').focus(); });
async function doLogin() {
var btn = document.getElementById('login-btn');
var errEl = document.getElementById('error-msg');
btn.disabled = true;
errEl.style.display = 'none';
var username = document.getElementById('username').value.trim();
var password = document.getElementById('password').value;
if (!username || !password) { errEl.textContent = 'Please enter both username and password'; errEl.style.display = 'block'; btn.disabled = false; return; }
try {
var r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, password: password }) });
var d = await r.json();
if (d.ok) { window.location.href = '/admin'; }
else { errEl.textContent = d.error || 'Login failed'; errEl.style.display = 'block'; }
} catch (e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; }
btn.disabled = false;
}
</script>
</body>
</html>
+49 -137
View File
@@ -1,147 +1,59 @@
/* === Reset === */ * { box-sizing: border-box; margin: 0; padding: 0; }
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } 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: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; }
body.setup-mode { cursor: default; }
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* === SETUP === */ /* === 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-screen { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; } .setup-header { padding: 1.5rem 2rem 1rem; flex-shrink: 0; text-align: center; }
.setup-header { text-align: center; margin-bottom: 2rem; } .setup-logo { width: 64px; height: 64px; margin-bottom: 0.5rem; }
.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; } .setup-header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; }
.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; } .setup-header h1 span { color: #6366f1; }
.subtitle { font-size: 0.95rem; color: #888; } #connection-status { font-size: 0.85rem; color: #666; margin-top: 0.3rem; transition: color 0.3s; }
.subtitle.connected { color: #4ade80; } #connection-status.connected { color: #4ade80; }
.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; } .setup-body { flex: 1; overflow-y: auto; padding: 0 2rem 1rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 0; }
.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; } .section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #555; margin-bottom: 0.6rem; }
.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-buttons { display: flex; gap: 0.5rem; }
.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); } .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; }
.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; } .btn-source:hover { background: rgba(255,255,255,0.08); color: #fff; }
.source-icon { font-size: 1.5rem; } .btn-source.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; color: #fff; }
.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; animation: fadeIn 0.3s ease forwards; } .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:hover { background: rgba(255,255,255,0.08); }
.album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; } .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-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; } .album-info { flex: 1; min-width: 0; }
.album-name { font-size: 1rem; font-weight: 500; } .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-count { font-size: 0.8rem; color: #888; margin-top: 2px; }
.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .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; }
.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); } .setup-error { padding: 0 2rem; }
.start-btn:disabled { opacity: 0.3; cursor: not-allowed; } .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; }
.setup-error { text-align: center; padding: 2rem; } .loading-text { color: #555; font-size: 0.9rem; text-align: center; padding: 2rem; }
.setup-error p { margin-bottom: 0.75rem; } .setup-footer { padding: 1rem 2rem 1.5rem; flex-shrink: 0; }
.setup-error .error-detail { color: #888; font-size: 0.85rem; } .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; }
.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; } .btn-start:hover:not(:disabled) { background: #4f46e5; }
.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; } .btn-start:disabled { opacity: 0.5; cursor: not-allowed; }
@keyframes spin { to { transform: rotate(360deg); } } .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; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* ============================================= /* === SLIDESHOW SCREEN === */
SLIDESHOW - VINTAGE POLAROID PILE .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; }
#slideshow-screen { background: #1e1a14; overflow: hidden; } .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; }
/* Background — near-full sepia */
.bg-blur {
position: absolute; top: -30px; left: -30px;
width: calc(100% + 60px); height: calc(100% + 60px);
background-size: cover; background-position: center;
filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
opacity: 0; transition: opacity 3s ease; z-index: 1;
}
.bg-blur.visible { opacity: 1; } .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; }
/* Canvas pile */ .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; }
#pile-canvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 2; pointer-events: none;
transition: opacity 2s ease;
}
/* Vignette */
.bg-vignette {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%);
z-index: 3; pointer-events: none;
}
/* --- Centering wrapper (flexbox — works on all resolutions) --- */
.main-frame-wrapper {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
z-index: 5; pointer-events: none; overflow: visible;
}
/* --- Main frame — no transform centering, flexbox does it --- */
.main-frame {
opacity: 0;
transition: opacity 1.2s ease;
animation: float 90s linear infinite;
pointer-events: auto;
}
.main-frame.visible { opacity: 1; } .main-frame.visible { opacity: 1; }
#main-photo { display: block; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
/* Slow drift + slight constant rotation */ #main-video { display: none; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
@keyframes float { .touch-zones { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; }
0% { transform: translate(0, 0) rotate(1.5deg); } .touch-zones > div { flex: 1; cursor: pointer; }
100% { transform: translate(8px, -5px) rotate(1.5deg); } .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s; }
}
/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
.main-frame .frame-border {
background: #ede8df;
padding: 1.2vmin 1.2vmin 4vmin 1.2vmin;
box-shadow:
0 6px 40px rgba(0,0,0,0.6),
0 2px 6px rgba(0,0,0,0.3),
inset 0 0 0 1px rgba(0,0,0,0.05);
border-radius: 2px;
position: relative;
}
/* Large main image — allowed to overhang slightly */
.main-frame .frame-media {
display: block;
max-width: 93vw;
max-height: 85vh;
width: auto; height: auto;
object-fit: contain;
background: #2a2520;
}
/* === OVERLAY === */
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; }
.overlay.hidden { opacity: 0; } .overlay.hidden { opacity: 0; }
.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); } .clock { 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; }
.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; } .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; }
.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; } .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; }
.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%); } .progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: rgba(255,255,255,0.1); }
.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-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.4); }
.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; } .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; }
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; } .btn-settings.visible { opacity: 1; }
/* === CONTROLS === */ @keyframes spin { to { transform: rotate(360deg); } }
.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.touch-left { left: 0; width: 20%; } @keyframes floatFrame { 0%, 100% { transform: translateY(0px) rotate(-0.3deg); } 50% { transform: translateY(-8px) rotate(0.3deg); } }
.touch-center { left: 20%; width: 60%; }
.touch-right { right: 0; width: 20%; }
.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
.settings-btn.visible { opacity: 1; pointer-events: auto; }
/* === RESPONSIVE === */
@media (max-width: 600px) {
.setup-container { padding: 1.25rem; }
.setup-header h1 { font-size: 1.6rem; }
.clock { font-size: 1.8rem; }
.overlay-top-right { top: 1rem; right: 1rem; }
.overlay-bottom { padding: 1rem 1rem 0.5rem; }
.source-buttons { flex-direction: column; }
.main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
.main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
}
.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
+45 -39
View File
@@ -6,70 +6,76 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1510"> <meta name="theme-color" content="#0a0a0f">
<title>Frambe</title> <title>Frambe</title>
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<div id="setup-screen" class="screen">
<div class="setup-container"> <!-- Setup Screen -->
<div class="setup-header"> <div id="setup-screen" class="setup-screen">
<img src="/img/icon.png" alt="Frambe" class="setup-logo"> <div class="setup-header">
<h1>Frambe</h1> <img src="/img/icon.png" alt="Frambe" class="setup-logo">
<p class="subtitle" id="connection-status">Connecting to Immich…</p> <h1>Fram<span>be</span></h1>
</div> <p id="connection-status">Connecting to Immich…</p>
<div id="setup-content" class="setup-content"> </div>
<div class="section"> <div id="setup-content" class="setup-body">
<h2>Select Photo Source</h2> <div>
<div class="source-buttons"> <div class="section-title">Photo Source</div>
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button> <div class="source-buttons">
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon"></span><span>Favorites</span></button> <button id="btn-all-photos" class="btn-source" onclick="selectSource('random')">🎲 Random</button>
</div> <button id="btn-favorites" class="btn-source" onclick="selectSource('favorites')">⭐ Favorites</button>
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
</div> </div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div> </div>
<div id="setup-error" class="setup-error" style="display:none"> <div>
<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>⚠️ Cannot connect to Immich</p>
<p class="error-detail" id="error-detail"></p> <p id="error-detail" style="margin-top:0.5rem;font-size:0.85rem;opacity:0.8"></p>
<button onclick="location.reload()">Retry</button> <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> </div>
<div class="setup-footer">
<button id="btn-start" class="btn-start" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div>
</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="bg-blur" class="bg-blur"></div>
<canvas id="pile-canvas"></canvas> <canvas id="pile-canvas"></canvas>
<div class="bg-vignette"></div>
<!-- Flexbox wrapper handles centering; animation lives on inner frame --> <div class="main-frame-wrap">
<div class="main-frame-wrapper">
<div id="main-frame" class="main-frame"> <div id="main-frame" class="main-frame">
<div class="frame-border"> <img id="main-photo" alt="">
<img id="main-photo" class="frame-media" alt=""> <video id="main-video" muted playsinline style="display:none"></video>
<video id="main-video" class="frame-media" muted playsinline style="display:none"></video>
</div>
</div> </div>
</div> </div>
<div id="overlay" class="overlay"> <div id="overlay" class="overlay">
<div class="overlay-top-right"> <div class="clock" id="clock"></div>
<div id="clock" class="clock"></div> <div class="date-display" id="date-display"></div>
<div id="date-display" class="date-display"></div> <div class="exif-info" id="exif-info"></div>
</div> <div class="progress-bar" id="progress-bar">
<div class="overlay-bottom"> <div class="progress-fill" id="progress-fill"></div>
<div id="exif-info" class="exif-info"></div>
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
</div> </div>
</div> </div>
<div class="touch-zone touch-left" onclick="prevPhoto()"></div> <div class="touch-zones">
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div> <div onclick="prevPhoto()"></div>
<div class="touch-zone touch-right" onclick="nextPhoto()"></div> <div onclick="toggleOverlay()"></div>
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button> <div onclick="nextPhoto()"></div>
</div>
<button id="btn-settings" class="btn-settings" onclick="exitSlideshow()"></button>
</div> </div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>
+34 -9
View File
@@ -1,4 +1,4 @@
// === Frambe v1.4.0 - Client with WebSocket Remote Control === // === Frambe v1.5.0 - Client with WebSocket Remote Control + Server-Controlled Defaults ===
(function () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
@@ -6,13 +6,39 @@
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false; var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false, pileCanvas, pileCtx; var currentVideoPlaying = false, pileCanvas, pileCtx;
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df'; var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
var wsConn = null, clientId = null, isSleeping = false; var wsConn = null, clientId = null, isSleeping = false, serverControlled = true;
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'),$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');
// === WEBSOCKET === // === 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 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',persistentId:persistentId,status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig(),source:currentSourceDescriptor()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='hello'||msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}else if(msg.type==='serverConfig'){applyServerConfig(msg.config||{});}}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 sendStatus(s){if(wsConn&&wsConn.readyState===WebSocket.OPEN)wsConn.send(JSON.stringify({type:'status',status:s,currentAlbum:selectedAlbumId,config:getCurrentConfig(),source:currentSourceDescriptor()}));}
function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};} function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};}
function currentSourceDescriptor(){if(!selectedSource)return null;return{source:selectedSource,albumId:selectedAlbumId||null,personId:selectedPersonId||null};}
// === SERVER-CONTROLLED DEFAULTS ===
// The server pushes a resolved config (default source, timers, display toggles, sleep
// schedule) for clients that haven't been manually overridden. Applying it here makes
// a freshly opened frame inherit the global settings without any local setup.
function applyServerConfig(sc){
console.log('[Frambe] Server config received');
// Display + timer defaults
if('slideshowInterval'in sc)config.slideshowInterval=sc.slideshowInterval;
if('showClock'in sc)config.showClock=sc.showClock;
if('showDate'in sc)config.showDate=sc.showDate;
if('showExif'in sc)config.showExif=sc.showExif;
if('showProgress'in sc)config.showProgress=sc.showProgress;
if(isRunning)applyConfigChange({slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress});
// Sleep schedule state — if the server says we are inside the sleep window, honour it.
if(sc.sleep){if(sc.sleep.sleeping&&!isSleeping)goToSleep();else if(sc.sleep.sleeping===false&&isSleeping)wakeUp();}
// Default photo source — only auto-start if nothing is playing yet and no URL/local choice was made.
if(sc.source&&sc.source.source&&!isRunning&&!urlDriven&&!selectedSource){
selectedSource=sc.source.source;selectedAlbumId=sc.source.albumId||null;selectedPersonId=sc.source.personId||null;
console.log('[Frambe] Auto-start from server default: '+selectedSource);
if(!(sc.sleep&&sc.sleep.sleeping))doStartSlideshow();
}
}
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 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 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 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');}
@@ -24,7 +50,7 @@
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 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);}} 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;} function showError(msg){$setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg;}
async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" onclick="selectAlbum(\''+a.id+'\', this)">';html+=thu?'<img class="album-thumb" src="'+thu+'" alt="" loading="lazy">':'<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">📁</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';}} async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" data-shared="'+(a.shared?'true':'false')+'" onclick="selectAlbum(\''+a.id+'\', this)">';html+=thu?'<img class="album-thumb" src="'+thu+'" alt="" loading="lazy">':'<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">'+(a.shared?'🔗':'📁')+'</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+(a.shared?' <span class="album-shared-badge">Shared</span>':'')+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';}}
window.selectSource=function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i<items.length;i++)items[i].classList.remove('selected');$btnStart.disabled=false;}; window.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;}; 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');} 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');}
@@ -33,10 +59,10 @@
// === CANVAS PILE === // === 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 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 clearPileCanvas(){if(pileCtx){pileCtx.setTransform(1,0,0,1,0,0);pileCtx.clearRect(0,0,pileCanvas.width,pileCanvas.height);pileCtx.scale(window.devicePixelRatio||1,window.devicePixelRatio||1);}}
function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.fillStyle='rgba(150,120,70,0.2)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;} 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;}
// === SLIDESHOW === // === 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);}} 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.startSlideshow=function(){doStartSlideshow();};
window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');}; 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 showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);}
@@ -59,6 +85,5 @@
function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;} function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}} async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();}); document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
function preventSleep(){try{var v=document.createElement('video');v.setAttribute('playsinline','');v.setAttribute('muted','');v.setAttribute('loop','');v.style.cssText='position:absolute;width:1px;height:1px;opacity:0.01';v.src='data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';document.body.appendChild(v);v.play().catch(function(){});}catch(e){}} init();requestWakeLock();
init();requestWakeLock();preventSleep();
})(); })();
+287 -72
View File
@@ -2,10 +2,13 @@ const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
const fs = require('fs');
const crypto = require('crypto');
const sharp = require('sharp');
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.4.0'; const VERSION = '1.5.2';
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -24,84 +27,296 @@ const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300; const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false'; 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' }; } // === GLOBAL SETTINGS (persisted to disk) ===
function log(msg) { console.log('[Frambe] ' + msg); } const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); } const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
const DEFAULT_SETTINGS = {
source: { source: 'random', albumId: null, personId: null },
slideshowInterval: SLIDESHOW_INTERVAL,
showClock: SHOW_CLOCK,
showDate: SHOW_DATE,
showExif: SHOW_EXIF,
showProgress: SHOW_PROGRESS,
sleep: { enabled: false, sleepAt: '23:00', wakeAt: '06:00' },
};
let globalSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
function loadSettings() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
globalSettings = Object.assign(JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), raw);
globalSettings.source = Object.assign({}, DEFAULT_SETTINGS.source, raw.source || {});
globalSettings.sleep = Object.assign({}, DEFAULT_SETTINGS.sleep, raw.sleep || {});
log('Settings loaded from ' + SETTINGS_FILE);
} else { log('No settings file; using defaults'); }
} catch(e) { logErr('Settings load: ' + e.message); }
}
function saveSettings() {
try { if(!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR,{recursive:true}); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(globalSettings,null,2)); }
catch(e) { logErr('Settings save: ' + e.message); }
}
loadSettings();
// === TIME HELPERS FOR SLEEP SCHEDULE ===
function parseHHMM(s) { if(!/^\d{1,2}:\d{2}$/.test(s||''))return null; const parts=s.split(':'),h=Number(parts[0]),m=Number(parts[1]); if(h>23||m>59)return null; return h*60+m; }
function inSleepWindow(sleepAt, wakeAt, nowMin) {
const s = parseHHMM(sleepAt), w = parseHHMM(wakeAt);
if (s === null || w === null) return false;
if (s === w) return false;
if (s < w) return nowMin >= s && nowMin < w;
return nowMin >= s || nowMin < w;
}
function nowMinuteOfDay() { const d=new Date(); return d.getHours()*60+d.getMinutes(); }
function effectiveSleep(client) {
const cs = client && client.config && client.config.sleep;
if (cs && cs.override) return { enabled: !!cs.enabled, sleepAt: cs.sleepAt||globalSettings.sleep.sleepAt, wakeAt: cs.wakeAt||globalSettings.sleep.wakeAt };
return globalSettings.sleep;
}
const wss = new WebSocketServer({ server });
const clients = new Map(); const clients = new Map();
let clientNameStore = {}; const adminSockets = new Set();
function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; } const CLIENT_TTL = parseInt(process.env.CLIENT_TTL, 10) || (7 * 24 * 60 * 60);
function generateClientId(ip) { return ip.replace(/[.:]/g, '_'); } const ONLINE_THRESHOLD = 35000;
function broadcastToAdmins(msg) { const d = JSON.stringify(msg); clients.forEach(c => { if (c.role === 'admin' && c.ws.readyState === WebSocket.OPEN) c.ws.send(d); }); }
function getClientList() { const list = []; clients.forEach((c, id) => { if (c.role === 'frame') list.push({ id, ip: c.ip, name: c.name || clientNameStore[c.ip] || '', status: c.status || 'unknown', connectedAt: c.connectedAt, lastSeen: c.lastSeen, config: c.config || {} }); }); return list; }
const wss = new WebSocketServer({ server, path: '/ws' }); // === CLIENT FINGERPRINTING & DEDUP ===
wss.on('connection', (ws, req) => { function fingerprint(pid, ip, ua) {
const ip = getClientIp(req); if (pid) return 'pid:' + pid;
const clientId = generateClientId(ip) + '_' + Date.now(); return 'sig:' + crypto.createHash('sha1').update((ip||'')+'|'+(ua||'')).digest('hex').slice(0, 16);
log('WebSocket connected: ' + ip + ' (' + clientId + ')'); }
const info = { ws, ip, role: 'frame', name: clientNameStore[ip] || '', status: 'connected', connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), config: {} }; function findExisting(pid, ip, ua) {
clients.set(clientId, info); const fp = fingerprint(pid, ip, ua);
ws.send(JSON.stringify({ type: 'welcome', clientId, name: info.name })); for (const c of clients.values()) { if (c.fingerprint === fp) return c; }
ws.on('message', raw => { if (pid) { const sig = fingerprint(null, ip, ua); for (const c of clients.values()) { if (c.fingerprint === sig) return c; } }
try { return null;
const msg = JSON.parse(raw); info.lastSeen = new Date().toISOString(); }
switch (msg.type) {
case 'register': function getClientList() {
info.role = msg.role || 'frame'; const now = Date.now();
if (msg.role === 'admin') { log('Admin connected from ' + ip); ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() })); } return Array.from(clients.values()).map(c => ({
else { log('Frame registered: ' + ip); info.status = msg.status || 'idle'; info.config = msg.config || {}; broadcastToAdmins({ type: 'clientList', clients: getClientList() }); } id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
break; fingerprint: c.fingerprint, persistentId: c.persistentId || null,
case 'status': connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
info.status = msg.status || info.status; if (msg.config) info.config = msg.config; status: c.status, online: (now - c.lastSeen) < ONLINE_THRESHOLD,
broadcastToAdmins({ type: 'clientUpdate', clientId, client: { id: clientId, ip: info.ip, name: info.name, status: info.status, lastSeen: info.lastSeen, config: info.config } }); config: c.config||{}, source: c.source||null, serverControlled: c.serverControlled !== false,
break; }));
case 'adminCommand': }
const target = clients.get(msg.targetId);
if (target && target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'command', action: msg.action, payload: msg.payload })); log('Command ' + msg.action + ' -> ' + msg.targetId); } function broadcastAdminClients() {
else ws.send(JSON.stringify({ type: 'error', message: 'Client not found' })); const msg = JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings });
break; adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
case 'renameClient': }
const rt = clients.get(msg.targetId);
if (rt) { rt.name = msg.name; clientNameStore[rt.ip] = msg.name; log('Renamed ' + msg.targetId + ' -> "' + msg.name + '"'); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); } function getClientIp(req) {
break; return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
} }
} catch (e) { logErr('WS parse error: ' + e.message); }
function resolvedConfigFor(client) {
const g = globalSettings;
const sleep = effectiveSleep(client);
return {
source: g.source,
slideshowInterval: g.slideshowInterval,
showClock: g.showClock, showDate: g.showDate, showExif: g.showExif, showProgress: g.showProgress,
sleep: { enabled: sleep.enabled, sleepAt: sleep.sleepAt, wakeAt: sleep.wakeAt,
sleeping: sleep.enabled && inSleepWindow(sleep.sleepAt, sleep.wakeAt, nowMinuteOfDay()) },
};
}
function pushServerConfig(client) {
if (!client || !client.ws || client.ws.readyState !== WebSocket.OPEN) return;
if (client.serverControlled === false) return;
try { client.ws.send(JSON.stringify({ type: 'serverConfig', config: resolvedConfigFor(client) })); } catch(e) {}
}
function pushServerConfigToAll() { clients.forEach(c => pushServerConfig(c)); }
// === SLEEP SCHEDULE TICK ===
let lastTickMinute = -1;
setInterval(() => {
const m = nowMinuteOfDay();
if (m === lastTickMinute) return;
lastTickMinute = m;
clients.forEach(c => {
if (!c.ws || c.ws.readyState !== WebSocket.OPEN) return;
const sleep = effectiveSleep(c);
if (!sleep.enabled) return;
const shouldSleep = inSleepWindow(sleep.sleepAt, sleep.wakeAt, m);
if (shouldSleep && c.status !== 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'sleep',payload:{scheduled:true}})); } catch(e){} log('Schedule: sleep -> '+c.id); }
else if (!shouldSleep && c.status === 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'wake',payload:{scheduled:true}})); } catch(e){} log('Schedule: wake -> '+c.id); }
});
}, 20000);
// === DEAD CLIENT PRUNING ===
setInterval(() => {
const now = Date.now(); let pruned = 0;
for (const [k,c] of clients.entries()) {
const offline = !c.ws || c.ws.readyState !== WebSocket.OPEN;
if (offline && (now - c.lastSeen) > CLIENT_TTL * 1000) { clients.delete(k); pruned++; }
}
if (pruned) { log('Pruned '+pruned+' dead client(s)'); broadcastAdminClients(); }
}, 60 * 60 * 1000);
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;
let boundId = id;
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(), settings: globalSettings }));
} else {
const pid = msg.persistentId || null;
const fp = fingerprint(pid, ip, ua);
const existing = findExisting(pid, ip, ua);
const effectiveId = existing ? existing.id : id;
const firstName = existing ? existing.name : '';
const firstSeen = existing ? existing.firstSeen : now;
const priorConfig = existing ? (existing.config||{}) : {};
const priorSource = existing ? existing.source : null;
const sc = existing ? (existing.serverControlled !== false) : true;
for (const [k,c] of Array.from(clients.entries())) { if (c.id === effectiveId || c.fingerprint === fp) clients.delete(k); }
boundId = effectiveId;
clients.set(effectiveId, {
id: effectiveId, persistentId: pid, fingerprint: fp, ws,
name: firstName, ip, userAgent: ua,
connectedAt: now, firstSeen, lastSeen: Date.now(),
status: msg.status||'idle',
config: Object.assign({}, priorConfig, msg.config||{}),
source: msg.source!==undefined ? msg.source : priorSource,
serverControlled: sc,
});
log('WS frame: ' + effectiveId + (pid?' [pid]':' [sig]') + ' (' + ip + ')');
pushServerConfig(clients.get(effectiveId));
broadcastAdminClients();
}
} else if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
} else if (msg.type === 'status') {
const c = clients.get(boundId);
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=Object.assign({},c.config,msg.config); if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
} else if (msg.type === 'adminCommand') {
const target = clients.get(msg.targetId) || 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 = clients.get(msg.targetId) || 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) { try { if(entry[1].ws&&entry[1].ws.readyState===WebSocket.OPEN) entry[1].ws.close(); } catch(e){} clients.delete(entry[0]); broadcastAdminClients(); }
} else if (msg.type === 'setClientControl') {
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.serverControlled = !!msg.serverControlled; if(target.serverControlled) pushServerConfig(target); broadcastAdminClients(); }
} else if (msg.type === 'setClientSleep') {
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.config = Object.assign({}, target.config, { sleep: msg.sleep||{} }); pushServerConfig(target); broadcastAdminClients(); }
} else if (msg.type === 'updateSettings') {
applySettings(msg.settings || {});
ws.send(JSON.stringify({ type:'settingsSaved', settings: globalSettings }));
pushServerConfigToAll();
broadcastAdminClients();
log('Global settings updated');
} else if (msg.type === 'getSettings') {
ws.send(JSON.stringify({ type:'settings', settings: globalSettings }));
}
} catch(e) { logErr('WS: ' + e.message); }
});
ws.on('close', () => {
if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
else { const c=clients.get(boundId); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+boundId); }
}); });
ws.on('close', () => { log('WebSocket disconnected: ' + ip); clients.delete(clientId); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); });
}); });
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); }); function applySettings(patch) {
app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, fp) => { if (fp.endsWith('.html') || fp.endsWith('.js') || fp.endsWith('.css')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } } })); if (patch.source) globalSettings.source = Object.assign({}, globalSettings.source, patch.source);
if ('slideshowInterval' in patch) globalSettings.slideshowInterval = parseInt(patch.slideshowInterval,10)||globalSettings.slideshowInterval;
['showClock','showDate','showExif','showProgress'].forEach(k => { if (k in patch) globalSettings[k] = !!patch[k]; });
if (patch.sleep) globalSettings.sleep = Object.assign({}, globalSettings.sleep, {
enabled: 'enabled' in patch.sleep ? !!patch.sleep.enabled : globalSettings.sleep.enabled,
sleepAt: patch.sleep.sleepAt || globalSettings.sleep.sleepAt,
wakeAt: patch.sleep.wakeAt || globalSettings.sleep.wakeAt,
});
saveSettings();
}
function sendToClient(clientId, payload) {
const c = clients.get(clientId) || 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.use(express.json()); 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/settings',(_req,res)=>{res.json({ok:true,settings:globalSettings});});
app.get('/api/time',(_req,res)=>{const d=new Date();let tz='';try{tz=Intl.DateTimeFormat().resolvedOptions().timeZone||'';}catch(e){}res.json({ok:true,iso:d.toISOString(),epoch:d.getTime(),hours:d.getHours(),minutes:d.getMinutes(),minuteOfDay:d.getHours()*60+d.getMinutes(),tz:tz,offsetMinutes:-d.getTimezoneOffset()});});
app.put('/api/settings',requireApiToken,(req,res)=>{applySettings(req.body||{});pushServerConfigToAll();broadcastAdminClients();res.json({ok:true,settings:globalSettings});});
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'));});
function mapAsset(a) { return { id: a.id, type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null }; } server.listen(PORT,()=>{
function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); } log('--- Frambe v'+VERSION+' ---');
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
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 }); }); log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const v = await r.json(); log('Immich OK v' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); } catch (e) { logErr('Immich failed: ' + e.message); res.status(502).json({ ok: false, error: e.message }); } }); log('Server time: '+new Date().toString());
app.get('/api/albums', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } }); log('Global sleep schedule: '+(globalSettings.sleep.enabled?(globalSettings.sleep.sleepAt+' -> '+globalSettings.sleep.wakeAt):'disabled'));
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); log('Album "' + al.albumName + '": ' + a.length + ' assets'); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { logErr('Album: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${c}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`${r.status}`); const d = await r.json(); res.json(filterAssets(d.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }))); } 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/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('/admin', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); });
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
server.listen(PORT, '0.0.0.0', () => {
log('--- Frambe v' + VERSION + ' ---');
log('Server listening on port ' + PORT);
log('Admin dashboard: http://0.0.0.0:' + PORT + '/admin');
log('WebSocket: ws://0.0.0.0:' + PORT + '/ws');
log('Immich URL: ' + IMMICH_URL);
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
log('Waiting for connections...');
}); });