Compare commits
53 Commits
v1.3.0-dev
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 31c228b172 | |||
| 8c5a14c818 | |||
| 2042648939 | |||
| cd51cb6d69 | |||
| ce6da1d714 | |||
| 9c0e5dafe8 | |||
| e444a457f5 | |||
| b9389c683d | |||
| 279a04fce9 | |||
| 5e1c9b6d42 | |||
| b7f3dd4645 | |||
| 3cc236c3d9 | |||
| 69139a868a | |||
| da51a4bc18 | |||
| 10f9683e81 | |||
| 581f6ee87b | |||
| c2fd34f04c | |||
| c85b75996e | |||
| b8516630ab | |||
| e1488d2f5e | |||
| 4bed4aa6da | |||
| e7344a0e4f | |||
| 1c5bbbaf91 | |||
| d871cb8145 | |||
| 6fef7b75cf | |||
| daa2bb2f96 | |||
| 37c13d3ff4 | |||
| 7d680f068f | |||
| 914dd32ac9 | |||
| 7e54ba4923 | |||
| c067ff237e | |||
| 243396c93a | |||
| 886cd3b016 | |||
| 0e2ec3eb37 | |||
| 6f85791302 | |||
| 0c279ad00a | |||
| 39aff8c839 | |||
| 213a7bd737 | |||
| e770332032 | |||
| 1d209b5daa | |||
| 249162fabb | |||
| 79d9cae986 | |||
| b8ea8eb150 | |||
| 402b6a0def | |||
| 355f26eaf1 | |||
| a269456f41 | |||
| ec79ac4752 | |||
| 83b8b78f0c | |||
| cc36a6fed4 | |||
| e2be524b2a | |||
| 4b9db2af5a | |||
| 1863da1a28 | |||
| 5a6536c6ca |
+20
-1
@@ -1,6 +1,12 @@
|
||||
# === 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
|
||||
# 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_API_KEY=your-api-key-here
|
||||
|
||||
@@ -22,5 +28,18 @@ SHOW_PROGRESS=true
|
||||
# ALBUM_ID=
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
.env
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
data/
|
||||
|
||||
+10
-1
@@ -3,6 +3,10 @@ FROM node:18-alpine
|
||||
LABEL maintainer="Frambe"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -9,8 +9,11 @@ A lightweight, self-contained Docker web application that connects to your [Immi
|
||||
## ✨ Features
|
||||
|
||||
- **Immich API Integration** — Connects securely via API key (kept server-side)
|
||||
- **Album Browser** — Select any album, random photos, or favorites only
|
||||
- **Album Browser** — Select any album (owned or shared), random photos, or favorites only
|
||||
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
|
||||
- **Admin Dashboard** — Real-time WebSocket-based control panel for all connected frames
|
||||
- **Admin Authentication** — Optional username/password login to protect the admin dashboard
|
||||
- **REST API with Token Auth** — Control frames from Home Assistant, scripts, or external tools
|
||||
- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters
|
||||
- **Auto-Refresh** — Periodically checks for new photos added to the source album/person
|
||||
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration
|
||||
@@ -25,30 +28,49 @@ A lightweight, self-contained Docker web application that connects to your [Immi
|
||||
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
|
||||
- **Docker Containerised** — Single container, minimal footprint
|
||||
|
||||
## 🚀 Quick Start
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A running [Immich](https://immich.app/) server
|
||||
- An Immich API key (see below)
|
||||
- Docker and Docker Compose (recommended) — or Node.js 18+ for running from source
|
||||
|
||||
### 1. Get your Immich API Key
|
||||
|
||||
1. Open your Immich web interface
|
||||
2. Click your profile picture → **Account Settings** → **API Keys**
|
||||
3. Create a new key with `asset.read` and `album.read` permissions
|
||||
4. Copy the key — you'll need it for the next step
|
||||
|
||||
### 2. Run with Docker Compose
|
||||
### 2. Deploy with Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
|
||||
cd frambe
|
||||
```
|
||||
|
||||
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
|
||||
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- IMMICH_URL=http://your-immich-server:2283
|
||||
- IMMICH_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
Then start the container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://your-server:3030` in a browser on your tablet/screen.
|
||||
Frambe is now running at `http://your-server:3030`.
|
||||
|
||||
### 3. Run with Docker directly
|
||||
### 3. Deploy with Docker Run
|
||||
|
||||
If you prefer not to use Compose:
|
||||
|
||||
```bash
|
||||
docker build -t frambe .
|
||||
@@ -61,6 +83,174 @@ docker run -d \
|
||||
frambe
|
||||
```
|
||||
|
||||
### 4. Run from Source (development)
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
|
||||
cd frambe
|
||||
npm install
|
||||
```
|
||||
|
||||
Create a `.env` file from the example:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your Immich URL and API key, then start the server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Frambe will be available at `http://localhost:3000`.
|
||||
|
||||
### Port Mapping
|
||||
|
||||
The internal server runs on port **3000**. The Docker Compose config maps this to external port **3030** by default. You can change this in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:3000" # Access Frambe on port 8080 instead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Upgrading
|
||||
|
||||
### Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
cd frambe
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your configuration in `docker-compose.yml` is preserved — only the application code is rebuilt.
|
||||
|
||||
### Docker Run
|
||||
|
||||
```bash
|
||||
cd frambe
|
||||
git pull
|
||||
docker stop frambe
|
||||
docker rm frambe
|
||||
docker build -t frambe .
|
||||
docker run -d \
|
||||
--name frambe \
|
||||
-p 3030:3000 \
|
||||
-e IMMICH_URL=http://your-immich-server:2283 \
|
||||
-e IMMICH_API_KEY=your-api-key \
|
||||
--restart unless-stopped \
|
||||
frambe
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cd frambe
|
||||
git pull
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
### Switching to a Specific Version
|
||||
|
||||
Frambe uses git tags for releases. To pin to a specific version:
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v1.4.1 # Replace with desired version
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
To switch back to the latest:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- **All upgrades are non-destructive** — Frambe stores no persistent data on disk. All configuration is via environment variables.
|
||||
- **No database migrations** — there is no database. Session tokens are in-memory and will reset on restart (users simply log in again).
|
||||
- **Check the changelog below** before upgrading major versions for any new required environment variables.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Authentication
|
||||
|
||||
### Admin Dashboard Login
|
||||
|
||||
Protect the admin dashboard with a username and password:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
When `ADMIN_PASSWORD` is set, accessing `/admin` requires signing in. When not set, the dashboard is open (useful for trusted local networks).
|
||||
|
||||
### API Token for External Access
|
||||
|
||||
Enable token-authenticated REST API access for Home Assistant, scripts, or other external tools:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- FRAMBE_API_TOKEN=your-secret-token-here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 REST API
|
||||
|
||||
When `FRAMBE_API_TOKEN` is configured, the following endpoints are available:
|
||||
|
||||
### List Connected Frames
|
||||
|
||||
```
|
||||
GET /api/clients
|
||||
Authorization: Bearer your-secret-token-here
|
||||
```
|
||||
|
||||
Returns all connected frame clients with their status, IP, name, and config.
|
||||
|
||||
### Send Command to a Frame
|
||||
|
||||
```
|
||||
POST /api/clients/:id/command
|
||||
Authorization: Bearer your-secret-token-here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "next",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Available actions: `start`, `stop`, `next`, `prev`, `sleep`, `wake`, `refresh`, `setSource`, `setConfig`
|
||||
|
||||
### Home Assistant Example
|
||||
|
||||
```yaml
|
||||
rest_command:
|
||||
frambe_next_photo:
|
||||
url: "http://frambe-server:3030/api/clients/{{ client_id }}/command"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer your-secret-token-here"
|
||||
Content-Type: "application/json"
|
||||
payload: '{"action": "next"}'
|
||||
```
|
||||
|
||||
Authentication can also be provided via `x-api-token` header or `?token=` query parameter.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Zero-Touch URL Parameters
|
||||
|
||||
Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet:
|
||||
@@ -74,9 +264,11 @@ Skip the setup screen entirely by passing query parameters. This is ideal for de
|
||||
|
||||
You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
All settings are via environment variables:
|
||||
All settings are via environment variables. Set them in `docker-compose.yml`, pass with `docker run -e`, or put them in a `.env` file when running from source.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
@@ -91,11 +283,17 @@ All settings are via environment variables:
|
||||
| `SHOW_DATE` | `true` | Display date overlay |
|
||||
| `SHOW_EXIF` | `true` | Display photo metadata |
|
||||
| `SHOW_PROGRESS` | `true` | Display progress bar |
|
||||
| `INCLUDE_VIDEOS` | `true` | Include video assets in slideshow |
|
||||
| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
|
||||
| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
|
||||
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
|
||||
| `ADMIN_USERNAME` | `admin` | Admin dashboard login username |
|
||||
| `ADMIN_PASSWORD` | *(empty)* | Admin dashboard password (leave empty to disable auth) |
|
||||
| `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) |
|
||||
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Controls
|
||||
|
||||
### Touch / Mouse
|
||||
@@ -110,6 +308,8 @@ All settings are via environment variables:
|
||||
- `I` — Toggle info overlay
|
||||
- `Esc` — Exit to album selection
|
||||
|
||||
---
|
||||
|
||||
## 📱 Tablet Setup Tips
|
||||
|
||||
1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
|
||||
@@ -117,23 +317,100 @@ All settings are via environment variables:
|
||||
3. Enable kiosk mode or guided access to lock to the app
|
||||
4. Disable screen timeout in your device settings
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Browser │ HTTP │ Frambe │ API │ Immich │
|
||||
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
|
||||
│ (Tablet) │◄────────►│ (Node.js) │◄────────►│ Server │
|
||||
└──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
|
||||
▲
|
||||
┌─────────┴─────────┐
|
||||
│ REST API / WS │
|
||||
│ (Home Assistant) │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
|
||||
|
||||
## 📋 Version History
|
||||
---
|
||||
|
||||
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
|
||||
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
|
||||
- **1.1.0** — Rebrand to Frambe
|
||||
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
|
||||
## 🏷️ Versioning
|
||||
|
||||
Frambe follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **Major** (`X.0.0`) — Large feature overhauls, breaking changes, or major UI redesigns
|
||||
- **Minor** (`0.X.0`) — New features, functionality additions, or significant improvements
|
||||
- **Patch** (`0.0.X`) — Bug fixes, small tweaks, and minor corrections
|
||||
|
||||
### Branches
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `main` | Stable releases — production-ready code |
|
||||
| `dev` | Development — latest features, may be unstable |
|
||||
|
||||
### Changelog
|
||||
|
||||
#### v1.4.1 — Shared Albums
|
||||
- ✅ Album picker now shows **shared albums** alongside owned albums
|
||||
- ✅ Shared albums are visually marked with a "Shared" badge in the picker
|
||||
- ✅ Shared album icons use 🔗 to distinguish from owned 📁 albums
|
||||
- ✅ Deduplication ensures albums shared with yourself don't appear twice
|
||||
|
||||
#### v1.4.0 — Admin Auth & REST API
|
||||
- ✅ Admin dashboard login with username/password authentication (env-based)
|
||||
- ✅ Session management with HttpOnly cookies (24-hour expiry, automatic cleanup)
|
||||
- ✅ API token authentication for external access (Home Assistant, scripts, curl)
|
||||
- ✅ REST endpoint: `GET /api/clients` — list all connected frames
|
||||
- ✅ REST endpoint: `POST /api/clients/:id/command` — send commands to frames
|
||||
- ✅ Multiple auth methods: Bearer token, `x-api-token` header, `?token=` query param
|
||||
- ✅ Auth status endpoint: `GET /api/auth/status`
|
||||
- ✅ Backwards compatible — auth is opt-in, disabled by default
|
||||
- 🆕 New env vars: `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `FRAMBE_API_TOKEN`
|
||||
|
||||
#### v1.3.0 — Admin Dashboard & Video Support
|
||||
- ✅ Real-time admin dashboard at `/admin` with WebSocket communication
|
||||
- ✅ Live frame management: start, stop, next, prev, sleep, wake, refresh
|
||||
- ✅ Remote source switching: change album, person, random, or favorites per frame
|
||||
- ✅ Remote config: adjust slideshow interval, toggle clock/date/EXIF/progress per frame
|
||||
- ✅ Frame naming and rename support (persists by IP)
|
||||
- ✅ Video playback support in slideshow (with `INCLUDE_VIDEOS` toggle)
|
||||
- ✅ Person / face recognition photo source via Immich's people API
|
||||
- ✅ Connection status indicators and auto-reconnect
|
||||
|
||||
#### v1.2.1 — Bug Fixes
|
||||
- 🐛 Fixed port mapping (3030:3000 external:internal)
|
||||
- 🐛 Fixed URL parameter auto-launch not starting the slideshow
|
||||
|
||||
#### v1.2.0 — Zero-Touch Launch & Auto-Refresh
|
||||
- ✅ URL query parameters for zero-touch launch (`?album=`, `?person=`, `?favorites`, `?random`)
|
||||
- ✅ Person / face support — display photos of a specific person
|
||||
- ✅ Periodic auto-refresh — new photos appear without restarting
|
||||
- ✅ App icon for home screen bookmarks
|
||||
- ✅ Default external port changed to 3030
|
||||
- 🆕 New env var: `REFRESH_INTERVAL`
|
||||
|
||||
#### v1.1.0 — Rebrand
|
||||
- ✅ Rebranded to Frambe
|
||||
|
||||
#### v1.0.0 — Initial Release
|
||||
- ✅ Album browser with Immich API integration
|
||||
- ✅ Full-screen slideshow with smooth crossfade transitions
|
||||
- ✅ Double-buffered image loading for seamless display
|
||||
- ✅ Background blur behind non-covering images
|
||||
- ✅ Clock, date, and EXIF metadata overlays
|
||||
- ✅ Progress bar showing time until next photo
|
||||
- ✅ Touch controls (left/centre/right tap zones)
|
||||
- ✅ Keyboard controls (arrows, space, F, I, Esc)
|
||||
- ✅ Screen wake lock to prevent display sleep
|
||||
- ✅ Configurable via environment variables
|
||||
- ✅ Docker containerised deployment
|
||||
- ✅ Vanilla HTML/CSS/JS frontend — no frameworks, works on older devices
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
+9
-21
@@ -7,25 +7,13 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3030:3000"
|
||||
environment:
|
||||
# REQUIRED
|
||||
- IMMICH_URL=http://your-immich-server:2283
|
||||
- IMMICH_API_KEY=your-api-key-here
|
||||
volumes:
|
||||
# Persists global settings (data/settings.json) across rebuilds.
|
||||
- frambe_data:/app/data
|
||||
# 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
|
||||
- SLIDESHOW_INTERVAL=30
|
||||
- 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
|
||||
volumes:
|
||||
frambe_data:
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frambe",
|
||||
"version": "1.3.0",
|
||||
"description": "Frambe — a lightweight digital photo frame web app for Immich",
|
||||
"version": "1.4.1",
|
||||
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
@@ -9,7 +9,9 @@
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"ws": "^8.18.0",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Frambe Admin</title>
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0f0f1a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:1.5rem;min-height:100vh}
|
||||
h1{font-size:1.6rem;font-weight:300;margin-bottom:.25rem}
|
||||
.header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;border-bottom:1px solid rgba(255,255,255,.1);padding-bottom:1rem}
|
||||
.header img{width:48px;height:48px;border-radius:10px}
|
||||
.header .version{font-size:.8rem;color:#666}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:.75rem}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
.dot.online{background:#4ade80} .dot.sleeping{background:#fbbf24} .dot.playing{background:#60a5fa} .dot.offline{background:#555}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:1rem}
|
||||
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:1.25rem;transition:all .2s}
|
||||
.card:hover{border-color:rgba(99,102,241,.3);background:rgba(255,255,255,.06)}
|
||||
.card.offline{opacity:.45}.card.offline:hover{opacity:.65}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
|
||||
.card-name{font-size:1.1rem;font-weight:500;display:flex;align-items:center;gap:.5rem}
|
||||
.card-ip{font-size:.72rem;color:#777;font-family:monospace}
|
||||
.card-meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
|
||||
.badge{font-size:.65rem;padding:2px 8px;border-radius:10px;text-transform:uppercase;font-weight:600;letter-spacing:.3px}
|
||||
.badge.online{background:rgba(74,222,128,.15);color:#86efac} .badge.offline{background:rgba(255,255,255,.06);color:#777}
|
||||
.badge.playing{background:rgba(96,165,250,.15);color:#93c5fd} .badge.sleeping{background:rgba(251,191,36,.15);color:#fcd34d}
|
||||
.badge.auto{background:rgba(168,85,247,.15);color:#d8b4fe} .badge.manual{background:rgba(148,163,184,.15);color:#cbd5e1}
|
||||
.name-input{background:transparent;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:.85rem;padding:3px 7px;width:130px}
|
||||
.name-input:focus{outline:none;border-color:#6366f1}
|
||||
.info-row{font-size:.72rem;color:#666;margin-bottom:.75rem;display:flex;gap:1rem;flex-wrap:wrap}
|
||||
.info-row span{white-space:nowrap}
|
||||
.controls{display:flex;flex-direction:column;gap:.6rem}
|
||||
.crow{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
|
||||
.clbl{font-size:.78rem;color:#777;min-width:55px}
|
||||
.btn{padding:5px 12px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;transition:all .12s;white-space:nowrap}
|
||||
.btn:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)}
|
||||
.btn.red{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}.btn.red:hover{background:rgba(239,68,68,.25)}
|
||||
.btn.grn{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.4);color:#86efac}.btn.grn:hover{background:rgba(34,197,94,.25)}
|
||||
.btn.blue{background:rgba(99,102,241,.12);border-color:rgba(99,102,241,.4);color:#a5b4fc}.btn.blue:hover{background:rgba(99,102,241,.25)}
|
||||
.btn.sm{font-size:.68rem;padding:2px 7px}
|
||||
.btn.logout{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.25);color:#fca5a5;font-size:.72rem;padding:3px 10px}
|
||||
select{padding:5px 8px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;max-width:200px}
|
||||
select:focus{outline:none;border-color:#6366f1}
|
||||
option{background:#1a1a2e;color:#e0e0e0}
|
||||
input[type=range]{width:110px;accent-color:#6366f1}
|
||||
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}
|
||||
input[type=time]:focus{outline:none;border-color:#6366f1}
|
||||
.rval{font-size:.78rem;color:#999;min-width:28px}
|
||||
.tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none}
|
||||
.tgl-s{position:absolute;inset:0;background:rgba(255,255,255,.08);border-radius:10px;transition:.2s}
|
||||
.tgl-s::before{content:'';position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#777;border-radius:50%;transition:.2s}
|
||||
.tgl input:checked+.tgl-s{background:rgba(99,102,241,.35)}.tgl input:checked+.tgl-s::before{transform:translateX(16px);background:#a5b4fc}
|
||||
.empty{text-align:center;padding:4rem 2rem;color:#555}
|
||||
.empty h2{font-size:1.1rem;font-weight:400;margin-bottom:.4rem;color:#777}
|
||||
.ws-pill{font-size:.72rem;padding:3px 9px;border-radius:20px}
|
||||
.ws-pill.on{background:rgba(34,197,94,.12);color:#86efac}.ws-pill.off{background:rgba(239,68,68,.12);color:#fca5a5}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
|
||||
<div><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
|
||||
<div class="header-right">
|
||||
<span class="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>
|
||||
|
||||
<!-- 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">▶</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 & applied to all server-controlled frames</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-head" onclick="toggle('frames-sec','frames-arr')">Frames <span class="arr open" id="frames-arr">▶</span></div>
|
||||
<div id="frames-sec">
|
||||
<div class="grid" id="grid"><div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sec-head" onclick="toggle('api-sec','api-arr')">REST API Reference <span class="arr" id="api-arr">▶</span></div>
|
||||
<div id="api-sec" style="display:none">
|
||||
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/clients</span></h3><p>List all known frames with status, IP, name, and config.</p><pre id="c-list">curl -s -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients<span class="cpb" onclick="cc('c-list')">Copy</span></pre></div>
|
||||
<div class="api-card"><h3><span class="mtd post">POST</span><span class="ep">/api/clients/:id/command</span></h3><p>Send a command to a frame. Actions: <code>start</code> <code>stop</code> <code>next</code> <code>prev</code> <code>sleep</code> <code>wake</code> <code>refresh</code> <code>setSource</code> <code>setConfig</code></p><pre id="c-cmd">curl -s -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"action":"next"}' http://YOUR_HOST:3030/api/clients/CLIENT_ID/command<span class="cpb" onclick="cc('c-cmd')">Copy</span></pre></div>
|
||||
<div class="api-card"><h3><span class="mtd del">DELETE</span><span class="ep">/api/clients/:id</span></h3><p>Remove a frame from the registry.</p><pre id="c-del">curl -s -X DELETE -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients/CLIENT_ID<span class="cpb" onclick="cc('c-del')">Copy</span></pre></div>
|
||||
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/settings</span> · <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()">×</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)+' · 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>';
|
||||
}
|
||||
h+='</div>';
|
||||
});
|
||||
g.innerHTML=h;
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
@@ -1,147 +1,59 @@
|
||||
/* === Reset === */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; }
|
||||
body.setup-mode { cursor: default; }
|
||||
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; color: #e8e6f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
/* === SETUP === */
|
||||
#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; }
|
||||
.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; }
|
||||
.subtitle { font-size: 0.95rem; color: #888; }
|
||||
.subtitle.connected { color: #4ade80; }
|
||||
.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
|
||||
.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; }
|
||||
.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); }
|
||||
.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; }
|
||||
.source-icon { font-size: 1.5rem; }
|
||||
.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; }
|
||||
.loading-text { text-align: center; color: #666; padding: 1rem; }
|
||||
/* === SETUP SCREEN === */
|
||||
.setup-screen { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||
.setup-header { padding: 1.5rem 2rem 1rem; flex-shrink: 0; text-align: center; }
|
||||
.setup-logo { width: 64px; height: 64px; margin-bottom: 0.5rem; }
|
||||
.setup-header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; }
|
||||
.setup-header h1 span { color: #6366f1; }
|
||||
#connection-status { font-size: 0.85rem; color: #666; margin-top: 0.3rem; transition: color 0.3s; }
|
||||
#connection-status.connected { color: #4ade80; }
|
||||
.setup-body { flex: 1; overflow-y: auto; padding: 0 2rem 1rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 0; }
|
||||
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #555; margin-bottom: 0.6rem; }
|
||||
.source-buttons { display: flex; gap: 0.5rem; }
|
||||
.btn-source { flex: 1; padding: 0.6rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 8px; color: #ccc; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-source:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||||
.btn-source.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; color: #fff; }
|
||||
.album-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 10px; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s ease; animation: fadeIn 0.3s ease forwards; }
|
||||
.album-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; }
|
||||
.album-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; background: #222; }
|
||||
.album-info { flex: 1; }
|
||||
.album-name { font-size: 1rem; font-weight: 500; }
|
||||
.album-thumb { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.06); }
|
||||
.album-info { flex: 1; min-width: 0; }
|
||||
.album-name { font-size: 1rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.album-count { font-size: 0.8rem; color: #888; margin-top: 2px; }
|
||||
.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; }
|
||||
.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); }
|
||||
.start-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.setup-error { text-align: center; padding: 2rem; }
|
||||
.setup-error p { margin-bottom: 0.75rem; }
|
||||
.setup-error .error-detail { color: #888; font-size: 0.85rem; }
|
||||
.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; }
|
||||
.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.album-shared-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; background: rgba(99,102,241,0.25); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.4); border-radius: 4px; padding: 1px 5px; margin-left: 6px; vertical-align: middle; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.setup-error { padding: 0 2rem; }
|
||||
.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 1rem; color: #fca5a5; }
|
||||
.loading-text { color: #555; font-size: 0.9rem; text-align: center; padding: 2rem; }
|
||||
.setup-footer { padding: 1rem 2rem 1.5rem; flex-shrink: 0; }
|
||||
.btn-start { width: 100%; padding: 0.9rem; background: #6366f1; border: none; border-radius: 10px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
|
||||
.btn-start:hover:not(:disabled) { background: #4f46e5; }
|
||||
.btn-start:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; }
|
||||
|
||||
/* =============================================
|
||||
SLIDESHOW - VINTAGE POLAROID PILE
|
||||
============================================= */
|
||||
#slideshow-screen { background: #1e1a14; overflow: hidden; }
|
||||
|
||||
/* Background — near-full sepia */
|
||||
.bg-blur {
|
||||
position: absolute; top: -30px; left: -30px;
|
||||
width: calc(100% + 60px); height: calc(100% + 60px);
|
||||
background-size: cover; background-position: center;
|
||||
filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
|
||||
opacity: 0; transition: opacity 3s ease; z-index: 1;
|
||||
}
|
||||
/* === SLIDESHOW SCREEN === */
|
||||
.slideshow-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; }
|
||||
#pile-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.bg-blur { position: absolute; top: -20px; left: -20px; right: -20px; bottom: -20px; background-size: cover; background-position: center; filter: blur(20px) brightness(0.25) saturate(0.4); opacity: 0; transition: opacity 1.5s ease; }
|
||||
.bg-blur.visible { opacity: 1; }
|
||||
|
||||
/* Canvas pile */
|
||||
#pile-canvas {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 2; pointer-events: none;
|
||||
transition: opacity 2s ease;
|
||||
}
|
||||
|
||||
/* Vignette */
|
||||
.bg-vignette {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%);
|
||||
z-index: 3; pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- Centering wrapper (flexbox — works on all resolutions) --- */
|
||||
.main-frame-wrapper {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 5; pointer-events: none; overflow: visible;
|
||||
}
|
||||
|
||||
/* --- Main frame — no transform centering, flexbox does it --- */
|
||||
.main-frame {
|
||||
opacity: 0;
|
||||
transition: opacity 1.2s ease;
|
||||
animation: float 90s linear infinite;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.main-frame-wrap { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.8s ease; animation: floatFrame 6s ease-in-out infinite; }
|
||||
.main-frame.visible { opacity: 1; }
|
||||
|
||||
/* Slow drift + slight constant rotation */
|
||||
@keyframes float {
|
||||
0% { transform: translate(0, 0) rotate(1.5deg); }
|
||||
100% { transform: translate(8px, -5px) rotate(1.5deg); }
|
||||
}
|
||||
|
||||
/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
|
||||
.main-frame .frame-border {
|
||||
background: #ede8df;
|
||||
padding: 1.2vmin 1.2vmin 4vmin 1.2vmin;
|
||||
box-shadow:
|
||||
0 6px 40px rgba(0,0,0,0.6),
|
||||
0 2px 6px rgba(0,0,0,0.3),
|
||||
inset 0 0 0 1px rgba(0,0,0,0.05);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Large main image — allowed to overhang slightly */
|
||||
.main-frame .frame-media {
|
||||
display: block;
|
||||
max-width: 93vw;
|
||||
max-height: 85vh;
|
||||
width: auto; height: auto;
|
||||
object-fit: contain;
|
||||
background: #2a2520;
|
||||
}
|
||||
|
||||
/* === OVERLAY === */
|
||||
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; }
|
||||
#main-photo { display: block; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
|
||||
#main-video { display: none; max-width: 80vw; max-height: 75vh; width: auto; height: auto; }
|
||||
.touch-zones { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; }
|
||||
.touch-zones > div { flex: 1; cursor: pointer; }
|
||||
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s; }
|
||||
.overlay.hidden { opacity: 0; }
|
||||
.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); }
|
||||
.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; }
|
||||
.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; }
|
||||
.overlay-bottom { position: absolute; bottom: 0; left: 0; width: 100%; padding: 1.5rem 2rem 1rem; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); }
|
||||
.exif-info { font-size: 0.85rem; font-weight: 300; color: rgba(255,255,255,0.75); margin-bottom: 0.75rem; text-shadow: 0 1px 4px rgba(0,0,0,0.8); }
|
||||
.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; }
|
||||
.clock { position: absolute; top: 1.5rem; right: 2rem; font-size: 3rem; font-weight: 200; color: rgba(255,255,255,0.9); text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.02em; }
|
||||
.date-display { position: absolute; top: 6rem; right: 2rem; font-size: 0.9rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.5); text-align: right; }
|
||||
.exif-info { position: absolute; bottom: 3rem; left: 50%; transform: translateX(-50%); font-size: 0.8rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.6); white-space: nowrap; }
|
||||
.progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: rgba(255,255,255,0.1); }
|
||||
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.4); }
|
||||
.btn-settings { position: absolute; bottom: 1.5rem; right: 1.5rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 44px; height: 44px; color: rgba(255,255,255,0.8); font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s; pointer-events: all; }
|
||||
.btn-settings.visible { opacity: 1; }
|
||||
|
||||
/* === CONTROLS === */
|
||||
.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; }
|
||||
.touch-left { left: 0; width: 20%; }
|
||||
.touch-center { left: 20%; width: 60%; }
|
||||
.touch-right { right: 0; width: 20%; }
|
||||
.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
|
||||
.settings-btn.visible { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 600px) {
|
||||
.setup-container { padding: 1.25rem; }
|
||||
.setup-header h1 { font-size: 1.6rem; }
|
||||
.clock { font-size: 1.8rem; }
|
||||
.overlay-top-right { top: 1rem; right: 1rem; }
|
||||
.overlay-bottom { padding: 1rem 1rem 0.5rem; }
|
||||
.source-buttons { flex-direction: column; }
|
||||
.main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
|
||||
.main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
|
||||
}
|
||||
|
||||
.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
|
||||
.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
|
||||
.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes floatFrame { 0%, 100% { transform: translateY(0px) rotate(-0.3deg); } 50% { transform: translateY(-8px) rotate(0.3deg); } }
|
||||
|
||||
+39
-33
@@ -6,70 +6,76 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#1a1510">
|
||||
<meta name="theme-color" content="#0a0a0f">
|
||||
<title>Frambe</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="setup-screen" class="screen">
|
||||
<div class="setup-container">
|
||||
|
||||
<!-- Setup Screen -->
|
||||
<div id="setup-screen" class="setup-screen">
|
||||
<div class="setup-header">
|
||||
<img src="/img/icon.png" alt="Frambe" class="setup-logo">
|
||||
<h1>Frambe</h1>
|
||||
<p class="subtitle" id="connection-status">Connecting to Immich…</p>
|
||||
<h1>Fram<span>be</span></h1>
|
||||
<p id="connection-status">Connecting to Immich…</p>
|
||||
</div>
|
||||
<div id="setup-content" class="setup-content">
|
||||
<div class="section">
|
||||
<h2>Select Photo Source</h2>
|
||||
<div id="setup-content" class="setup-body">
|
||||
<div>
|
||||
<div class="section-title">Photo Source</div>
|
||||
<div class="source-buttons">
|
||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
|
||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon">⭐</span><span>Favorites</span></button>
|
||||
<button id="btn-all-photos" class="btn-source" onclick="selectSource('random')">🎲 Random</button>
|
||||
<button id="btn-favorites" class="btn-source" onclick="selectSource('favorites')">⭐ Favorites</button>
|
||||
</div>
|
||||
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
|
||||
</div>
|
||||
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
|
||||
<div>
|
||||
<div class="section-title">Albums</div>
|
||||
<div id="albums-list"><p class="loading-text">Loading albums…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="setup-error" class="setup-error" style="display:none">
|
||||
<div class="error-box">
|
||||
<p>⚠️ Cannot connect to Immich</p>
|
||||
<p class="error-detail" id="error-detail"></p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
<p id="error-detail" style="margin-top:0.5rem;font-size:0.85rem;opacity:0.8"></p>
|
||||
<button onclick="location.reload()" style="margin-top:0.75rem;padding:0.4rem 1rem;background:#6366f1;border:none;border-radius:6px;color:#fff;cursor:pointer">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setup-footer">
|
||||
<button id="btn-start" class="btn-start" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="slideshow-screen" class="screen" style="display:none">
|
||||
<!-- Slideshow Screen -->
|
||||
<div id="slideshow-screen" class="slideshow-screen" style="display:none">
|
||||
<div id="bg-blur" class="bg-blur"></div>
|
||||
<canvas id="pile-canvas"></canvas>
|
||||
<div class="bg-vignette"></div>
|
||||
|
||||
<!-- Flexbox wrapper handles centering; animation lives on inner frame -->
|
||||
<div class="main-frame-wrapper">
|
||||
<div class="main-frame-wrap">
|
||||
<div id="main-frame" class="main-frame">
|
||||
<div class="frame-border">
|
||||
<img id="main-photo" class="frame-media" alt="">
|
||||
<video id="main-video" class="frame-media" muted playsinline style="display:none"></video>
|
||||
</div>
|
||||
<img id="main-photo" alt="">
|
||||
<video id="main-video" muted playsinline style="display:none"></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overlay" class="overlay">
|
||||
<div class="overlay-top-right">
|
||||
<div id="clock" class="clock"></div>
|
||||
<div id="date-display" class="date-display"></div>
|
||||
</div>
|
||||
<div class="overlay-bottom">
|
||||
<div id="exif-info" class="exif-info"></div>
|
||||
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
<div class="date-display" id="date-display"></div>
|
||||
<div class="exif-info" id="exif-info"></div>
|
||||
<div class="progress-bar" id="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
|
||||
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
|
||||
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
|
||||
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()">✕</button>
|
||||
<div class="touch-zones">
|
||||
<div onclick="prevPhoto()"></div>
|
||||
<div onclick="toggleOverlay()"></div>
|
||||
<div onclick="nextPhoto()"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-settings" class="btn-settings" onclick="exitSlideshow()">✕</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+68
-187
@@ -1,201 +1,83 @@
|
||||
// === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
|
||||
// === Frambe v1.5.0 - Client with WebSocket Remote Control + Server-Controlled Defaults ===
|
||||
(function () {
|
||||
'use strict';
|
||||
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
|
||||
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
|
||||
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
|
||||
var currentVideoPlaying = false;
|
||||
var pileCanvas, pileCtx;
|
||||
// Shared polaroid proportions (match main frame CSS: ~3% sides, ~10% bottom)
|
||||
var FRAME_PAD_RATIO = 0.03;
|
||||
var FRAME_BOTTOM_RATIO = 0.10;
|
||||
var FRAME_COLOR = '#ede8df';
|
||||
var currentVideoPlaying = false, pileCanvas, pileCtx;
|
||||
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
|
||||
var wsConn = null, clientId = null, isSleeping = false, 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');
|
||||
var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
|
||||
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
|
||||
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
|
||||
var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame');
|
||||
var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video');
|
||||
var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display');
|
||||
var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill');
|
||||
var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings');
|
||||
var $progressBar = document.getElementById('progress-bar');
|
||||
// === WEBSOCKET ===
|
||||
function connectWebSocket(){var proto=location.protocol==='https:'?'wss:':'ws:';wsConn=new WebSocket(proto+'//'+location.host+'/ws');wsConn.onopen=function(){console.log('[Frambe] WebSocket connected');wsConn.send(JSON.stringify({type:'register',role:'frame',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(),source:currentSourceDescriptor()}));}
|
||||
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};}
|
||||
|
||||
function getUrlParams() { var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p; }
|
||||
async function autoLaunch(src, aid, pid) { urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launching: source='+src+(aid?' album='+aid:'')+(pid?' person='+pid:''));await doStartSlideshow(); }
|
||||
|
||||
async function init() {
|
||||
document.body.classList.add('setup-mode');
|
||||
try {
|
||||
config = await (await fetch('/api/config')).json();
|
||||
console.log('[Frambe] Running version ' + (config.version || 'unknown'));
|
||||
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
|
||||
var si = await (await fetch('/api/server-info')).json();
|
||||
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
|
||||
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
|
||||
$connectionStatus.classList.add('connected');
|
||||
var params = getUrlParams();
|
||||
if (params.album) { await autoLaunch('album', params.album, null); return; }
|
||||
if (params.person) { await autoLaunch('person', null, params.person); return; }
|
||||
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
|
||||
if ('random' in params) { await autoLaunch('random', null, null); return; }
|
||||
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
|
||||
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
|
||||
await loadAlbums();
|
||||
} catch (err) { showError('Failed to initialize: ' + err.message); }
|
||||
// === 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 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>';} }
|
||||
function handleRemoteCommand(action,payload){console.log('[Frambe] Remote: '+action);switch(action){case'setSource':selectedSource=payload.source;selectedAlbumId=payload.albumId||null;selectedPersonId=payload.personId||null;if(isSleeping)wakeUp();if(isRunning){clearTimeout(slideshowTimer);stopVideo();}doStartSlideshow();break;case'start':if(isSleeping)wakeUp();if(!isRunning&&selectedSource)doStartSlideshow();break;case'stop':if(isRunning)exitSlideshowInternal();sendStatus('idle');break;case'next':if(isRunning)showNextAsset();break;case'prev':if(isRunning)showPrevAsset();break;case'sleep':goToSleep();break;case'wake':wakeUp();break;case'refresh':location.reload();break;case'setConfig':applyConfigChange(payload);break;}}
|
||||
function goToSleep(){isSleeping=true;document.body.style.background='#000';if($slideshowScreen)$slideshowScreen.style.display='none';if($setupScreen)$setupScreen.style.display='none';var s=document.getElementById('sleep-overlay');if(!s){s=document.createElement('div');s.id='sleep-overlay';s.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:#000;z-index:9999;';document.body.appendChild(s);}s.style.display='block';if(isRunning){clearTimeout(slideshowTimer);stopVideo();}sendStatus('sleeping');}
|
||||
function wakeUp(){isSleeping=false;document.body.style.background='';var s=document.getElementById('sleep-overlay');if(s)s.style.display='none';if(isRunning)$slideshowScreen.style.display='block';else $setupScreen.style.display='flex';sendStatus(isRunning?'playing':'idle');}
|
||||
function applyConfigChange(c){if('slideshowInterval'in c)config.slideshowInterval=c.slideshowInterval;if('showClock'in c){config.showClock=c.showClock;$clock.style.display=c.showClock?'':'none';}if('showDate'in c){config.showDate=c.showDate;$dateDisplay.style.display=c.showDate?'':'none';}if('showExif'in c){config.showExif=c.showExif;$exifInfo.style.display=c.showExif?'':'none';}if('showProgress'in c){config.showProgress=c.showProgress;$progressBar.style.display=c.showProgress?'':'none';}sendStatus(isRunning?'playing':'idle');}
|
||||
function exitSlideshowInternal(){isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();}
|
||||
|
||||
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;};
|
||||
// === INIT ===
|
||||
function getUrlParams(){var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p;}
|
||||
async function autoLaunch(src,aid,pid){urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launch: '+src);await doStartSlideshow();}
|
||||
async function init(){document.body.classList.add('setup-mode');connectWebSocket();try{config=await(await fetch('/api/config')).json();console.log('[Frambe] v'+(config.version||'?'));if(!config.connected){showError('API key not configured.');return;}var si=await(await fetch('/api/server-info')).json();if(!si.ok){showError('Cannot reach Immich: '+si.error);return;}$connectionStatus.textContent='Connected to Immich v'+si.version.major+'.'+si.version.minor+'.'+si.version.patch;$connectionStatus.classList.add('connected');var p=getUrlParams();if(p.album){await autoLaunch('album',p.album,null);return;}if(p.person){await autoLaunch('person',null,p.person);return;}if('favorites'in p){await autoLaunch('favorites',null,null);return;}if('random'in p){await autoLaunch('random',null,null);return;}if(config.albumId){await autoLaunch('album',config.albumId,null);return;}if(config.showFavoritesOnly){await autoLaunch('favorites',null,null);return;}await loadAlbums();}catch(err){showError('Init failed: '+err.message);}}
|
||||
function showError(msg){$setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg;}
|
||||
async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" data-shared="'+(a.shared?'true':'false')+'" onclick="selectAlbum(\''+a.id+'\', this)">';html+=thu?'<img class="album-thumb" src="'+thu+'" alt="" loading="lazy">':'<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">'+(a.shared?'🔗':'📁')+'</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+(a.shared?' <span class="album-shared-badge">Shared</span>':'')+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';}}
|
||||
window.selectSource=function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i<items.length;i++)items[i].classList.remove('selected');$btnStart.disabled=false;};
|
||||
window.selectAlbum=function(id,el){selectedSource='album';selectedAlbumId=id;selectedPersonId=null;document.getElementById('btn-all-photos').classList.remove('selected');document.getElementById('btn-favorites').classList.remove('selected');var items=document.querySelectorAll('.album-item');for(var i=0;i<items.length;i++)items[i].classList.remove('selected');el.classList.add('selected');$btnStart.disabled=false;};
|
||||
async function loadAssets(){var res;if(selectedSource==='album'&&selectedAlbumId){res=await fetch('/api/albums/'+selectedAlbumId);if(!res.ok)throw new Error('Album: '+res.status);var al=await res.json();assets=al.assets||[];}else if(selectedSource==='person'&&selectedPersonId){res=await fetch('/api/people/'+selectedPersonId);if(!res.ok)throw new Error('Person: '+res.status);assets=await res.json();}else if(selectedSource==='favorites'){res=await fetch('/api/assets/favorites');if(!res.ok)throw new Error('Favorites: '+res.status);assets=await res.json();}else{res=await fetch('/api/assets/random?count=100');if(!res.ok)throw new Error('Random: '+res.status);assets=await res.json();}if(config.shuffle)shuffleArray(assets);console.log('[Frambe] Loaded '+assets.length+' assets');}
|
||||
function startRefreshTimer(){if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(async function(){try{var oldIds={};for(var i=0;i<assets.length;i++)oldIds[assets[i].id]=true;var nw,r;if(selectedSource==='album'&&selectedAlbumId){r=await(await fetch('/api/albums/'+selectedAlbumId)).json();nw=r.assets||[];}else if(selectedSource==='person'&&selectedPersonId){nw=await(await fetch('/api/people/'+selectedPersonId)).json();}else if(selectedSource==='favorites'){nw=await(await fetch('/api/assets/favorites')).json();}else return;var added=0;for(var j=0;j<nw.length;j++){if(!oldIds[nw[j].id]){assets.push(nw[j]);added++;}}if(added>0)console.log('[Frambe] +'+added+' assets');}catch(e){}},(config.refreshInterval||300)*1000);}
|
||||
|
||||
async function loadAssets() { var res;if(selectedSource==='album'&&selectedAlbumId){res=await fetch('/api/albums/'+selectedAlbumId);if(!res.ok)throw new Error('Album fetch failed: '+res.status);var al=await res.json();assets=al.assets||[];}else if(selectedSource==='person'&&selectedPersonId){res=await fetch('/api/people/'+selectedPersonId);if(!res.ok)throw new Error('Person fetch failed: '+res.status);assets=await res.json();}else if(selectedSource==='favorites'){res=await fetch('/api/assets/favorites');if(!res.ok)throw new Error('Favorites fetch failed: '+res.status);assets=await res.json();}else{res=await fetch('/api/assets/random?count=100');if(!res.ok)throw new Error('Random fetch failed: '+res.status);assets=await res.json();}if(config.shuffle)shuffleArray(assets);console.log('[Frambe] Loaded '+assets.length+' assets'); }
|
||||
function startRefreshTimer() { if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(async function(){try{var oldIds={};for(var i=0;i<assets.length;i++)oldIds[assets[i].id]=true;var nw,r;if(selectedSource==='album'&&selectedAlbumId){r=await(await fetch('/api/albums/'+selectedAlbumId)).json();nw=r.assets||[];}else if(selectedSource==='person'&&selectedPersonId){nw=await(await fetch('/api/people/'+selectedPersonId)).json();}else if(selectedSource==='favorites'){nw=await(await fetch('/api/assets/favorites')).json();}else return;var added=0;for(var j=0;j<nw.length;j++){if(!oldIds[nw[j].id]){assets.push(nw[j]);added++;}}if(added>0)console.log('[Frambe] Refresh added '+added+' new asset(s)');}catch(e){console.warn('[Frambe] Refresh failed: '+e.message);}}, (config.refreshInterval||300)*1000); }
|
||||
// === CANVAS PILE ===
|
||||
function initPileCanvas(){pileCanvas=document.getElementById('pile-canvas');var d=window.devicePixelRatio||1;pileCanvas.width=window.innerWidth*d;pileCanvas.height=window.innerHeight*d;pileCanvas.style.width=window.innerWidth+'px';pileCanvas.style.height=window.innerHeight+'px';pileCtx=pileCanvas.getContext('2d');pileCtx.scale(d,d);}
|
||||
function clearPileCanvas(){if(pileCtx){pileCtx.setTransform(1,0,0,1,0,0);pileCtx.clearRect(0,0,pileCanvas.width,pileCanvas.height);pileCtx.scale(window.devicePixelRatio||1,window.devicePixelRatio||1);}}
|
||||
function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.filter='saturate(0.45) sepia(0.25) brightness(0.88)';pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.filter='none';pileCtx.fillStyle='rgba(140,110,60,0.35)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
|
||||
|
||||
// =========================
|
||||
// CANVAS PILE
|
||||
// =========================
|
||||
function initPileCanvas() {
|
||||
pileCanvas = document.getElementById('pile-canvas');
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
pileCanvas.width = window.innerWidth * dpr;
|
||||
pileCanvas.height = window.innerHeight * dpr;
|
||||
pileCanvas.style.width = window.innerWidth + 'px';
|
||||
pileCanvas.style.height = window.innerHeight + 'px';
|
||||
pileCtx = pileCanvas.getContext('2d');
|
||||
pileCtx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
function clearPileCanvas() {
|
||||
if (pileCtx) { pileCtx.setTransform(1,0,0,1,0,0); pileCtx.clearRect(0, 0, pileCanvas.width, pileCanvas.height); pileCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); }
|
||||
}
|
||||
|
||||
function dropPhotoPile(imgSrc) {
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = function () {
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
// Pile polaroid size: 18-25% of screen width
|
||||
var polaroidW = vw * (0.18 + Math.random() * 0.07);
|
||||
// Use shared proportions to match main frame
|
||||
var pad = polaroidW * FRAME_PAD_RATIO;
|
||||
var bottomPad = polaroidW * FRAME_BOTTOM_RATIO;
|
||||
var innerW = polaroidW - pad * 2;
|
||||
var innerH = innerW * (img.height / img.width);
|
||||
var totalH = innerH + pad + bottomPad;
|
||||
var cx = Math.random() * vw, cy = Math.random() * vh;
|
||||
var rot = (Math.random() - 0.5) * 30;
|
||||
|
||||
var startTime = null, fadeDuration = 1200;
|
||||
function drawFrame(timestamp) {
|
||||
if (!startTime) startTime = timestamp;
|
||||
var alpha = Math.min((timestamp - startTime) / fadeDuration, 1);
|
||||
pileCtx.save();
|
||||
pileCtx.globalAlpha = alpha;
|
||||
pileCtx.translate(cx, cy);
|
||||
pileCtx.rotate(rot * Math.PI / 180);
|
||||
pileCtx.shadowColor = 'rgba(0,0,0,0.45)';
|
||||
pileCtx.shadowBlur = 18;
|
||||
pileCtx.shadowOffsetX = 3;
|
||||
pileCtx.shadowOffsetY = 6;
|
||||
pileCtx.fillStyle = FRAME_COLOR;
|
||||
pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH);
|
||||
pileCtx.shadowColor = 'transparent'; pileCtx.shadowBlur = 0; pileCtx.shadowOffsetX = 0; pileCtx.shadowOffsetY = 0;
|
||||
pileCtx.drawImage(img, -polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
|
||||
// Sepia wash
|
||||
pileCtx.fillStyle = 'rgba(150, 120, 70, 0.2)';
|
||||
pileCtx.fillRect(-polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
|
||||
pileCtx.restore();
|
||||
if (alpha < 1) requestAnimationFrame(drawFrame);
|
||||
}
|
||||
requestAnimationFrame(drawFrame);
|
||||
};
|
||||
img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
|
||||
img.src = imgSrc;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// SLIDESHOW ENGINE
|
||||
// =========================
|
||||
async function doStartSlideshow() {
|
||||
if (!selectedSource) return;
|
||||
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
|
||||
try {
|
||||
await loadAssets();
|
||||
if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000); return; }
|
||||
$setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
|
||||
document.body.classList.remove('setup-mode'); isRunning = true;
|
||||
initPileCanvas();
|
||||
if (!config.showClock) $clock.style.display = 'none';
|
||||
if (!config.showDate) $dateDisplay.style.display = 'none';
|
||||
if (!config.showExif) $exifInfo.style.display = 'none';
|
||||
if (!config.showProgress) $progressBar.style.display = 'none';
|
||||
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
|
||||
updateClock(); setInterval(updateClock, 1000);
|
||||
currentIndex = -1;
|
||||
showNextAsset(); scheduleOverlayHide(); startRefreshTimer();
|
||||
} catch (err) { console.error('[Frambe] Start failed: '+err.message); $btnStart.textContent='Error: '+err.message; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000); }
|
||||
}
|
||||
|
||||
window.startSlideshow = function () { doStartSlideshow(); };
|
||||
window.exitSlideshow = function () {
|
||||
if (urlDriven) { window.location.href = window.location.pathname; return; }
|
||||
isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();
|
||||
$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');
|
||||
$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;
|
||||
$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();
|
||||
};
|
||||
|
||||
function showNextAsset() { currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex); }
|
||||
function showPrevAsset() { currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex); }
|
||||
|
||||
function showAsset(index) {
|
||||
if (!assets[index]) return;
|
||||
clearTimeout(slideshowTimer); stopVideo();
|
||||
var asset = assets[index], isVideo = asset.type === 'VIDEO';
|
||||
var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
|
||||
console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id));
|
||||
if (currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) dropPhotoPile('/api/assets/' + assets[pi].id + '/thumbnail?size=thumbnail'); }
|
||||
$mainFrame.classList.remove('visible');
|
||||
var img = new Image();
|
||||
img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); };
|
||||
img.onerror = function () { setTimeout(showNextAsset, 500); };
|
||||
img.src = thumbUrl;
|
||||
var ni = index + 1; if (ni >= assets.length) ni = 0;
|
||||
if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; }
|
||||
}
|
||||
|
||||
function displayAsset(asset, thumbUrl, isVideo) {
|
||||
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); }
|
||||
$mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none';
|
||||
if (isVideo) {
|
||||
$mainVideo.style.display = 'block';
|
||||
$mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl;
|
||||
$mainVideo.load();
|
||||
$mainVideo.play().then(function(){ currentVideoPlaying=true; }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); });
|
||||
$mainVideo.onended = function () { currentVideoPlaying=false; showNextAsset(); };
|
||||
slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){showNextAsset();} }, Math.max((config.slideshowInterval||30)*3, 120)*1000);
|
||||
} else {
|
||||
$mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl;
|
||||
slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000);
|
||||
}
|
||||
requestAnimationFrame(function () { $mainFrame.classList.add('visible'); });
|
||||
updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000);
|
||||
}
|
||||
|
||||
function stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} }
|
||||
|
||||
function updateExifInfo(a) { if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · '); }
|
||||
function startProgress(ms) { if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';} }
|
||||
function updateClock() { var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'}); }
|
||||
window.toggleOverlay = function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}};
|
||||
// === SLIDESHOW ===
|
||||
async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML='<span class="spinner"></span> Loading...';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}}
|
||||
window.startSlideshow=function(){doStartSlideshow();};
|
||||
window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');};
|
||||
function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);}
|
||||
function showPrevAsset(){currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex);}
|
||||
function showAsset(idx){if(!assets[idx])return;clearTimeout(slideshowTimer);stopVideo();var a=assets[idx],isV=a.type==='VIDEO',thu='/api/assets/'+a.id+'/thumbnail?size=preview';if(currentIndex>0){var pi=currentIndex-1;if(pi<0)pi=assets.length-1;if(assets[pi])dropPhotoPile('/api/assets/'+assets[pi].id+'/thumbnail?size=thumbnail');}$mainFrame.classList.remove('visible');var img=new Image();img.onload=function(){setTimeout(function(){displayAsset(a,thu,isV);},500);};img.onerror=function(){setTimeout(showNextAsset,500);};img.src=thu;var ni=idx+1;if(ni>=assets.length)ni=0;if(assets[ni]){var pre=new Image();pre.src='/api/assets/'+assets[ni].id+'/thumbnail?size=preview';}}
|
||||
function displayAsset(a,thu,isV){if(config.backgroundBlur){$bgBlur.style.backgroundImage='url('+thu+')';$bgBlur.classList.add('visible');}$mainVideo.style.display='none';$mainPhoto.style.display='none';if(isV){$mainVideo.style.display='block';$mainVideo.src='/api/assets/'+a.id+'/video';$mainVideo.poster=thu;$mainVideo.load();$mainVideo.play().then(function(){currentVideoPlaying=true;}).catch(function(){});$mainVideo.onended=function(){currentVideoPlaying=false;showNextAsset();};slideshowTimer=setTimeout(function(){if(currentVideoPlaying)showNextAsset();},Math.max((config.slideshowInterval||30)*3,120)*1000);}else{$mainPhoto.style.display='block';$mainPhoto.src=thu;slideshowTimer=setTimeout(showNextAsset,(config.slideshowInterval||30)*1000);}requestAnimationFrame(function(){$mainFrame.classList.add('visible');});updateExifInfo(a);startProgress(isV?null:(config.slideshowInterval||30)*1000);}
|
||||
function stopVideo(){if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;}}
|
||||
function updateExifInfo(a){if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · ');}
|
||||
function startProgress(ms){if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';}}
|
||||
function updateClock(){var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'});}
|
||||
window.toggleOverlay=function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}};
|
||||
function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
|
||||
window.nextPhoto = function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
window.prevPhoto = function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case 'ArrowRight':case ' ':e.preventDefault();nextPhoto();break;case 'ArrowLeft':e.preventDefault();prevPhoto();break;case 'Escape':exitSlideshow();break;case 'f':toggleFullscreen();break;case 'i':toggleOverlay();break;}});
|
||||
window.nextPhoto=function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
window.prevPhoto=function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case'ArrowRight':case' ':e.preventDefault();nextPhoto();break;case'ArrowLeft':e.preventDefault();prevPhoto();break;case'Escape':exitSlideshow();break;case'f':toggleFullscreen();break;case'i':toggleOverlay();break;}});
|
||||
function toggleFullscreen(){if(!document.fullscreenElement&&!document.webkitFullscreenElement){var el=document.documentElement;if(el.requestFullscreen)el.requestFullscreen();else if(el.webkitRequestFullscreen)el.webkitRequestFullscreen();}else{if(document.exitFullscreen)document.exitFullscreen();else if(document.webkitExitFullscreen)document.webkitExitFullscreen();}}
|
||||
function shuffleArray(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}}
|
||||
function padZero(n){return n<10?'0'+n:''+n;}
|
||||
@@ -203,6 +85,5 @@
|
||||
function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
|
||||
async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
|
||||
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
|
||||
function preventSleep(){try{var v=document.createElement('video');v.setAttribute('playsinline','');v.setAttribute('muted','');v.setAttribute('loop','');v.style.cssText='position:absolute;width:1px;height:1px;opacity:0.01';v.src='data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';document.body.appendChild(v);v.play().catch(function(){});}catch(e){}}
|
||||
init();requestWakeLock();preventSleep();
|
||||
init();requestWakeLock();
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
require('dotenv').config();
|
||||
|
||||
const VERSION = '1.3.0';
|
||||
const VERSION = '1.5.2';
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
|
||||
const API_KEY = process.env.IMMICH_API_KEY || '';
|
||||
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
|
||||
@@ -22,104 +27,296 @@ const ALBUM_ID = process.env.ALBUM_ID || '';
|
||||
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
|
||||
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
||||
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
||||
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
|
||||
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
|
||||
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
|
||||
const imageCache = new Map();
|
||||
function cacheGet(key) { const e=imageCache.get(key);if(!e)return null;imageCache.delete(key);imageCache.set(key,e);return e; }
|
||||
function cacheSet(key,value) { if(imageCache.size>=IMAGE_CACHE_MAX){imageCache.delete(imageCache.keys().next().value);}imageCache.set(key,value); }
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
|
||||
const AUTH_ENABLED = !!ADMIN_PASSWORD;
|
||||
const sessions = new Map();
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000;
|
||||
function createSession(username) { const token=crypto.randomBytes(32).toString('hex'),now=Date.now();sessions.set(token,{username,createdAt:now,expiresAt:now+SESSION_TTL});return token; }
|
||||
function validateSession(token) { if(!token)return false;const s=sessions.get(token);if(!s)return false;if(Date.now()>s.expiresAt){sessions.delete(token);return false;}return true; }
|
||||
function cleanupSessions() { const now=Date.now();sessions.forEach((s,t)=>{if(now>s.expiresAt)sessions.delete(t);}); }
|
||||
setInterval(cleanupSessions, 60 * 60 * 1000);
|
||||
function requireAdminAuth(req,res,next) { if(!AUTH_ENABLED)return next();const cookie=req.headers.cookie||'';const match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match&&validateSession(match[1]))return next();if(req.headers['accept']&&req.headers['accept'].includes('application/json'))return res.status(401).json({error:'Not authenticated'});return res.redirect('/admin/login'); }
|
||||
function requireApiToken(req,res,next) { if(!FRAMBE_API_TOKEN)return next();const auth=req.headers['authorization']||'',hdr=req.headers['x-api-token']||'',qry=req.query.token||'',provided=auth.startsWith('Bearer ')?auth.slice(7):(hdr||qry);if(provided===FRAMBE_API_TOKEN)return next();return res.status(401).json({error:'Invalid API token'}); }
|
||||
function immichHeaders() { return {'x-api-key':API_KEY,'Content-Type':'application/json'}; }
|
||||
function log(msg) { console.log('['+new Date().toISOString()+'] '+msg); }
|
||||
function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg); }
|
||||
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
|
||||
function mapAsset(a) { return {id:a.id,type:a.type,fileCreatedAt:a.fileCreatedAt,fileModifiedAt:a.fileModifiedAt,isFavorite:a.isFavorite,exifInfo:a.exifInfo?{dateTimeOriginal:a.exifInfo.dateTimeOriginal,city:a.exifInfo.city,state:a.exifInfo.state,country:a.exifInfo.country,make:a.exifInfo.make,model:a.exifInfo.model}:null,originalMimeType:a.originalMimeType,duration:a.duration}; }
|
||||
|
||||
function immichHeaders() {
|
||||
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
|
||||
// === GLOBAL SETTINGS (persisted to disk) ===
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
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 log(msg) { console.log('[Frambe] ' + msg); }
|
||||
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
|
||||
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();
|
||||
|
||||
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.html') || filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0');
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.use(express.json());
|
||||
// === 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;
|
||||
}
|
||||
|
||||
function mapAsset(a) {
|
||||
const wss = new WebSocketServer({ server });
|
||||
const clients = new Map();
|
||||
const adminSockets = new Set();
|
||||
const CLIENT_TTL = parseInt(process.env.CLIENT_TTL, 10) || (7 * 24 * 60 * 60);
|
||||
const ONLINE_THRESHOLD = 35000;
|
||||
|
||||
// === CLIENT FINGERPRINTING & DEDUP ===
|
||||
function fingerprint(pid, ip, ua) {
|
||||
if (pid) return 'pid:' + pid;
|
||||
return 'sig:' + crypto.createHash('sha1').update((ip||'')+'|'+(ua||'')).digest('hex').slice(0, 16);
|
||||
}
|
||||
function findExisting(pid, ip, ua) {
|
||||
const fp = fingerprint(pid, ip, ua);
|
||||
for (const c of clients.values()) { if (c.fingerprint === fp) return c; }
|
||||
if (pid) { const sig = fingerprint(null, ip, ua); for (const c of clients.values()) { if (c.fingerprint === sig) return c; } }
|
||||
return null;
|
||||
}
|
||||
|
||||
function getClientList() {
|
||||
const now = Date.now();
|
||||
return Array.from(clients.values()).map(c => ({
|
||||
id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
|
||||
fingerprint: c.fingerprint, persistentId: c.persistentId || null,
|
||||
connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
|
||||
status: c.status, online: (now - c.lastSeen) < ONLINE_THRESHOLD,
|
||||
config: c.config||{}, source: c.source||null, serverControlled: c.serverControlled !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastAdminClients() {
|
||||
const msg = JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings });
|
||||
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function resolvedConfigFor(client) {
|
||||
const g = globalSettings;
|
||||
const sleep = effectiveSleep(client);
|
||||
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,
|
||||
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 filterAssets(assets) {
|
||||
if (INCLUDE_VIDEOS) return assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO');
|
||||
return assets.filter(a => a.type === 'IMAGE');
|
||||
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); }
|
||||
});
|
||||
});
|
||||
|
||||
function applySettings(patch) {
|
||||
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();
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
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.get('/api/server-info', async (_req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const v = await r.json(); log('Immich connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v });
|
||||
} catch (err) { logErr('Immich connection failed: ' + err.message); res.status(502).json({ ok: false, error: err.message }); }
|
||||
});
|
||||
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'));});
|
||||
|
||||
app.get('/api/albums', async (_req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const albums = await r.json(); log('Listed ' + albums.length + ' albums'); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
|
||||
} catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/albums/:id', async (req, res) => {
|
||||
try { log('Fetching album ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const album = await r.json(); const assets = filterAssets(album.assets || []).map(mapAsset); const vids = assets.filter(a => a.type === 'VIDEO').length; log('Album "' + album.albumName + '" returned ' + assets.length + ' assets (' + (assets.length - vids) + ' photos, ' + vids + ' videos)'); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
|
||||
} catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people', async (_req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })); log('Listed ' + people.length + ' people'); res.json(people);
|
||||
} catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people/:id', async (req, res) => {
|
||||
try { log('Fetching person ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const raw = await r.json(); const assets = filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset); log('Person returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people/:id/thumbnail', async (req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/random', async (req, res) => {
|
||||
try { const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const assets = filterAssets(await r.json()).map(mapAsset); log('Random returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/favorites', async (_req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const assets = filterAssets(data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })); log('Favorites returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/thumbnail', async (req, res) => {
|
||||
try { const size = req.query.size || 'preview'; const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/video', async (req, res) => {
|
||||
try { log('Streaming video ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/video/playback`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res);
|
||||
} catch (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/original', async (req, res) => {
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
log('--- Frambe v' + VERSION + ' ---');
|
||||
log('Server listening on port ' + PORT);
|
||||
log('Immich URL: ' + IMMICH_URL);
|
||||
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
||||
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's');
|
||||
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
|
||||
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
|
||||
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
|
||||
log('Waiting for requests...');
|
||||
server.listen(PORT,()=>{
|
||||
log('--- Frambe v'+VERSION+' ---');
|
||||
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
|
||||
log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
|
||||
log('Server time: '+new Date().toString());
|
||||
log('Global sleep schedule: '+(globalSettings.sleep.enabled?(globalSettings.sleep.sleepAt+' -> '+globalSettings.sleep.wakeAt):'disabled'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user