Compare commits
45 Commits
v1.0.0
...
v1.4.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| cc36a6fed4 | |||
| e2be524b2a | |||
| 4b9db2af5a | |||
| 1863da1a28 | |||
| 5a6536c6ca | |||
| fc470f4df2 | |||
| 7f7ea978b9 | |||
| dc1f3402d6 | |||
| c1ebbaef98 | |||
| 1b45afb6f4 | |||
| d1163511fa | |||
| 804c6cfd86 | |||
| 65993839a7 | |||
| d6b6464171 | |||
| 0f4a995cdc | |||
| ad7a2b8250 | |||
| 9ab2dc7578 | |||
| c2b40db843 | |||
| 6943d0c0dd | |||
| 4f2f6ee555 | |||
| f2cc8b4413 | |||
| 4d85d58526 | |||
| 68dbf9dc59 | |||
| 4f7d30d3d2 | |||
| cac179754c | |||
| a609ed2165 | |||
| 2ea13fcbaa | |||
| 6c26688244 | |||
| 88d79a9569 | |||
| 1c55aa9418 | |||
| 3cfcc3f983 | |||
| 245766d841 | |||
| d32180d352 | |||
| 4feaaf30d0 | |||
| be6f0fc30f | |||
| 00bfa926e4 | |||
| ea2828d071 | |||
| 2409ab882e | |||
| 2c4a64c73c | |||
| 1756035cf8 | |||
| 96d9c0960a | |||
| d31abb68c8 | |||
| 597c5fd883 | |||
| 7009c6e3fa | |||
| a9c55ba861 |
+4
-3
@@ -1,4 +1,4 @@
|
||||
# === ImmichFrame Configuration ===
|
||||
# === Frambe Configuration ===
|
||||
|
||||
# REQUIRED
|
||||
IMMICH_URL=http://your-immich-server:2283
|
||||
@@ -10,6 +10,7 @@ TRANSITION_DURATION=2
|
||||
IMAGE_FIT=contain
|
||||
SHUFFLE=true
|
||||
BACKGROUND_BLUR=true
|
||||
REFRESH_INTERVAL=300
|
||||
|
||||
# Overlays
|
||||
SHOW_CLOCK=true
|
||||
@@ -17,9 +18,9 @@ SHOW_DATE=true
|
||||
SHOW_EXIF=true
|
||||
SHOW_PROGRESS=true
|
||||
|
||||
# Auto-start (optional)
|
||||
# Auto-start (optional — or use URL params instead)
|
||||
# ALBUM_ID=
|
||||
# SHOW_FAVORITES_ONLY=false
|
||||
|
||||
# Server
|
||||
# Server (internal port — Docker maps externally via docker-compose)
|
||||
PORT=3000
|
||||
|
||||
+2
-9
@@ -1,28 +1,21 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
LABEL maintainer="ImmichFrame"
|
||||
LABEL description="Lightweight digital photo frame for Immich"
|
||||
LABEL maintainer="Frambe"
|
||||
LABEL description="Frambe — lightweight digital photo frame for Immich"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY package.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production && npm cache clean --force
|
||||
|
||||
# Copy application
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/config || exit 1
|
||||
|
||||
# Run as non-root
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -S appuser -u 1001 -G appgroup
|
||||
USER appuser
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# 🖼️ ImmichFrame
|
||||
# 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.
|
||||
|
||||
@@ -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)
|
||||
- **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
|
||||
- **Background Blur** — Blurred backdrop fills the space behind non-covering images
|
||||
- **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
|
||||
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
|
||||
- **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
|
||||
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
|
||||
- **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
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git
|
||||
cd immich-frame
|
||||
git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
|
||||
cd frambe
|
||||
```
|
||||
|
||||
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
|
||||
@@ -40,21 +46,34 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
|
||||
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
|
||||
|
||||
```bash
|
||||
docker build -t immich-frame .
|
||||
docker build -t frambe .
|
||||
docker run -d \
|
||||
--name immich-frame \
|
||||
-p 3000:3000 \
|
||||
--name frambe \
|
||||
-p 3030:3000 \
|
||||
-e IMMICH_URL=http://your-immich-server:2283 \
|
||||
-e IMMICH_API_KEY=your-api-key \
|
||||
--restart unless-stopped \
|
||||
immich-frame
|
||||
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
|
||||
|
||||
All settings are via environment variables:
|
||||
@@ -72,9 +91,10 @@ All settings are via environment variables:
|
||||
| `SHOW_DATE` | `true` | Display date overlay |
|
||||
| `SHOW_EXIF` | `true` | Display photo metadata |
|
||||
| `SHOW_PROGRESS` | `true` | Display progress bar |
|
||||
| `ALBUM_ID` | *(empty)* | Auto-start with specific album |
|
||||
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
|
||||
| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
|
||||
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
|
||||
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
|
||||
|
||||
## 🎮 Controls
|
||||
|
||||
@@ -92,7 +112,7 @@ All settings are via environment variables:
|
||||
|
||||
## 📱 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
|
||||
3. Enable kiosk mode or guided access to lock to the app
|
||||
4. Disable screen timeout in your device settings
|
||||
@@ -101,15 +121,18 @@ All settings are via environment variables:
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Browser │ HTTP │ ImmichFrame │ API │ Immich │
|
||||
│ Browser │ HTTP │ Frambe │ API │ Immich │
|
||||
│ (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
|
||||
|
||||
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
|
||||
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
|
||||
- **1.1.0** — Rebrand to Frambe
|
||||
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
|
||||
|
||||
## 📄 License
|
||||
|
||||
+19
-19
@@ -1,31 +1,31 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
immich-frame:
|
||||
frambe:
|
||||
build: .
|
||||
container_name: immich-frame
|
||||
container_name: frambe
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3030:3000"
|
||||
environment:
|
||||
# REQUIRED: Your Immich server URL (no trailing slash)
|
||||
# REQUIRED
|
||||
- IMMICH_URL=http://your-immich-server:2283
|
||||
# REQUIRED: Your Immich API key
|
||||
- IMMICH_API_KEY=your-api-key-here
|
||||
|
||||
# OPTIONAL: Slideshow settings
|
||||
- SLIDESHOW_INTERVAL=30 # Seconds between photos
|
||||
- TRANSITION_DURATION=2 # Crossfade duration in seconds
|
||||
- IMAGE_FIT=contain # 'contain' or 'cover'
|
||||
- SHUFFLE=true # Randomise photo order
|
||||
- BACKGROUND_BLUR=true # Blurred background behind photos
|
||||
# Slideshow
|
||||
- SLIDESHOW_INTERVAL=30
|
||||
- TRANSITION_DURATION=2
|
||||
- IMAGE_FIT=contain
|
||||
- SHUFFLE=true
|
||||
- BACKGROUND_BLUR=true
|
||||
- REFRESH_INTERVAL=300 # Seconds between album/person refresh checks
|
||||
|
||||
# OPTIONAL: Overlay settings
|
||||
- SHOW_CLOCK=true # Show time overlay
|
||||
- SHOW_DATE=true # Show date overlay
|
||||
- SHOW_EXIF=true # Show photo location/camera info
|
||||
- SHOW_PROGRESS=true # Show progress bar
|
||||
# Overlays
|
||||
- SHOW_CLOCK=true
|
||||
- SHOW_DATE=true
|
||||
- SHOW_EXIF=true
|
||||
- SHOW_PROGRESS=true
|
||||
|
||||
# OPTIONAL: Auto-start with specific album or favorites
|
||||
# - ALBUM_ID= # Auto-start with this album UUID
|
||||
# - SHOW_FAVORITES_ONLY=false # Auto-start showing only favorites
|
||||
# Auto-start (optional — or use URL params instead)
|
||||
# - ALBUM_ID=
|
||||
# - SHOW_FAVORITES_ONLY=false
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "immich-frame",
|
||||
"version": "1.0.0",
|
||||
"description": "A lightweight digital photo frame web app for Immich",
|
||||
"name": "frambe",
|
||||
"version": "1.4.0",
|
||||
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
@@ -9,7 +9,8 @@
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -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
@@ -1,449 +1,147 @@
|
||||
/* === Reset & Base === */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* === Reset === */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; }
|
||||
body.setup-mode { cursor: default; }
|
||||
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
cursor: none;
|
||||
}
|
||||
/* === SETUP === */
|
||||
#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; }
|
||||
.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; }
|
||||
.subtitle { font-size: 0.95rem; color: #888; }
|
||||
.subtitle.connected { color: #4ade80; }
|
||||
.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
|
||||
.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; }
|
||||
.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); }
|
||||
.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; }
|
||||
.source-icon { font-size: 1.5rem; }
|
||||
.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; }
|
||||
.loading-text { text-align: center; color: #666; padding: 1rem; }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* === 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;
|
||||
}
|
||||
/* =============================================
|
||||
SLIDESHOW - VINTAGE POLAROID PILE
|
||||
============================================= */
|
||||
#slideshow-screen { background: #1e1a14; overflow: hidden; }
|
||||
|
||||
/* Background — near-full sepia */
|
||||
.bg-blur {
|
||||
position: absolute;
|
||||
top: -20px; left: -20px;
|
||||
width: calc(100% + 40px);
|
||||
height: calc(100% + 40px);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(30px) brightness(0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease;
|
||||
z-index: 1;
|
||||
position: absolute; top: -30px; left: -30px;
|
||||
width: calc(100% + 60px); height: calc(100% + 60px);
|
||||
background-size: cover; background-position: center;
|
||||
filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
|
||||
opacity: 0; transition: opacity 3s ease; z-index: 1;
|
||||
}
|
||||
.bg-blur.visible { opacity: 1; }
|
||||
|
||||
.bg-blur.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
/* Canvas pile */
|
||||
#pile-canvas {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 2; pointer-events: none;
|
||||
transition: opacity 2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.photo-layer.active {
|
||||
opacity: 1;
|
||||
/* Vignette */
|
||||
.bg-vignette {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%);
|
||||
z-index: 3; pointer-events: none;
|
||||
}
|
||||
|
||||
/* === 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;
|
||||
/* --- Centering wrapper (flexbox — works on all resolutions) --- */
|
||||
.main-frame-wrapper {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 5; pointer-events: none; overflow: visible;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
/* --- Main frame — no transform centering, flexbox does it --- */
|
||||
.main-frame {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.overlay-top-right {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 2rem;
|
||||
text-align: right;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8), 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.date-display {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.overlay-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.exif-info {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.75rem;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s linear;
|
||||
}
|
||||
|
||||
/* === Touch Zones === */
|
||||
.touch-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.touch-left {
|
||||
left: 0;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.touch-center {
|
||||
left: 20%;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.touch-right {
|
||||
right: 0;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* === Settings Button === */
|
||||
.settings-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-btn.visible {
|
||||
opacity: 1;
|
||||
transition: opacity 1.2s ease;
|
||||
animation: float 90s linear infinite;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.main-frame.visible { opacity: 1; }
|
||||
|
||||
/* === Scrollbar Styling === */
|
||||
.albums-list::-webkit-scrollbar,
|
||||
.setup-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
/* Slow drift + slight constant rotation */
|
||||
@keyframes float {
|
||||
0% { transform: translate(0, 0) rotate(1.5deg); }
|
||||
100% { transform: translate(8px, -5px) rotate(1.5deg); }
|
||||
}
|
||||
|
||||
.albums-list::-webkit-scrollbar-track,
|
||||
.setup-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
|
||||
.main-frame .frame-border {
|
||||
background: #ede8df;
|
||||
padding: 1.2vmin 1.2vmin 4vmin 1.2vmin;
|
||||
box-shadow:
|
||||
0 6px 40px rgba(0,0,0,0.6),
|
||||
0 2px 6px rgba(0,0,0,0.3),
|
||||
inset 0 0 0 1px rgba(0,0,0,0.05);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.albums-list::-webkit-scrollbar-thumb,
|
||||
.setup-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
/* Large main image — allowed to overhang slightly */
|
||||
.main-frame .frame-media {
|
||||
display: block;
|
||||
max-width: 93vw;
|
||||
max-height: 85vh;
|
||||
width: auto; height: auto;
|
||||
object-fit: contain;
|
||||
background: #2a2520;
|
||||
}
|
||||
|
||||
/* === 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) {
|
||||
.setup-container {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.setup-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.clock {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.overlay-top-right {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
.overlay-bottom {
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
.source-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
.setup-container { padding: 1.25rem; }
|
||||
.setup-header h1 { font-size: 1.6rem; }
|
||||
.clock { font-size: 1.8rem; }
|
||||
.overlay-top-right { top: 1rem; right: 1rem; }
|
||||
.overlay-bottom { padding: 1rem 1rem 0.5rem; }
|
||||
.source-buttons { flex-direction: column; }
|
||||
.main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
|
||||
.main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
|
||||
}
|
||||
|
||||
/* === Loading Spinner === */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* === Fade-in Animation === */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.album-item {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
|
||||
.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
|
||||
.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
+22
-35
@@ -6,43 +6,31 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>ImmichFrame</title>
|
||||
<meta name="theme-color" content="#1a1510">
|
||||
<title>Frambe</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Setup Screen -->
|
||||
<div id="setup-screen" class="screen">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<h1>🖼️ ImmichFrame</h1>
|
||||
<img src="/img/icon.png" alt="Frambe" class="setup-logo">
|
||||
<h1>Frambe</h1>
|
||||
<p class="subtitle" id="connection-status">Connecting to Immich…</p>
|
||||
</div>
|
||||
|
||||
<div id="setup-content" class="setup-content">
|
||||
<!-- Album Selection -->
|
||||
<div class="section">
|
||||
<h2>Select Photo Source</h2>
|
||||
<div class="source-buttons">
|
||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')">
|
||||
<span class="source-icon">🎲</span>
|
||||
<span>Random Photos</span>
|
||||
</button>
|
||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
|
||||
<span class="source-icon">⭐</span>
|
||||
<span>Favorites</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="albums-list" class="albums-list">
|
||||
<p class="loading-text">Loading albums…</p>
|
||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
|
||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon">⭐</span><span>Favorites</span></button>
|
||||
</div>
|
||||
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Start Button -->
|
||||
<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 id="setup-error" class="setup-error" style="display:none">
|
||||
<p>⚠️ Cannot connect to Immich</p>
|
||||
<p class="error-detail" id="error-detail"></p>
|
||||
@@ -51,16 +39,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slideshow Screen -->
|
||||
<div id="slideshow-screen" class="screen" style="display:none">
|
||||
<!-- Background blur layer -->
|
||||
<div id="bg-blur" class="bg-blur"></div>
|
||||
<canvas id="pile-canvas"></canvas>
|
||||
<div class="bg-vignette"></div>
|
||||
|
||||
<!-- Photo layers (double-buffered for crossfade) -->
|
||||
<div id="photo-layer-a" class="photo-layer active"></div>
|
||||
<div id="photo-layer-b" class="photo-layer"></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>
|
||||
|
||||
<!-- Overlay: Clock & Info -->
|
||||
<div id="overlay" class="overlay">
|
||||
<div class="overlay-top-right">
|
||||
<div id="clock" class="clock"></div>
|
||||
@@ -68,21 +61,15 @@
|
||||
</div>
|
||||
<div class="overlay-bottom">
|
||||
<div id="exif-info" class="exif-info"></div>
|
||||
<div id="progress-bar" class="progress-bar">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Touch/Click controls (invisible) -->
|
||||
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
|
||||
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
|
||||
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
|
||||
|
||||
<!-- Settings button (hidden until overlay shown) -->
|
||||
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()">✕</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+61
-552
@@ -1,555 +1,64 @@
|
||||
// === ImmichFrame - Frontend Application ===
|
||||
// === Frambe v1.4.0 - Client with WebSocket Remote Control ===
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- State ---
|
||||
var config = {};
|
||||
var assets = [];
|
||||
var currentIndex = -1;
|
||||
var activeLayer = 'a';
|
||||
var slideshowTimer = null;
|
||||
var progressTimer = null;
|
||||
var progressStart = 0;
|
||||
var overlayVisible = true;
|
||||
var overlayTimeout = null;
|
||||
var selectedSource = null;
|
||||
var selectedAlbumId = null;
|
||||
var isRunning = false;
|
||||
var preloadedImages = {};
|
||||
|
||||
// --- DOM Elements ---
|
||||
var $setupScreen = document.getElementById('setup-screen');
|
||||
var $slideshowScreen = document.getElementById('slideshow-screen');
|
||||
var $connectionStatus = document.getElementById('connection-status');
|
||||
var $setupContent = document.getElementById('setup-content');
|
||||
var $setupError = document.getElementById('setup-error');
|
||||
var $errorDetail = document.getElementById('error-detail');
|
||||
var $albumsList = document.getElementById('albums-list');
|
||||
var $btnStart = document.getElementById('btn-start');
|
||||
var $layerA = document.getElementById('photo-layer-a');
|
||||
var $layerB = document.getElementById('photo-layer-b');
|
||||
var $bgBlur = document.getElementById('bg-blur');
|
||||
var $clock = document.getElementById('clock');
|
||||
var $dateDisplay = document.getElementById('date-display');
|
||||
var $exifInfo = document.getElementById('exif-info');
|
||||
var $progressFill = document.getElementById('progress-fill');
|
||||
var $overlay = document.getElementById('overlay');
|
||||
var $btnSettings = document.getElementById('btn-settings');
|
||||
var $progressBar = document.getElementById('progress-bar');
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
document.body.classList.add('setup-mode');
|
||||
try {
|
||||
// Load config
|
||||
var configRes = await fetch('/api/config');
|
||||
config = await configRes.json();
|
||||
|
||||
if (!config.connected) {
|
||||
showError('API key not configured. Set IMMICH_API_KEY in your environment.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
var serverRes = await fetch('/api/server-info');
|
||||
var serverInfo = await serverRes.json();
|
||||
|
||||
if (!serverInfo.ok) {
|
||||
showError('Cannot reach Immich server: ' + serverInfo.error);
|
||||
return;
|
||||
}
|
||||
|
||||
$connectionStatus.textContent = 'Connected to Immich v' + serverInfo.version.major + '.' + serverInfo.version.minor + '.' + serverInfo.version.patch;
|
||||
$connectionStatus.classList.add('connected');
|
||||
|
||||
// Load albums
|
||||
await loadAlbums();
|
||||
|
||||
// If a default album is set, auto-start
|
||||
if (config.albumId) {
|
||||
selectedSource = 'album';
|
||||
selectedAlbumId = config.albumId;
|
||||
$btnStart.disabled = false;
|
||||
startSlideshow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.showFavoritesOnly) {
|
||||
selectedSource = 'favorites';
|
||||
$btnStart.disabled = false;
|
||||
startSlideshow();
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
showError('Failed to initialize: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
$setupContent.style.display = 'none';
|
||||
$setupError.style.display = 'block';
|
||||
$errorDetail.textContent = msg;
|
||||
}
|
||||
|
||||
// --- Albums ---
|
||||
async function loadAlbums() {
|
||||
try {
|
||||
var res = await fetch('/api/albums');
|
||||
var albums = await res.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];
|
||||
var thumbUrl = a.albumThumbnailAssetId
|
||||
? '/api/assets/' + a.albumThumbnailAssetId + '/thumbnail?size=thumbnail'
|
||||
: '';
|
||||
html += '<div class="album-item" data-id="' + a.id + '" onclick="selectAlbum(\'' + a.id + '\', this)">';
|
||||
if (thumbUrl) {
|
||||
html += '<img class="album-thumb" src="' + thumbUrl + '" alt="" loading="lazy">';
|
||||
} else {
|
||||
html += '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem;">📁</div>';
|
||||
}
|
||||
html += '<div class="album-info">';
|
||||
html += '<div class="album-name">' + escapeHtml(a.albumName) + '</div>';
|
||||
html += '<div class="album-count">' + a.assetCount + ' photos</div>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
$albumsList.innerHTML = html;
|
||||
} catch (err) {
|
||||
$albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Source Selection ---
|
||||
window.selectSource = function (source) {
|
||||
selectedSource = source;
|
||||
selectedAlbumId = null;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('btn-all-photos').classList.toggle('selected', source === 'random');
|
||||
document.getElementById('btn-favorites').classList.toggle('selected', source === 'favorites');
|
||||
|
||||
// Deselect albums
|
||||
var albumItems = document.querySelectorAll('.album-item');
|
||||
for (var i = 0; i < albumItems.length; i++) {
|
||||
albumItems[i].classList.remove('selected');
|
||||
}
|
||||
|
||||
$btnStart.disabled = false;
|
||||
};
|
||||
|
||||
window.selectAlbum = function (albumId, el) {
|
||||
selectedSource = 'album';
|
||||
selectedAlbumId = albumId;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('btn-all-photos').classList.remove('selected');
|
||||
document.getElementById('btn-favorites').classList.remove('selected');
|
||||
|
||||
var albumItems = document.querySelectorAll('.album-item');
|
||||
for (var i = 0; i < albumItems.length; i++) {
|
||||
albumItems[i].classList.remove('selected');
|
||||
}
|
||||
el.classList.add('selected');
|
||||
|
||||
$btnStart.disabled = false;
|
||||
};
|
||||
|
||||
// --- Load Assets ---
|
||||
async function loadAssets() {
|
||||
var res;
|
||||
if (selectedSource === 'album' && selectedAlbumId) {
|
||||
res = await fetch('/api/albums/' + selectedAlbumId);
|
||||
var album = await res.json();
|
||||
assets = album.assets || [];
|
||||
} else if (selectedSource === 'favorites') {
|
||||
res = await fetch('/api/assets/favorites');
|
||||
assets = await res.json();
|
||||
} else {
|
||||
res = await fetch('/api/assets/random?count=100');
|
||||
assets = await res.json();
|
||||
}
|
||||
|
||||
if (config.shuffle) {
|
||||
shuffleArray(assets);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slideshow Control ---
|
||||
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;
|
||||
}
|
||||
|
||||
// Switch to slideshow
|
||||
$setupScreen.style.display = 'none';
|
||||
$slideshowScreen.style.display = 'block';
|
||||
document.body.classList.remove('setup-mode');
|
||||
isRunning = true;
|
||||
|
||||
// Apply config
|
||||
var transMs = (config.transitionDuration || 2) * 1000;
|
||||
$layerA.style.transition = 'opacity ' + transMs + 'ms ease';
|
||||
$layerB.style.transition = 'opacity ' + transMs + 'ms ease';
|
||||
$bgBlur.style.transition = 'opacity ' + (transMs * 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';
|
||||
|
||||
// Start clock
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Show first photo
|
||||
currentIndex = -1;
|
||||
showNextPhoto();
|
||||
|
||||
// Auto-hide overlay
|
||||
scheduleOverlayHide();
|
||||
|
||||
} catch (err) {
|
||||
$btnStart.textContent = 'Error: ' + err.message;
|
||||
setTimeout(function () {
|
||||
$btnStart.textContent = '▶ Start Slideshow';
|
||||
$btnStart.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
window.exitSlideshow = function () {
|
||||
isRunning = false;
|
||||
clearTimeout(slideshowTimer);
|
||||
clearInterval(progressTimer);
|
||||
|
||||
$slideshowScreen.style.display = 'none';
|
||||
$setupScreen.style.display = 'flex';
|
||||
document.body.classList.add('setup-mode');
|
||||
|
||||
$btnStart.textContent = '▶ Start Slideshow';
|
||||
$btnStart.disabled = false;
|
||||
|
||||
// Reset layers
|
||||
$layerA.style.backgroundImage = '';
|
||||
$layerB.style.backgroundImage = '';
|
||||
$bgBlur.style.backgroundImage = '';
|
||||
$layerA.classList.add('active');
|
||||
$layerB.classList.remove('active');
|
||||
$bgBlur.classList.remove('visible');
|
||||
activeLayer = 'a';
|
||||
};
|
||||
|
||||
// --- Photo Display ---
|
||||
function showNextPhoto() {
|
||||
currentIndex++;
|
||||
if (currentIndex >= assets.length) {
|
||||
// Reload and reshuffle for infinite loop
|
||||
if (config.shuffle) shuffleArray(assets);
|
||||
currentIndex = 0;
|
||||
}
|
||||
showPhoto(currentIndex);
|
||||
}
|
||||
|
||||
function showPrevPhoto() {
|
||||
currentIndex--;
|
||||
if (currentIndex < 0) currentIndex = assets.length - 1;
|
||||
showPhoto(currentIndex);
|
||||
}
|
||||
|
||||
function showPhoto(index) {
|
||||
if (!assets[index]) return;
|
||||
|
||||
clearTimeout(slideshowTimer);
|
||||
clearInterval(progressTimer);
|
||||
|
||||
var asset = assets[index];
|
||||
var imageUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
|
||||
|
||||
// Preload the image before showing
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
displayImage(imageUrl, asset);
|
||||
};
|
||||
img.onerror = function () {
|
||||
// Skip broken images
|
||||
setTimeout(showNextPhoto, 500);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
|
||||
// Preload next image
|
||||
preloadNext(index + 1);
|
||||
}
|
||||
|
||||
function displayImage(url, asset) {
|
||||
var fitStyle = config.imageFit || 'contain';
|
||||
var incomingLayer, outgoingLayer;
|
||||
|
||||
if (activeLayer === 'a') {
|
||||
incomingLayer = $layerB;
|
||||
outgoingLayer = $layerA;
|
||||
activeLayer = 'b';
|
||||
} else {
|
||||
incomingLayer = $layerA;
|
||||
outgoingLayer = $layerB;
|
||||
activeLayer = 'a';
|
||||
}
|
||||
|
||||
// Set the image
|
||||
incomingLayer.style.backgroundImage = 'url(' + url + ')';
|
||||
incomingLayer.style.backgroundSize = fitStyle;
|
||||
|
||||
// Crossfade
|
||||
incomingLayer.classList.add('active');
|
||||
outgoingLayer.classList.remove('active');
|
||||
|
||||
// Background blur
|
||||
if (config.backgroundBlur) {
|
||||
$bgBlur.style.backgroundImage = 'url(' + url + ')';
|
||||
$bgBlur.classList.add('visible');
|
||||
}
|
||||
|
||||
// Update EXIF info
|
||||
updateExifInfo(asset);
|
||||
|
||||
// Progress bar
|
||||
startProgress();
|
||||
|
||||
// Schedule next
|
||||
var interval = (config.slideshowInterval || 30) * 1000;
|
||||
slideshowTimer = setTimeout(showNextPhoto, interval);
|
||||
}
|
||||
|
||||
function preloadNext(index) {
|
||||
if (index >= assets.length) index = 0;
|
||||
if (!assets[index]) return;
|
||||
var img = new Image();
|
||||
img.src = '/api/assets/' + assets[index].id + '/thumbnail?size=preview';
|
||||
}
|
||||
|
||||
// --- EXIF Info ---
|
||||
function updateExifInfo(asset) {
|
||||
if (!config.showExif || !asset.exifInfo) {
|
||||
$exifInfo.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = [];
|
||||
var exif = asset.exifInfo;
|
||||
|
||||
// Location
|
||||
var location = [exif.city, exif.state, exif.country].filter(Boolean).join(', ');
|
||||
if (location) parts.push('📍 ' + location);
|
||||
|
||||
// Date
|
||||
if (exif.dateTimeOriginal) {
|
||||
var d = new Date(exif.dateTimeOriginal);
|
||||
parts.push(formatDate(d));
|
||||
} else if (asset.fileCreatedAt) {
|
||||
var d2 = new Date(asset.fileCreatedAt);
|
||||
parts.push(formatDate(d2));
|
||||
}
|
||||
|
||||
// Camera
|
||||
if (exif.make || exif.model) {
|
||||
var camera = [exif.make, exif.model].filter(Boolean).join(' ');
|
||||
parts.push('📷 ' + camera);
|
||||
}
|
||||
|
||||
$exifInfo.textContent = parts.join(' • ');
|
||||
}
|
||||
|
||||
// --- Progress Bar ---
|
||||
function startProgress() {
|
||||
if (!config.showProgress) return;
|
||||
|
||||
$progressFill.style.transition = 'none';
|
||||
$progressFill.style.width = '0%';
|
||||
|
||||
progressStart = Date.now();
|
||||
var duration = (config.slideshowInterval || 30) * 1000;
|
||||
|
||||
// Force reflow
|
||||
$progressFill.offsetWidth;
|
||||
|
||||
$progressFill.style.transition = 'width ' + duration + 'ms linear';
|
||||
$progressFill.style.width = '100%';
|
||||
}
|
||||
|
||||
// --- Clock ---
|
||||
function updateClock() {
|
||||
var now = new Date();
|
||||
|
||||
if (config.showClock) {
|
||||
var h = now.getHours();
|
||||
var m = now.getMinutes();
|
||||
$clock.textContent = padZero(h) + ':' + padZero(m);
|
||||
}
|
||||
|
||||
if (config.showDate) {
|
||||
var options = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' };
|
||||
$dateDisplay.textContent = now.toLocaleDateString(undefined, options);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overlay Toggle ---
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
window.nextPhoto = function () {
|
||||
showNextPhoto();
|
||||
if (overlayVisible) scheduleOverlayHide();
|
||||
};
|
||||
|
||||
window.prevPhoto = function () {
|
||||
showPrevPhoto();
|
||||
if (overlayVisible) scheduleOverlayHide();
|
||||
};
|
||||
|
||||
// --- Keyboard Controls ---
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fullscreen ---
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
function shuffleArray(arr) {
|
||||
for (var i = arr.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var temp = arr[i];
|
||||
arr[i] = arr[j];
|
||||
arr[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
function padZero(n) {
|
||||
return n < 10 ? '0' + n : '' + n;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return d.getDate() + ' ' + months[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Keep screen awake (where supported) ---
|
||||
async function requestWakeLock() {
|
||||
try {
|
||||
if ('wakeLock' in navigator) {
|
||||
await navigator.wakeLock.request('screen');
|
||||
}
|
||||
} catch (e) {
|
||||
// Not supported or permission denied - that's fine
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible' && isRunning) {
|
||||
requestWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Prevent screen sleep on older devices via video trick ---
|
||||
function preventSleep() {
|
||||
try {
|
||||
var noSleep = document.createElement('video');
|
||||
noSleep.setAttribute('playsinline', '');
|
||||
noSleep.setAttribute('muted', '');
|
||||
noSleep.setAttribute('loop', '');
|
||||
noSleep.style.position = 'absolute';
|
||||
noSleep.style.width = '1px';
|
||||
noSleep.style.height = '1px';
|
||||
noSleep.style.opacity = '0.01';
|
||||
// Tiny transparent video data URI
|
||||
noSleep.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAA' +
|
||||
'htZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAA' +
|
||||
'AAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hk' +
|
||||
'AAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAA' +
|
||||
'AAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
document.body.appendChild(noSleep);
|
||||
noSleep.play().catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// --- Boot ---
|
||||
init();
|
||||
requestWakeLock();
|
||||
preventSleep();
|
||||
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
|
||||
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
|
||||
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
|
||||
var currentVideoPlaying = false, pileCanvas, pileCtx;
|
||||
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df';
|
||||
var wsConn = null, clientId = null, isSleeping = false;
|
||||
var $setupScreen=document.getElementById('setup-screen'),$slideshowScreen=document.getElementById('slideshow-screen'),$connectionStatus=document.getElementById('connection-status'),$setupContent=document.getElementById('setup-content'),$setupError=document.getElementById('setup-error'),$errorDetail=document.getElementById('error-detail'),$albumsList=document.getElementById('albums-list'),$btnStart=document.getElementById('btn-start'),$bgBlur=document.getElementById('bg-blur'),$mainFrame=document.getElementById('main-frame'),$mainPhoto=document.getElementById('main-photo'),$mainVideo=document.getElementById('main-video'),$clock=document.getElementById('clock'),$dateDisplay=document.getElementById('date-display'),$exifInfo=document.getElementById('exif-info'),$progressFill=document.getElementById('progress-fill'),$overlay=document.getElementById('overlay'),$btnSettings=document.getElementById('btn-settings'),$progressBar=document.getElementById('progress-bar');
|
||||
|
||||
// === WEBSOCKET ===
|
||||
function connectWebSocket(){var proto=location.protocol==='https:'?'wss:':'ws:';wsConn=new WebSocket(proto+'//'+location.host+'/ws');wsConn.onopen=function(){console.log('[Frambe] WebSocket connected');wsConn.send(JSON.stringify({type:'register',role:'frame',status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}}catch(err){}};wsConn.onclose=function(){setTimeout(connectWebSocket,5000);};}
|
||||
function sendStatus(s){if(wsConn&&wsConn.readyState===WebSocket.OPEN)wsConn.send(JSON.stringify({type:'status',status:s,currentAlbum:selectedAlbumId,config:getCurrentConfig()}));}
|
||||
function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};}
|
||||
function handleRemoteCommand(action,payload){console.log('[Frambe] Remote: '+action);switch(action){case'setSource':selectedSource=payload.source;selectedAlbumId=payload.albumId||null;selectedPersonId=payload.personId||null;if(isSleeping)wakeUp();if(isRunning){clearTimeout(slideshowTimer);stopVideo();}doStartSlideshow();break;case'start':if(isSleeping)wakeUp();if(!isRunning&&selectedSource)doStartSlideshow();break;case'stop':if(isRunning)exitSlideshowInternal();sendStatus('idle');break;case'next':if(isRunning)showNextAsset();break;case'prev':if(isRunning)showPrevAsset();break;case'sleep':goToSleep();break;case'wake':wakeUp();break;case'refresh':location.reload();break;case'setConfig':applyConfigChange(payload);break;}}
|
||||
function goToSleep(){isSleeping=true;document.body.style.background='#000';if($slideshowScreen)$slideshowScreen.style.display='none';if($setupScreen)$setupScreen.style.display='none';var s=document.getElementById('sleep-overlay');if(!s){s=document.createElement('div');s.id='sleep-overlay';s.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:#000;z-index:9999;';document.body.appendChild(s);}s.style.display='block';if(isRunning){clearTimeout(slideshowTimer);stopVideo();}sendStatus('sleeping');}
|
||||
function wakeUp(){isSleeping=false;document.body.style.background='';var s=document.getElementById('sleep-overlay');if(s)s.style.display='none';if(isRunning)$slideshowScreen.style.display='block';else $setupScreen.style.display='flex';sendStatus(isRunning?'playing':'idle');}
|
||||
function applyConfigChange(c){if('slideshowInterval'in c)config.slideshowInterval=c.slideshowInterval;if('showClock'in c){config.showClock=c.showClock;$clock.style.display=c.showClock?'':'none';}if('showDate'in c){config.showDate=c.showDate;$dateDisplay.style.display=c.showDate?'':'none';}if('showExif'in c){config.showExif=c.showExif;$exifInfo.style.display=c.showExif?'':'none';}if('showProgress'in c){config.showProgress=c.showProgress;$progressBar.style.display=c.showProgress?'':'none';}sendStatus(isRunning?'playing':'idle');}
|
||||
function exitSlideshowInternal(){isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();}
|
||||
|
||||
// === INIT ===
|
||||
function getUrlParams(){var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p;}
|
||||
async function autoLaunch(src,aid,pid){urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launch: '+src);await doStartSlideshow();}
|
||||
async function init(){document.body.classList.add('setup-mode');connectWebSocket();try{config=await(await fetch('/api/config')).json();console.log('[Frambe] v'+(config.version||'?'));if(!config.connected){showError('API key not configured.');return;}var si=await(await fetch('/api/server-info')).json();if(!si.ok){showError('Cannot reach Immich: '+si.error);return;}$connectionStatus.textContent='Connected to Immich v'+si.version.major+'.'+si.version.minor+'.'+si.version.patch;$connectionStatus.classList.add('connected');var p=getUrlParams();if(p.album){await autoLaunch('album',p.album,null);return;}if(p.person){await autoLaunch('person',null,p.person);return;}if('favorites'in p){await autoLaunch('favorites',null,null);return;}if('random'in p){await autoLaunch('random',null,null);return;}if(config.albumId){await autoLaunch('album',config.albumId,null);return;}if(config.showFavoritesOnly){await autoLaunch('favorites',null,null);return;}await loadAlbums();}catch(err){showError('Init failed: '+err.message);}}
|
||||
function showError(msg){$setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg;}
|
||||
async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" onclick="selectAlbum(\''+a.id+'\', this)">';html+=thu?'<img class="album-thumb" src="'+thu+'" alt="" loading="lazy">':'<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">📁</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';}}
|
||||
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);}
|
||||
|
||||
// === CANVAS PILE ===
|
||||
function initPileCanvas(){pileCanvas=document.getElementById('pile-canvas');var d=window.devicePixelRatio||1;pileCanvas.width=window.innerWidth*d;pileCanvas.height=window.innerHeight*d;pileCanvas.style.width=window.innerWidth+'px';pileCanvas.style.height=window.innerHeight+'px';pileCtx=pileCanvas.getContext('2d');pileCtx.scale(d,d);}
|
||||
function clearPileCanvas(){if(pileCtx){pileCtx.setTransform(1,0,0,1,0,0);pileCtx.clearRect(0,0,pileCanvas.width,pileCanvas.height);pileCtx.scale(window.devicePixelRatio||1,window.devicePixelRatio||1);}}
|
||||
function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.fillStyle='rgba(150,120,70,0.2)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
|
||||
|
||||
// === SLIDESHOW ===
|
||||
async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML='<span class="spinner"></span> Loading…';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}}
|
||||
window.startSlideshow=function(){doStartSlideshow();};
|
||||
window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');};
|
||||
function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);}
|
||||
function showPrevAsset(){currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex);}
|
||||
function showAsset(idx){if(!assets[idx])return;clearTimeout(slideshowTimer);stopVideo();var a=assets[idx],isV=a.type==='VIDEO',thu='/api/assets/'+a.id+'/thumbnail?size=preview';if(currentIndex>0){var pi=currentIndex-1;if(pi<0)pi=assets.length-1;if(assets[pi])dropPhotoPile('/api/assets/'+assets[pi].id+'/thumbnail?size=thumbnail');}$mainFrame.classList.remove('visible');var img=new Image();img.onload=function(){setTimeout(function(){displayAsset(a,thu,isV);},500);};img.onerror=function(){setTimeout(showNextAsset,500);};img.src=thu;var ni=idx+1;if(ni>=assets.length)ni=0;if(assets[ni]){var pre=new Image();pre.src='/api/assets/'+assets[ni].id+'/thumbnail?size=preview';}}
|
||||
function displayAsset(a,thu,isV){if(config.backgroundBlur){$bgBlur.style.backgroundImage='url('+thu+')';$bgBlur.classList.add('visible');}$mainVideo.style.display='none';$mainPhoto.style.display='none';if(isV){$mainVideo.style.display='block';$mainVideo.src='/api/assets/'+a.id+'/video';$mainVideo.poster=thu;$mainVideo.load();$mainVideo.play().then(function(){currentVideoPlaying=true;}).catch(function(){});$mainVideo.onended=function(){currentVideoPlaying=false;showNextAsset();};slideshowTimer=setTimeout(function(){if(currentVideoPlaying)showNextAsset();},Math.max((config.slideshowInterval||30)*3,120)*1000);}else{$mainPhoto.style.display='block';$mainPhoto.src=thu;slideshowTimer=setTimeout(showNextAsset,(config.slideshowInterval||30)*1000);}requestAnimationFrame(function(){$mainFrame.classList.add('visible');});updateExifInfo(a);startProgress(isV?null:(config.slideshowInterval||30)*1000);}
|
||||
function stopVideo(){if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;}}
|
||||
function updateExifInfo(a){if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · ');}
|
||||
function startProgress(ms){if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';}}
|
||||
function updateClock(){var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'});}
|
||||
window.toggleOverlay=function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}};
|
||||
function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
|
||||
window.nextPhoto=function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
window.prevPhoto=function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case'ArrowRight':case' ':e.preventDefault();nextPhoto();break;case'ArrowLeft':e.preventDefault();prevPhoto();break;case'Escape':exitSlideshow();break;case'f':toggleFullscreen();break;case'i':toggleOverlay();break;}});
|
||||
function toggleFullscreen(){if(!document.fullscreenElement&&!document.webkitFullscreenElement){var el=document.documentElement;if(el.requestFullscreen)el.requestFullscreen();else if(el.webkitRequestFullscreen)el.webkitRequestFullscreen();}else{if(document.exitFullscreen)document.exitFullscreen();else if(document.webkitExitFullscreen)document.webkitExitFullscreen();}}
|
||||
function shuffleArray(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}}
|
||||
function padZero(n){return n<10?'0'+n:''+n;}
|
||||
function formatDate(d){var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d.getDate()+' '+m[d.getMonth()]+' '+d.getFullYear();}
|
||||
function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
|
||||
async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
|
||||
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
|
||||
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();
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
require('dotenv').config();
|
||||
|
||||
const VERSION = '1.4.0';
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// --- Configuration ---
|
||||
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
|
||||
const API_KEY = process.env.IMMICH_API_KEY || '';
|
||||
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
|
||||
@@ -20,230 +22,86 @@ const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
|
||||
const SHUFFLE = process.env.SHUFFLE !== 'false';
|
||||
const ALBUM_ID = process.env.ALBUM_ID || '';
|
||||
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
|
||||
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
||||
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
||||
|
||||
// --- Shared headers for Immich API ---
|
||||
function immichHeaders() {
|
||||
return {
|
||||
'x-api-key': API_KEY,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
function immichHeaders() { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; }
|
||||
function log(msg) { console.log('[Frambe] ' + msg); }
|
||||
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
|
||||
|
||||
// --- Middleware ---
|
||||
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());
|
||||
|
||||
// --- API: Config endpoint (sends safe config to frontend) ---
|
||||
app.get('/api/config', (_req, res) => {
|
||||
res.json({
|
||||
slideshowInterval: SLIDESHOW_INTERVAL,
|
||||
transitionDuration: TRANSITION_DURATION,
|
||||
showClock: SHOW_CLOCK,
|
||||
showDate: SHOW_DATE,
|
||||
showExif: SHOW_EXIF,
|
||||
showProgress: SHOW_PROGRESS,
|
||||
imageFit: IMAGE_FIT,
|
||||
backgroundBlur: BACKGROUND_BLUR,
|
||||
shuffle: SHUFFLE,
|
||||
albumId: ALBUM_ID,
|
||||
showFavoritesOnly: SHOW_FAVORITES_ONLY,
|
||||
connected: !!API_KEY,
|
||||
});
|
||||
});
|
||||
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 }; }
|
||||
function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); }
|
||||
|
||||
// --- API: Server info / connectivity check ---
|
||||
app.get('/api/server-info', async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${IMMICH_URL}/api/server/version`, {
|
||||
headers: immichHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ ok: true, version: data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ ok: false, 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')); });
|
||||
|
||||
// --- API: List albums ---
|
||||
app.get('/api/albums', async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${IMMICH_URL}/api/albums`, {
|
||||
headers: immichHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const albums = await response.json();
|
||||
// Return simplified album list
|
||||
const simplified = albums.map((a) => ({
|
||||
id: a.id,
|
||||
albumName: a.albumName,
|
||||
assetCount: a.assetCount,
|
||||
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
||||
updatedAt: a.updatedAt,
|
||||
}));
|
||||
res.json(simplified);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Get album assets ---
|
||||
app.get('/api/albums/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, {
|
||||
headers: immichHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const album = await response.json();
|
||||
// Filter to images only, return simplified asset data
|
||||
const assets = (album.assets || [])
|
||||
.filter((a) => a.type === 'IMAGE')
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
originalFileName: a.originalFileName,
|
||||
fileCreatedAt: a.fileCreatedAt,
|
||||
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({
|
||||
id: album.id,
|
||||
albumName: album.albumName,
|
||||
assetCount: assets.length,
|
||||
assets,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Get random assets (when no album selected) ---
|
||||
app.get('/api/assets/random', async (req, res) => {
|
||||
try {
|
||||
const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
|
||||
const response = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, {
|
||||
headers: immichHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const assets = await response.json();
|
||||
const images = assets
|
||||
.filter((a) => a.type === 'IMAGE')
|
||||
.map((a) => ({
|
||||
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,
|
||||
}));
|
||||
res.json(images);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Get favorites ---
|
||||
app.get('/api/assets/favorites', async (_req, res) => {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
isFavorite: true,
|
||||
type: 'IMAGE',
|
||||
size: 250,
|
||||
page: 1,
|
||||
});
|
||||
const response = await fetch(`${IMMICH_URL}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: immichHeaders(),
|
||||
body,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const data = await response.json();
|
||||
const images = (data.assets?.items || []).map((a) => ({
|
||||
id: a.id,
|
||||
originalFileName: a.originalFileName,
|
||||
fileCreatedAt: a.fileCreatedAt,
|
||||
isFavorite: true,
|
||||
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(images);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Proxy image (thumbnail) ---
|
||||
app.get('/api/assets/:id/thumbnail', async (req, res) => {
|
||||
try {
|
||||
const size = req.query.size || 'preview'; // 'thumbnail' or 'preview'
|
||||
const response = await fetch(
|
||||
`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`,
|
||||
{ headers: { 'x-api-key': API_KEY } }
|
||||
);
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const contentType = response.headers.get('content-type');
|
||||
res.set('Content-Type', contentType || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
response.body.pipe(res);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Proxy full-size image ---
|
||||
app.get('/api/assets/:id/original', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${IMMICH_URL}/api/assets/${req.params.id}/original`,
|
||||
{ headers: { 'x-api-key': API_KEY } }
|
||||
);
|
||||
if (!response.ok) throw new Error(`Immich returned ${response.status}`);
|
||||
const contentType = response.headers.get('content-type');
|
||||
res.set('Content-Type', contentType || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
response.body.pipe(res);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fallback: serve index.html for SPA ---
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// --- Start ---
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🖼️ ImmichFrame running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`📡 Immich server: ${IMMICH_URL}`);
|
||||
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
|
||||
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
|
||||
console.log(`⏱️ Slideshow interval: ${SLIDESHOW_INTERVAL}s`);
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
log('--- Frambe v' + VERSION + ' ---');
|
||||
log('Server listening on port ' + PORT);
|
||||
log('Admin dashboard: http://0.0.0.0:' + PORT + '/admin');
|
||||
log('WebSocket: ws://0.0.0.0:' + PORT + '/ws');
|
||||
log('Immich URL: ' + IMMICH_URL);
|
||||
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
||||
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
|
||||
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
|
||||
log('Waiting for connections...');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user