30 Commits

Author SHA1 Message Date
jessikitty cc36a6fed4 feat(1.4.0): add ws dependency for WebSocket server/client communication 2026-05-22 19:51:44 +10:00
jessikitty e2be524b2a feat(1.4.0): WebSocket client with remote control — sleep/wake, setSource, setConfig, start/stop/next/prev 2026-05-22 19:50:53 +10:00
jessikitty 4b9db2af5a feat(1.4.0): admin dashboard with client cards, album/person selector, playback/power/config controls 2026-05-22 19:48:23 +10:00
jessikitty 1863da1a28 feat(1.4.0): WebSocket server, client registry, admin command routing, client naming 2026-05-22 19:47:09 +10:00
jessikitty 5a6536c6ca Merge pull request 'v1.3.0 — Vintage Polaroid Pile Theme' (#1) from dev into main
Reviewed-on: #1
2026-05-22 11:59:49 +10:00
jessikitty fc470f4df2 version 1.3.0 — Vintage Polaroid Pile theme 2026-05-22 09:18:46 +10:00
jessikitty 7f7ea978b9 fix: pile uses shared FRAME_PAD_RATIO/FRAME_BOTTOM_RATIO/FRAME_COLOR to match main, stronger sepia wash on pile 2026-05-22 09:17:58 +10:00
jessikitty dc1f3402d6 fix: flexbox centering for all resolutions, 1.5deg rotation, larger frame (93vw), full sepia bg, proportional polaroid padding (vmin) 2026-05-22 09:15:38 +10:00
jessikitty c1ebbaef98 fix: wrap main-frame in flexbox centering wrapper for reliable centering on all resolutions 2026-05-22 09:14:33 +10:00
jessikitty 1b45afb6f4 fix: pile photos fade in with sepia wash, bg transitions smoothly, drift is single-direction 2026-05-20 10:38:59 +10:00
jessikitty d1163511fa fix: portrait frame hugs image, heavier sepia wash, slow single-direction drift, bg fade transition 2026-05-20 10:37:00 +10:00
jessikitty 804c6cfd86 feat: canvas-based cumulative photo pile, unified polaroid for photos+videos, larger main frame 2026-05-20 09:48:15 +10:00
jessikitty 65993839a7 feat: replace pile divs with canvas, remove filmstrip elements, unified polaroid frame 2026-05-20 09:46:24 +10:00
jessikitty d6b6464171 feat: enlarge main frame to ~90% screen, remove filmstrip, add canvas pile, unified polaroid style 2026-05-20 09:45:56 +10:00
jessikitty 0f4a995cdc feat(1.3.0): Polaroid pile slideshow engine, video playback with filmstrip frame, floating animation 2026-05-19 21:48:53 +10:00
jessikitty ad7a2b8250 feat(1.3.0): vintage Polaroid pile theme, filmstrip video frame, floating animation, warm vignette 2026-05-19 21:46:19 +10:00
jessikitty 9ab2dc7578 feat(1.3.0): new DOM structure with photo pile, main polaroid/filmstrip frame, video element 2026-05-19 21:44:28 +10:00
jessikitty c2b40db843 feat(1.3.0): video support, INCLUDE_VIDEOS config, video streaming endpoint, asset type in mapAsset 2026-05-19 21:43:24 +10:00
jessikitty 6943d0c0dd fix(1.2.2): request logging, cache-busting, clean logs 2026-05-19 16:55:27 +10:00
jessikitty 4f2f6ee555 fix: add request logging, no-cache on HTML/JS/CSS, clean startup log, version identifier 2026-05-19 16:55:06 +10:00
jessikitty f2cc8b4413 fix: correct port mapping in README docker examples (3030:3000), add v1.2.1 to changelog 2026-05-19 16:38:34 +10:00
jessikitty 4d85d58526 fix(1.2.1): fix port mapping and URL param auto-launch 2026-05-19 16:36:40 +10:00
jessikitty 68dbf9dc59 fix: internal port 3000 (Docker maps 3030 externally) 2026-05-19 16:35:50 +10:00
jessikitty 4f7d30d3d2 fix: URL param auto-launch now awaits slideshow start, uses 'in' operator for ?favorites/?random, adds console logging for debug 2026-05-19 16:35:03 +10:00
jessikitty cac179754c fix: internal port back to 3000 (Docker maps externally via compose) 2026-05-19 16:33:06 +10:00
jessikitty a609ed2165 fix: port mapping 3030:3000 (external:internal) 2026-05-19 16:32:37 +10:00
jessikitty 2ea13fcbaa fix: revert internal port to 3000 (Docker maps 3030:3000 externally) 2026-05-19 16:32:03 +10:00
jessikitty 6c26688244 docs: update README with v1.2.0 features — URL params, person support, refresh, icon, port 3030 2026-05-19 16:16:27 +10:00
jessikitty 88d79a9569 Upload files to "public" 2026-05-19 16:14:44 +10:00
jessikitty 1c55aa9418 Upload files to "public/img" 2026-05-19 16:14:32 +10:00
12 changed files with 419 additions and 751 deletions
+2 -2
View File
@@ -22,5 +22,5 @@ SHOW_PROGRESS=true
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server # Server (internal port — Docker maps externally via docker-compose)
PORT=3030 PORT=3000
+2 -2
View File
@@ -11,10 +11,10 @@ RUN npm install --production && npm cache clean --force
COPY server.js ./ COPY server.js ./
COPY public/ ./public/ COPY public/ ./public/
EXPOSE 3030 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3030/api/config || exit 1 CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup adduser -S appuser -u 1001 -G appgroup
+34 -12
View File
@@ -1,4 +1,8 @@
# 🖼️ Frambe # Frambe
<p align="center">
<img src="public/img/icon.png" alt="Frambe" width="180">
</p>
A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames.
@@ -6,6 +10,9 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Immich API Integration** — Connects securely via API key (kept server-side) - **Immich API Integration** — Connects securely via API key (kept server-side)
- **Album Browser** — Select any album, random photos, or favorites only - **Album Browser** — Select any album, random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters
- **Auto-Refresh** — Periodically checks for new photos added to the source album/person
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration - **Smooth Crossfade** — Double-buffered image transitions with configurable duration
- **Background Blur** — Blurred backdrop fills the space behind non-covering images - **Background Blur** — Blurred backdrop fills the space behind non-covering images
- **Clock & Date Overlay** — Always know the time at a glance - **Clock & Date Overlay** — Always know the time at a glance
@@ -14,7 +21,6 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay - **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) - **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
- **Screen Wake Lock** — Prevents screen sleep on supported devices - **Screen Wake Lock** — Prevents screen sleep on supported devices
- **Auto-Start** — Configure an album ID or favorites-only to skip the setup screen
- **Responsive** — Works on any screen size from phone to TV - **Responsive** — Works on any screen size from phone to TV
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint - **Docker Containerised** — Single container, minimal footprint
@@ -30,8 +36,8 @@ A lightweight, self-contained Docker web application that connects to your [Immi
### 2. Run with Docker Compose ### 2. Run with Docker Compose
```bash ```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd immich-frame cd frambe
``` ```
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
@@ -40,7 +46,7 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
docker compose up -d docker compose up -d
``` ```
Open `http://your-server:3000` in a browser on your tablet/screen. Open `http://your-server:3030` in a browser on your tablet/screen.
### 3. Run with Docker directly ### 3. Run with Docker directly
@@ -48,13 +54,26 @@ Open `http://your-server:3000` in a browser on your tablet/screen.
docker build -t frambe . docker build -t frambe .
docker run -d \ docker run -d \
--name frambe \ --name frambe \
-p 3000:3000 \ -p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \ -e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \ -e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \ --restart unless-stopped \
frambe frambe
``` ```
## 🔗 Zero-Touch URL Parameters
Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet:
| URL | What it shows |
|---|---|
| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album |
| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) |
| `http://server:3030/?favorites` | Favorite photos only |
| `http://server:3030/?random` | Random photos from the library |
You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
## ⚙️ Configuration ## ⚙️ Configuration
All settings are via environment variables: All settings are via environment variables:
@@ -72,9 +91,10 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay | | `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar | | `SHOW_PROGRESS` | `true` | Display progress bar |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `PORT` | `3000` | Server port | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
## 🎮 Controls ## 🎮 Controls
@@ -92,7 +112,7 @@ All settings are via environment variables:
## 📱 Tablet Setup Tips ## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
2. Add to Home Screen for a full-screen app experience 2. Add to Home Screen for a full-screen app experience
3. Enable kiosk mode or guided access to lock to the app 3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings 4. Disable screen timeout in your device settings
@@ -103,13 +123,15 @@ All settings are via environment variables:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │ │ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ │ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3000 └──────────────┘ :2283 └──────────────┘ └──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
``` ```
The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
## 📋 Version History ## 📋 Version History
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
- **1.1.0** — Rebrand to Frambe - **1.1.0** — Rebrand to Frambe
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment - **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: frambe container_name: frambe
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3030:3030" - "3030:3000"
environment: environment:
# REQUIRED # REQUIRED
- IMMICH_URL=http://your-immich-server:2283 - IMMICH_URL=http://your-immich-server:2283
+4 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "frambe", "name": "frambe",
"version": "1.2.0", "version": "1.4.0",
"description": "Frambe — a lightweight digital photo frame web app for Immich", "description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js"
@@ -9,7 +9,8 @@
"dependencies": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"dotenv": "^16.4.5" "dotenv": "^16.4.5",
"ws": "^8.18.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+103
View File
@@ -0,0 +1,103 @@
<!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: 0.25rem; }
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1rem; }
.header img { width: 48px; height: 48px; border-radius: 10px; }
.header .version { font-size: 0.8rem; color: #666; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-dot.online { background: #4ade80; }
.status-dot.sleeping { background: #fbbf24; }
.status-dot.playing { background: #60a5fa; }
.clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
.client-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1.25rem; transition: all 0.2s; }
.client-card:hover { border-color: rgba(99,102,241,0.3); background: rgba(255,255,255,0.06); }
.client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.client-name { font-size: 1.1rem; font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
.client-ip { font-size: 0.75rem; color: #888; font-family: monospace; }
.client-status { font-size: 0.8rem; color: #aaa; text-transform: capitalize; }
.name-input { background: transparent; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: #fff; font-size: 0.9rem; padding: 4px 8px; width: 140px; }
.name-input:focus { outline: none; border-color: #6366f1; }
.controls { display: flex; flex-direction: column; gap: 0.75rem; }
.control-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.control-label { font-size: 0.8rem; color: #888; min-width: 60px; }
.btn { padding: 6px 14px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.btn:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25); }
.btn.danger { background: rgba(239,68,68,0.15); border-color: #ef4444; color: #fca5a5; }
.btn.danger:hover { background: rgba(239,68,68,0.3); }
.btn.success { background: rgba(34,197,94,0.15); border-color: #22c55e; color: #86efac; }
.btn.success:hover { background: rgba(34,197,94,0.3); }
select { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; max-width: 200px; }
select:focus { outline: none; border-color: #6366f1; }
option { background: #1a1a2e; color: #e0e0e0; }
input[type=range] { width: 120px; accent-color: #6366f1; }
.range-value { font-size: 0.8rem; color: #aaa; min-width: 30px; }
.toggle { position: relative; width: 40px; height: 22px; cursor: pointer; }
.toggle input { display: none; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.1); border-radius: 11px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: #888; border-radius: 50%; transition: 0.2s; }
.toggle input:checked + .toggle-slider { background: rgba(99,102,241,0.4); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #a5b4fc; }
.empty-state { text-align: center; padding: 4rem 2rem; color: #666; }
.empty-state h2 { font-size: 1.2rem; font-weight: 400; margin-bottom: 0.5rem; color: #888; }
.ws-status { font-size: 0.75rem; padding: 4px 10px; border-radius: 20px; }
.ws-status.connected { background: rgba(34,197,94,0.15); color: #86efac; }
.ws-status.disconnected { background: rgba(239,68,68,0.15); color: #fca5a5; }
.divider { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 0.5rem 0; }
</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="version-text">Connecting...</span></div>
<span class="ws-status disconnected" id="ws-status">Disconnected</span>
</div>
<div class="clients-grid" id="clients-grid">
<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div>
</div>
<script>
var ws=null, clientsData={}, albumsCache=[], peopleCache=[];
function connect() {
var proto = location.protocol==='https:'?'wss:':'ws:';
ws = new WebSocket(proto+'//'+location.host+'/ws');
ws.onopen = function(){ document.getElementById('ws-status').textContent='Connected'; document.getElementById('ws-status').className='ws-status connected'; ws.send(JSON.stringify({type:'register',role:'admin'})); loadAlbumsAndPeople(); };
ws.onmessage = function(e){ var msg=JSON.parse(e.data); if(msg.type==='clientList'){clientsData={};msg.clients.forEach(function(c){clientsData[c.id]=c;});renderClients();} else if(msg.type==='clientUpdate'){clientsData[msg.clientId]=msg.client;renderClients();} };
ws.onclose = function(){ document.getElementById('ws-status').textContent='Disconnected'; document.getElementById('ws-status').className='ws-status disconnected'; setTimeout(connect,3000); };
}
async function loadAlbumsAndPeople(){ try{ var c=await(await fetch('/api/config')).json(); document.getElementById('version-text').textContent='v'+(c.version||'?'); albumsCache=await(await fetch('/api/albums')).json(); peopleCache=await(await fetch('/api/people')).json(); }catch(e){} }
function sendCommand(id,action,payload){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:action,payload:payload||{}})); }
function renameClient(id,name){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'renameClient',targetId:id,name:name})); }
function handleSourceChange(id,val){ if(!val)return; if(val==='random')sendCommand(id,'setSource',{source:'random'}); else if(val==='favorites')sendCommand(id,'setSource',{source:'favorites'}); else if(val.startsWith('album:'))sendCommand(id,'setSource',{source:'album',albumId:val.substring(6)}); else if(val.startsWith('person:'))sendCommand(id,'setSource',{source:'person',personId:val.substring(7)}); }
function esc(s){ var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML; }
function renderClients(){
var grid=document.getElementById('clients-grid'),ids=Object.keys(clientsData);
if(!ids.length){grid.innerHTML='<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div>';return;}
var html='';
ids.forEach(function(id){ var c=clientsData[id],sc=c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online',cfg=c.config||{};
html+='<div class="client-card">';
html+='<div class="client-header"><div><div class="client-name"><span class="status-dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="renameClient(\''+id+'\',this.value)"/></div><div class="client-ip">'+esc(c.ip)+'</div></div><div class="client-status">'+esc(c.status||'connected')+'</div></div>';
html+='<div class="controls">';
html+='<div class="control-row"><span class="control-label">Source</span><select onchange="handleSourceChange(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albumsCache.forEach(function(a){html+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
peopleCache.filter(function(p){return p.name;}).forEach(function(p){html+='<option value="person:'+p.id+'">'+esc(p.name)+' (person)</option>';});
html+='</select></div><hr class="divider">';
html+='<div class="control-row"><span class="control-label">Playback</span><button class="btn success" onclick="sendCommand(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="sendCommand(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="sendCommand(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="sendCommand(\''+id+'\',\'prev\')">Prev</button></div>';
html+='<div class="control-row"><span class="control-label">Power</span><button class="btn danger" onclick="sendCommand(\''+id+'\',\'sleep\')">Sleep</button><button class="btn success" onclick="sendCommand(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="sendCommand(\''+id+'\',\'refresh\')">Refresh</button></div>';
html+='<hr class="divider">';
html+='<div class="control-row"><span class="control-label">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="sendCommand(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="range-value">'+(cfg.slideshowInterval||30)+'s</span></div>';
html+='<div class="control-row"><span class="control-label">Clock</span><label class="toggle"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Date</span><label class="toggle"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='<div class="control-row"><span class="control-label">EXIF</span><label class="toggle"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Progress</span><label class="toggle"><input type="checkbox" '+(cfg.showProgress!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showProgress:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='</div></div>';
});
grid.innerHTML=html;
}
connect();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+121 -423
View File
@@ -1,449 +1,147 @@
/* === Reset & Base === */ /* === Reset === */
*, *::before, *::after { *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0; 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; }
padding: 0; body.setup-mode { cursor: default; }
box-sizing: border-box; .screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
}
html, body { /* === SETUP === */
width: 100%; #setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; }
height: 100%; .setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; }
overflow: hidden; .setup-header { text-align: center; margin-bottom: 2rem; }
background: #000; .setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
color: #fff; .setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; }
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; .subtitle { font-size: 0.95rem; color: #888; }
-webkit-font-smoothing: antialiased; .subtitle.connected { color: #4ade80; }
cursor: none; .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; }
.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-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); } }
body.setup-mode { /* =============================================
cursor: default; SLIDESHOW - VINTAGE POLAROID PILE
} ============================================= */
#slideshow-screen { background: #1e1a14; overflow: hidden; }
/* === Screens === */
.screen {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
/* === Setup Screen === */
#setup-screen {
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.setup-container {
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
padding: 2rem;
}
.setup-header {
text-align: center;
margin-bottom: 2rem;
}
.setup-header h1 {
font-size: 2.2rem;
font-weight: 300;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 0.95rem;
color: #888;
}
.subtitle.connected {
color: #4ade80;
}
.subtitle.error {
color: #f87171;
}
/* === Source Buttons === */
.section h2 {
font-size: 1rem;
font-weight: 500;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.source-buttons {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.source-btn {
flex: 1;
padding: 1rem;
background: rgba(255, 255, 255, 0.06);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.source-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.25);
}
.source-btn.selected {
background: rgba(99, 102, 241, 0.2);
border-color: #6366f1;
}
.source-icon {
font-size: 1.5rem;
}
/* === Albums List === */
.albums-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 1.5rem;
}
.loading-text {
text-align: center;
color: #666;
padding: 1rem;
}
.album-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.04);
border: 2px solid transparent;
border-radius: 10px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.album-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.album-item.selected {
background: rgba(99, 102, 241, 0.15);
border-color: #6366f1;
}
.album-thumb {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
background: #222;
}
.album-info {
flex: 1;
}
.album-name {
font-size: 1rem;
font-weight: 500;
}
.album-count {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
/* === Start Button === */
.start-btn {
display: block;
width: 100%;
padding: 1rem;
background: #6366f1;
border: none;
border-radius: 12px;
color: #fff;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.03em;
}
.start-btn:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
}
.start-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* === Setup Error === */
.setup-error {
text-align: center;
padding: 2rem;
}
.setup-error p {
margin-bottom: 0.75rem;
}
.error-detail {
color: #888;
font-size: 0.85rem;
}
.setup-error button {
margin-top: 1rem;
padding: 0.75rem 2rem;
background: #6366f1;
border: none;
border-radius: 8px;
color: #fff;
font-size: 1rem;
cursor: pointer;
}
/* === Slideshow === */
#slideshow-screen {
background: #000;
}
/* Background — near-full sepia */
.bg-blur { .bg-blur {
position: absolute; position: absolute; top: -30px; left: -30px;
top: -20px; left: -20px; width: calc(100% + 60px); height: calc(100% + 60px);
width: calc(100% + 40px); background-size: cover; background-position: center;
height: calc(100% + 40px); filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
background-size: cover; opacity: 0; transition: opacity 3s ease; z-index: 1;
background-position: center;
filter: blur(30px) brightness(0.35);
opacity: 0;
transition: opacity 1.5s ease;
z-index: 1;
} }
.bg-blur.visible { opacity: 1; }
.bg-blur.visible { /* Canvas pile */
opacity: 1; #pile-canvas {
} position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 2; pointer-events: none;
.photo-layer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
transition: opacity 2s ease; transition: opacity 2s ease;
z-index: 2;
} }
.photo-layer.active { /* Vignette */
opacity: 1; .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;
} }
/* === Overlay === */ /* --- Centering wrapper (flexbox — works on all resolutions) --- */
.overlay { .main-frame-wrapper {
position: absolute; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
top: 0; left: 0; display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%; z-index: 5; pointer-events: none; overflow: visible;
z-index: 10;
pointer-events: none;
opacity: 1;
transition: opacity 0.5s ease;
} }
.overlay.hidden { /* --- Main frame — no transform centering, flexbox does it --- */
.main-frame {
opacity: 0; opacity: 0;
} transition: opacity 1.2s ease;
animation: float 90s linear infinite;
.overlay-top-right {
position: absolute;
top: 1.5rem;
right: 2rem;
text-align: right;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8), 0 0 20px rgba(0, 0, 0, 0.5);
}
.clock {
font-size: 2.5rem;
font-weight: 200;
letter-spacing: 0.05em;
line-height: 1.2;
}
.date-display {
font-size: 0.95rem;
font-weight: 300;
color: rgba(255, 255, 255, 0.8);
margin-top: 0.25rem;
}
.overlay-bottom {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 1.5rem 2rem 1rem;
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%);
}
.exif-info {
font-size: 0.85rem;
font-weight: 300;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.75rem;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.progress-bar {
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: rgba(255, 255, 255, 0.5);
border-radius: 2px;
transition: width 0.3s linear;
}
/* === Touch Zones === */
.touch-zone {
position: absolute;
top: 0;
height: 100%;
z-index: 20;
cursor: pointer;
}
.touch-left {
left: 0;
width: 20%;
}
.touch-center {
left: 20%;
width: 60%;
}
.touch-right {
right: 0;
width: 20%;
}
/* === Settings Button === */
.settings-btn {
position: absolute;
top: 1rem;
left: 1rem;
width: 44px;
height: 44px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
color: #fff;
font-size: 1.2rem;
cursor: pointer;
z-index: 30;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.settings-btn.visible {
opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
.main-frame.visible { opacity: 1; }
/* === Scrollbar Styling === */ /* Slow drift + slight constant rotation */
.albums-list::-webkit-scrollbar, @keyframes float {
.setup-container::-webkit-scrollbar { 0% { transform: translate(0, 0) rotate(1.5deg); }
width: 6px; 100% { transform: translate(8px, -5px) rotate(1.5deg); }
} }
.albums-list::-webkit-scrollbar-track, /* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
.setup-container::-webkit-scrollbar-track { .main-frame .frame-border {
background: transparent; 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;
} }
.albums-list::-webkit-scrollbar-thumb, /* Large main image — allowed to overhang slightly */
.setup-container::-webkit-scrollbar-thumb { .main-frame .frame-media {
background: rgba(255, 255, 255, 0.15); display: block;
border-radius: 3px; max-width: 93vw;
max-height: 85vh;
width: auto; height: auto;
object-fit: contain;
background: #2a2520;
} }
/* === Responsive === */ /* === OVERLAY === */
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; }
.overlay.hidden { opacity: 0; }
.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.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; }
/* === 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) { @media (max-width: 600px) {
.setup-container { .setup-container { padding: 1.25rem; }
padding: 1.25rem; .setup-header h1 { font-size: 1.6rem; }
} .clock { font-size: 1.8rem; }
.setup-header h1 { .overlay-top-right { top: 1rem; right: 1rem; }
font-size: 1.6rem; .overlay-bottom { padding: 1rem 1rem 0.5rem; }
} .source-buttons { flex-direction: column; }
.clock { .main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
font-size: 1.8rem; .main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
}
.overlay-top-right {
top: 1rem;
right: 1rem;
}
.overlay-bottom {
padding: 1rem 1rem 0.5rem;
}
.source-buttons {
flex-direction: column;
}
} }
/* === Loading Spinner === */ .albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
.spinner { .albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
display: inline-block; .albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
width: 24px;
height: 24px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* === Fade-in Animation === */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.album-item {
animation: fadeIn 0.3s ease forwards;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+18 -12
View File
@@ -6,7 +6,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f0f1a"> <meta name="theme-color" content="#1a1510">
<title>Frambe</title> <title>Frambe</title>
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -24,16 +24,10 @@
<div class="section"> <div class="section">
<h2>Select Photo Source</h2> <h2>Select Photo Source</h2>
<div class="source-buttons"> <div class="source-buttons">
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"> <button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
<span class="source-icon">🎲</span><span>Random Photos</span> <button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon"></span><span>Favorites</span></button>
</button>
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
<span class="source-icon"></span><span>Favorites</span>
</button>
</div>
<div id="albums-list" class="albums-list">
<p class="loading-text">Loading albums…</p>
</div> </div>
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
</div> </div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button> <button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div> </div>
@@ -47,8 +41,19 @@
<div id="slideshow-screen" class="screen" style="display:none"> <div id="slideshow-screen" class="screen" style="display:none">
<div id="bg-blur" class="bg-blur"></div> <div id="bg-blur" class="bg-blur"></div>
<div id="photo-layer-a" class="photo-layer active"></div> <canvas id="pile-canvas"></canvas>
<div id="photo-layer-b" class="photo-layer"></div> <div class="bg-vignette"></div>
<!-- Flexbox wrapper handles centering; animation lives on inner frame -->
<div class="main-frame-wrapper">
<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>
</div>
</div>
<div id="overlay" class="overlay"> <div id="overlay" class="overlay">
<div class="overlay-top-right"> <div class="overlay-top-right">
<div id="clock" class="clock"></div> <div id="clock" class="clock"></div>
@@ -59,6 +64,7 @@
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div> <div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
</div> </div>
</div> </div>
<div class="touch-zone touch-left" onclick="prevPhoto()"></div> <div class="touch-zone touch-left" onclick="prevPhoto()"></div>
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div> <div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
<div class="touch-zone touch-right" onclick="nextPhoto()"></div> <div class="touch-zone touch-right" onclick="nextPhoto()"></div>
+55 -174
View File
@@ -1,183 +1,64 @@
// === Frambe - Frontend Application === // === Frambe v1.4.0 - Client with WebSocket Remote Control ===
(function () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, activeLayer = 'a', slideshowTimer = null; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null; var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false; var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false, pileCanvas, pileCtx;
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
var wsConn = null, clientId = null, isSleeping = false;
var $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'); // === WEBSOCKET ===
var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content'); function connectWebSocket(){var proto=location.protocol==='https:'?'wss:':'ws:';wsConn=new WebSocket(proto+'//'+location.host+'/ws');wsConn.onopen=function(){console.log('[Frambe] WebSocket connected');wsConn.send(JSON.stringify({type:'register',role:'frame',status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}}catch(err){}};wsConn.onclose=function(){setTimeout(connectWebSocket,5000);};}
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail'); function sendStatus(s){if(wsConn&&wsConn.readyState===WebSocket.OPEN)wsConn.send(JSON.stringify({type:'status',status:s,currentAlbum:selectedAlbumId,config:getCurrentConfig()}));}
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start'); function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};}
var $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b'); 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;}}
var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock'); 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');}
var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info'); 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');}
var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay'); 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');}
var $btnSettings = document.getElementById('btn-settings'), $progressBar = document.getElementById('progress-bar'); function exitSlideshowInternal(){isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();}
function getUrlParams() { // === INIT ===
var p = {}, s = window.location.search.substring(1); if (!s) return p; 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;}
var pairs = s.split('&'); async function autoLaunch(src,aid,pid){urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launch: '+src);await doStartSlideshow();}
for (var i = 0; i < pairs.length; i++) { var kv = pairs[i].split('='); p[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); } 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);}}
return p; 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>';}}
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 init() { // === CANVAS PILE ===
document.body.classList.add('setup-mode'); 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);}
try { 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);}}
config = await (await fetch('/api/config')).json(); function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.fillStyle='rgba(150,120,70,0.2)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
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) { urlDriven = true; selectedSource = 'album'; selectedAlbumId = params.album; $btnStart.disabled = false; startSlideshow(); return; }
if (params.person) { urlDriven = true; selectedSource = 'person'; selectedPersonId = params.person; $btnStart.disabled = false; startSlideshow(); return; }
if (params.favorites === '' || params.favorites === 'true' || params.favorites === '1') { urlDriven = true; selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; }
if (params.random === '' || params.random === 'true' || params.random === '1') { urlDriven = true; selectedSource = 'random'; $btnStart.disabled = false; startSlideshow(); return; }
if (config.albumId) { selectedSource = 'album'; selectedAlbumId = config.albumId; $btnStart.disabled = false; startSlideshow(); return; }
if (config.showFavoritesOnly) { selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; }
await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); }
}
function showError(msg) { $setupContent.style.display = 'none'; $setupError.style.display = 'block'; $errorDetail.textContent = msg; } // === SLIDESHOW ===
async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML='<span class="spinner"></span> Loading…';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}}
async function loadAlbums() { window.startSlideshow=function(){doStartSlideshow();};
try { window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');};
var albums = await (await fetch('/api/albums')).json(); function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);}
if (!albums.length) { $albumsList.innerHTML = '<p class="loading-text">No albums found</p>'; return; } function showPrevAsset(){currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex);}
var html = ''; 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';}}
for (var i = 0; i < albums.length; i++) { 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);}
var a = albums[i], thu = a.albumThumbnailAssetId ? '/api/assets/' + a.albumThumbnailAssetId + '/thumbnail?size=thumbnail' : ''; function stopVideo(){if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;}}
html += '<div class="album-item" data-id="' + a.id + '" onclick="selectAlbum(\'' + a.id + '\', this)">'; 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(' · ');}
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>'; 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%';}}
html += '<div class="album-info"><div class="album-name">' + escapeHtml(a.albumName) + '</div><div class="album-count">' + a.assetCount + ' photos</div></div></div>'; 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');}};
$albumsList.innerHTML = html; function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
} catch (e) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; } 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.selectSource = function (src) { 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();}}
selectedSource = src; selectedAlbumId = null; selectedPersonId = null; 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;}}
document.getElementById('btn-all-photos').classList.toggle('selected', src === 'random'); function padZero(n){return n<10?'0'+n:''+n;}
document.getElementById('btn-favorites').classList.toggle('selected', src === 'favorites'); function formatDate(d){var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d.getDate()+' '+m[d.getMonth()]+' '+d.getFullYear();}
var items = document.querySelectorAll('.album-item'); function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
for (var i = 0; i < items.length; i++) items[i].classList.remove('selected'); async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
$btnStart.disabled = false; 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){}}
window.selectAlbum = function (id, el) { init();requestWakeLock();preventSleep();
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() {
if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); }
else { assets = await (await fetch('/api/assets/random?count=100')).json(); }
if (config.shuffle) shuffleArray(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 ' + added + ' new photo(s)');
} catch (e) { console.warn('Frambe: refresh failed', e.message); }
}, (config.refreshInterval || 300) * 1000);
}
window.startSlideshow = async function () {
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;
var t = (config.transitionDuration || 2) * 1000;
$layerA.style.transition = 'opacity ' + t + 'ms ease'; $layerB.style.transition = 'opacity ' + t + 'ms ease';
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
if (!config.showClock) $clock.style.display = 'none'; if (!config.showDate) $dateDisplay.style.display = 'none';
if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showProgress) $progressBar.style.display = 'none';
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000);
currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer();
} catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); }
};
window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; }
isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer);
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode');
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
};
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
function showPhoto(idx) {
if (!assets[idx]) return; clearTimeout(slideshowTimer);
var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview';
var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url;
preloadNext(idx + 1);
}
function displayImage(url, asset) {
var fit = config.imageFit || 'contain', inc, out;
if (activeLayer === 'a') { inc = $layerB; out = $layerA; activeLayer = 'b'; } else { inc = $layerA; out = $layerB; activeLayer = 'a'; }
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit;
inc.classList.add('active'); out.classList.remove('active');
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
updateExifInfo(asset); startProgress();
slideshowTimer = setTimeout(showNextPhoto, (config.slideshowInterval || 30) * 1000);
}
function preloadNext(i) { if (i >= assets.length) i = 0; if (!assets[i]) return; var img = new Image(); img.src = '/api/assets/' + assets[i].id + '/thumbnail?size=preview'; }
function updateExifInfo(a) {
if (!config.showExif || !a.exifInfo) { $exifInfo.textContent = ''; return; }
var p = [], e = a.exifInfo, loc = [e.city, e.state, e.country].filter(Boolean).join(', ');
if (loc) p.push('📍 ' + loc);
if (e.dateTimeOriginal) p.push(formatDate(new Date(e.dateTimeOriginal)));
else if (a.fileCreatedAt) p.push(formatDate(new Date(a.fileCreatedAt)));
if (e.make || e.model) p.push('📷 ' + [e.make, e.model].filter(Boolean).join(' '));
$exifInfo.textContent = p.join(' • ');
}
function startProgress() {
if (!config.showProgress) return;
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth;
$progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + 'ms linear'; $progressFill.style.width = '100%';
}
function updateClock() {
var n = new Date();
if (config.showClock) $clock.textContent = padZero(n.getHours()) + ':' + padZero(n.getMinutes());
if (config.showDate) $dateDisplay.textContent = n.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
window.toggleOverlay = function () { overlayVisible = !overlayVisible; if (overlayVisible) { $overlay.classList.remove('hidden'); $btnSettings.classList.add('visible'); scheduleOverlayHide(); } else { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); } };
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
document.addEventListener('keydown', function (e) { if (!isRunning) return; switch (e.key) { case 'ArrowRight': case ' ': e.preventDefault(); nextPhoto(); break; case 'ArrowLeft': e.preventDefault(); prevPhoto(); break; case 'Escape': exitSlideshow(); break; case 'f': toggleFullscreen(); break; case 'i': toggleOverlay(); break; } });
function toggleFullscreen() { if (!document.fullscreenElement && !document.webkitFullscreenElement) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } }
function shuffleArray(arr) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } }
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
function formatDate(d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return d.getDate() + ' ' + m[d.getMonth()] + ' ' + d.getFullYear(); }
function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && isRunning) requestWakeLock(); });
function preventSleep() { try { var v = document.createElement('video'); v.setAttribute('playsinline',''); v.setAttribute('muted',''); v.setAttribute('loop',''); v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0.01'; v.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA'; document.body.appendChild(v); v.play().catch(function(){}); } catch(e){} }
init(); requestWakeLock(); preventSleep();
})(); })();
+79 -122
View File
@@ -1,11 +1,14 @@
const express = require('express'); const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
const http = require('http');
const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.4.0';
const app = express(); const app = express();
const PORT = process.env.PORT || 3030; const server = http.createServer(app);
const PORT = process.env.PORT || 3000;
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || ''; const API_KEY = process.env.IMMICH_API_KEY || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30; const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
@@ -20,131 +23,85 @@ const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || ''; const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300; const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
function immichHeaders() { function immichHeaders() { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; }
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; function log(msg) { console.log('[Frambe] ' + msg); }
} function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
app.use(express.static(path.join(__dirname, 'public'))); const clients = new Map();
let clientNameStore = {};
function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; }
function generateClientId(ip) { return ip.replace(/[.:]/g, '_'); }
function broadcastToAdmins(msg) { const d = JSON.stringify(msg); clients.forEach(c => { if (c.role === 'admin' && c.ws.readyState === WebSocket.OPEN) c.ws.send(d); }); }
function getClientList() { const list = []; clients.forEach((c, id) => { if (c.role === 'frame') list.push({ id, ip: c.ip, name: c.name || clientNameStore[c.ip] || '', status: c.status || 'unknown', connectedAt: c.connectedAt, lastSeen: c.lastSeen, config: c.config || {} }); }); return list; }
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
const ip = getClientIp(req);
const clientId = generateClientId(ip) + '_' + Date.now();
log('WebSocket connected: ' + ip + ' (' + clientId + ')');
const info = { ws, ip, role: 'frame', name: clientNameStore[ip] || '', status: 'connected', connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), config: {} };
clients.set(clientId, info);
ws.send(JSON.stringify({ type: 'welcome', clientId, name: info.name }));
ws.on('message', raw => {
try {
const msg = JSON.parse(raw); info.lastSeen = new Date().toISOString();
switch (msg.type) {
case 'register':
info.role = msg.role || 'frame';
if (msg.role === 'admin') { log('Admin connected from ' + ip); ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() })); }
else { log('Frame registered: ' + ip); info.status = msg.status || 'idle'; info.config = msg.config || {}; broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
break;
case 'status':
info.status = msg.status || info.status; if (msg.config) info.config = msg.config;
broadcastToAdmins({ type: 'clientUpdate', clientId, client: { id: clientId, ip: info.ip, name: info.name, status: info.status, lastSeen: info.lastSeen, config: info.config } });
break;
case 'adminCommand':
const target = clients.get(msg.targetId);
if (target && target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'command', action: msg.action, payload: msg.payload })); log('Command ' + msg.action + ' -> ' + msg.targetId); }
else ws.send(JSON.stringify({ type: 'error', message: 'Client not found' }));
break;
case 'renameClient':
const rt = clients.get(msg.targetId);
if (rt) { rt.name = msg.name; clientNameStore[rt.ip] = msg.name; log('Renamed ' + msg.targetId + ' -> "' + msg.name + '"'); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
break;
}
} catch (e) { logErr('WS parse error: ' + e.message); }
});
ws.on('close', () => { log('WebSocket disconnected: ' + ip); clients.delete(clientId); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); });
});
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, fp) => { if (fp.endsWith('.html') || fp.endsWith('.js') || fp.endsWith('.css')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } } }));
app.use(express.json()); app.use(express.json());
app.get('/api/config', (_req, res) => { function mapAsset(a) { return { id: a.id, type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null }; }
res.json({ function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); }
slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL,
connected: !!API_KEY,
});
});
app.get('/api/server-info', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json({ ok: true, version: await r.json() });
} catch (err) { res.status(502).json({ ok: false, error: err.message }); }
});
app.get('/api/albums', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const albums = await r.json();
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
function mapAsset(a) {
return {
id: a.id, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null,
};
}
app.get('/api/albums/:id', async (req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const album = await r.json();
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/people', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/people/:id', async (req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const assets = await r.json();
res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/people/:id/thumbnail', async (req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/random', async (req, res) => {
try {
const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/favorites', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/thumbnail', async (req, res) => {
try {
const size = req.query.size || 'preview';
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/original', async (req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); }
});
app.get('/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 }); });
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const v = await r.json(); log('Immich OK v' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); } catch (e) { logErr('Immich failed: ' + e.message); res.status(502).json({ ok: false, error: e.message }); } });
app.get('/api/albums', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); log('Album "' + al.albumName + '": ' + a.length + ' assets'); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { logErr('Album: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${c}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`${r.status}`); const d = await r.json(); res.json(filterAssets(d.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${req.query.size || 'preview'}`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/video/playback`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/admin', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); });
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`); log('--- Frambe v' + VERSION + ' ---');
console.log(`📡 Immich server: ${IMMICH_URL}`); log('Server listening on port ' + PORT);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); log('Admin dashboard: http://0.0.0.0:' + PORT + '/admin');
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); log('WebSocket: ws://0.0.0.0:' + PORT + '/ws');
console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`); log('Immich URL: ' + IMMICH_URL);
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
log('Waiting for connections...');
}); });