25 Commits

Author SHA1 Message Date
jessikitty fc470f4df2 version 1.3.0 — Vintage Polaroid Pile theme 2026-05-22 09:18:46 +10:00
jessikitty 7f7ea978b9 fix: pile uses shared FRAME_PAD_RATIO/FRAME_BOTTOM_RATIO/FRAME_COLOR to match main, stronger sepia wash on pile 2026-05-22 09:17:58 +10:00
jessikitty dc1f3402d6 fix: flexbox centering for all resolutions, 1.5deg rotation, larger frame (93vw), full sepia bg, proportional polaroid padding (vmin) 2026-05-22 09:15:38 +10:00
jessikitty c1ebbaef98 fix: wrap main-frame in flexbox centering wrapper for reliable centering on all resolutions 2026-05-22 09:14:33 +10:00
jessikitty 1b45afb6f4 fix: pile photos fade in with sepia wash, bg transitions smoothly, drift is single-direction 2026-05-20 10:38:59 +10:00
jessikitty d1163511fa fix: portrait frame hugs image, heavier sepia wash, slow single-direction drift, bg fade transition 2026-05-20 10:37:00 +10:00
jessikitty 804c6cfd86 feat: canvas-based cumulative photo pile, unified polaroid for photos+videos, larger main frame 2026-05-20 09:48:15 +10:00
jessikitty 65993839a7 feat: replace pile divs with canvas, remove filmstrip elements, unified polaroid frame 2026-05-20 09:46:24 +10:00
jessikitty d6b6464171 feat: enlarge main frame to ~90% screen, remove filmstrip, add canvas pile, unified polaroid style 2026-05-20 09:45:56 +10:00
jessikitty 0f4a995cdc feat(1.3.0): Polaroid pile slideshow engine, video playback with filmstrip frame, floating animation 2026-05-19 21:48:53 +10:00
jessikitty ad7a2b8250 feat(1.3.0): vintage Polaroid pile theme, filmstrip video frame, floating animation, warm vignette 2026-05-19 21:46:19 +10:00
jessikitty 9ab2dc7578 feat(1.3.0): new DOM structure with photo pile, main polaroid/filmstrip frame, video element 2026-05-19 21:44:28 +10:00
jessikitty c2b40db843 feat(1.3.0): video support, INCLUDE_VIDEOS config, video streaming endpoint, asset type in mapAsset 2026-05-19 21:43:24 +10:00
jessikitty 6943d0c0dd fix(1.2.2): request logging, cache-busting, clean logs 2026-05-19 16:55:27 +10:00
jessikitty 4f2f6ee555 fix: add request logging, no-cache on HTML/JS/CSS, clean startup log, version identifier 2026-05-19 16:55:06 +10:00
jessikitty f2cc8b4413 fix: correct port mapping in README docker examples (3030:3000), add v1.2.1 to changelog 2026-05-19 16:38:34 +10:00
jessikitty 4d85d58526 fix(1.2.1): fix port mapping and URL param auto-launch 2026-05-19 16:36:40 +10:00
jessikitty 68dbf9dc59 fix: internal port 3000 (Docker maps 3030 externally) 2026-05-19 16:35:50 +10:00
jessikitty 4f7d30d3d2 fix: URL param auto-launch now awaits slideshow start, uses 'in' operator for ?favorites/?random, adds console logging for debug 2026-05-19 16:35:03 +10:00
jessikitty cac179754c fix: internal port back to 3000 (Docker maps externally via compose) 2026-05-19 16:33:06 +10:00
jessikitty a609ed2165 fix: port mapping 3030:3000 (external:internal) 2026-05-19 16:32:37 +10:00
jessikitty 2ea13fcbaa fix: revert internal port to 3000 (Docker maps 3030:3000 externally) 2026-05-19 16:32:03 +10:00
jessikitty 6c26688244 docs: update README with v1.2.0 features — URL params, person support, refresh, icon, port 3030 2026-05-19 16:16:27 +10:00
jessikitty 88d79a9569 Upload files to "public" 2026-05-19 16:14:44 +10:00
jessikitty 1c55aa9418 Upload files to "public/img" 2026-05-19 16:14:32 +10:00
11 changed files with 402 additions and 676 deletions
+2 -2
View File
@@ -22,5 +22,5 @@ SHOW_PROGRESS=true
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server # Server (internal port — Docker maps externally via docker-compose)
PORT=3030 PORT=3000
+2 -2
View File
@@ -11,10 +11,10 @@ RUN npm install --production && npm cache clean --force
COPY server.js ./ COPY server.js ./
COPY public/ ./public/ COPY public/ ./public/
EXPOSE 3030 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3030/api/config || exit 1 CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup adduser -S appuser -u 1001 -G appgroup
+34 -12
View File
@@ -1,4 +1,8 @@
# 🖼️ Frambe # Frambe
<p align="center">
<img src="public/img/icon.png" alt="Frambe" width="180">
</p>
A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames.
@@ -6,6 +10,9 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Immich API Integration** — Connects securely via API key (kept server-side) - **Immich API Integration** — Connects securely via API key (kept server-side)
- **Album Browser** — Select any album, random photos, or favorites only - **Album Browser** — Select any album, random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters
- **Auto-Refresh** — Periodically checks for new photos added to the source album/person
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration - **Smooth Crossfade** — Double-buffered image transitions with configurable duration
- **Background Blur** — Blurred backdrop fills the space behind non-covering images - **Background Blur** — Blurred backdrop fills the space behind non-covering images
- **Clock & Date Overlay** — Always know the time at a glance - **Clock & Date Overlay** — Always know the time at a glance
@@ -14,7 +21,6 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay - **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) - **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
- **Screen Wake Lock** — Prevents screen sleep on supported devices - **Screen Wake Lock** — Prevents screen sleep on supported devices
- **Auto-Start** — Configure an album ID or favorites-only to skip the setup screen
- **Responsive** — Works on any screen size from phone to TV - **Responsive** — Works on any screen size from phone to TV
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint - **Docker Containerised** — Single container, minimal footprint
@@ -30,8 +36,8 @@ A lightweight, self-contained Docker web application that connects to your [Immi
### 2. Run with Docker Compose ### 2. Run with Docker Compose
```bash ```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd immich-frame cd frambe
``` ```
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
@@ -40,7 +46,7 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
docker compose up -d docker compose up -d
``` ```
Open `http://your-server:3000` in a browser on your tablet/screen. Open `http://your-server:3030` in a browser on your tablet/screen.
### 3. Run with Docker directly ### 3. Run with Docker directly
@@ -48,13 +54,26 @@ Open `http://your-server:3000` in a browser on your tablet/screen.
docker build -t frambe . docker build -t frambe .
docker run -d \ docker run -d \
--name frambe \ --name frambe \
-p 3000:3000 \ -p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \ -e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \ -e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \ --restart unless-stopped \
frambe frambe
``` ```
## 🔗 Zero-Touch URL Parameters
Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet:
| URL | What it shows |
|---|---|
| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album |
| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) |
| `http://server:3030/?favorites` | Favorite photos only |
| `http://server:3030/?random` | Random photos from the library |
You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
## ⚙️ Configuration ## ⚙️ Configuration
All settings are via environment variables: All settings are via environment variables:
@@ -72,9 +91,10 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay | | `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar | | `SHOW_PROGRESS` | `true` | Display progress bar |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `PORT` | `3000` | Server port | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
## 🎮 Controls ## 🎮 Controls
@@ -92,7 +112,7 @@ All settings are via environment variables:
## 📱 Tablet Setup Tips ## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
2. Add to Home Screen for a full-screen app experience 2. Add to Home Screen for a full-screen app experience
3. Enable kiosk mode or guided access to lock to the app 3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings 4. Disable screen timeout in your device settings
@@ -103,13 +123,15 @@ All settings are via environment variables:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │ │ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ │ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3000 └──────────────┘ :2283 └──────────────┘ └──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
``` ```
The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
## 📋 Version History ## 📋 Version History
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
- **1.1.0** — Rebrand to Frambe - **1.1.0** — Rebrand to Frambe
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment - **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: frambe container_name: frambe
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3030:3030" - "3030:3000"
environment: environment:
# REQUIRED # REQUIRED
- IMMICH_URL=http://your-immich-server:2283 - IMMICH_URL=http://your-immich-server:2283
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frambe", "name": "frambe",
"version": "1.2.0", "version": "1.3.0",
"description": "Frambe — a lightweight digital photo frame web app for Immich", "description": "Frambe — a lightweight digital photo frame web app for Immich",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

After

Width:  |  Height:  |  Size: 52 KiB

+18 -12
View File
@@ -6,7 +6,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f0f1a"> <meta name="theme-color" content="#1a1510">
<title>Frambe</title> <title>Frambe</title>
<link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/img/icon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -24,16 +24,10 @@
<div class="section"> <div class="section">
<h2>Select Photo Source</h2> <h2>Select Photo Source</h2>
<div class="source-buttons"> <div class="source-buttons">
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"> <button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
<span class="source-icon">🎲</span><span>Random Photos</span> <button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon"></span><span>Favorites</span></button>
</button>
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
<span class="source-icon"></span><span>Favorites</span>
</button>
</div>
<div id="albums-list" class="albums-list">
<p class="loading-text">Loading albums…</p>
</div> </div>
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
</div> </div>
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button> <button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
</div> </div>
@@ -47,8 +41,19 @@
<div id="slideshow-screen" class="screen" style="display:none"> <div id="slideshow-screen" class="screen" style="display:none">
<div id="bg-blur" class="bg-blur"></div> <div id="bg-blur" class="bg-blur"></div>
<div id="photo-layer-a" class="photo-layer active"></div> <canvas id="pile-canvas"></canvas>
<div id="photo-layer-b" class="photo-layer"></div> <div class="bg-vignette"></div>
<!-- Flexbox wrapper handles centering; animation lives on inner frame -->
<div class="main-frame-wrapper">
<div id="main-frame" class="main-frame">
<div class="frame-border">
<img id="main-photo" class="frame-media" alt="">
<video id="main-video" class="frame-media" muted playsinline style="display:none"></video>
</div>
</div>
</div>
<div id="overlay" class="overlay"> <div id="overlay" class="overlay">
<div class="overlay-top-right"> <div class="overlay-top-right">
<div id="clock" class="clock"></div> <div id="clock" class="clock"></div>
@@ -59,6 +64,7 @@
<div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div> <div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
</div> </div>
</div> </div>
<div class="touch-zone touch-left" onclick="prevPhoto()"></div> <div class="touch-zone touch-left" onclick="prevPhoto()"></div>
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div> <div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
<div class="touch-zone touch-right" onclick="nextPhoto()"></div> <div class="touch-zone touch-right" onclick="nextPhoto()"></div>
+163 -138
View File
@@ -1,183 +1,208 @@
// === Frambe - Frontend Application === // === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
(function () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, activeLayer = 'a', slideshowTimer = null; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null; var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false; var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false;
var pileCanvas, pileCtx;
// Shared polaroid proportions (match main frame CSS: ~3% sides, ~10% bottom)
var FRAME_PAD_RATIO = 0.03;
var FRAME_BOTTOM_RATIO = 0.10;
var FRAME_COLOR = '#ede8df';
var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen'); var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen');
var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content'); var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail'); var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start'); var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
var $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b'); var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame');
var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock'); var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video');
var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info'); var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display');
var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay'); var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill');
var $btnSettings = document.getElementById('btn-settings'), $progressBar = document.getElementById('progress-bar'); var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings');
var $progressBar = document.getElementById('progress-bar');
function getUrlParams() { function getUrlParams() { var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p; }
var p = {}, s = window.location.search.substring(1); if (!s) return p; async function autoLaunch(src, aid, pid) { urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launching: source='+src+(aid?' album='+aid:'')+(pid?' person='+pid:''));await doStartSlideshow(); }
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 init() { async function init() {
document.body.classList.add('setup-mode'); document.body.classList.add('setup-mode');
try { try {
config = await (await fetch('/api/config')).json(); config = await (await fetch('/api/config')).json();
console.log('[Frambe] Running version ' + (config.version || 'unknown'));
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; } if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
var si = await (await fetch('/api/server-info')).json(); var si = await (await fetch('/api/server-info')).json();
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; } if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch; $connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
$connectionStatus.classList.add('connected'); $connectionStatus.classList.add('connected');
var params = getUrlParams(); var params = getUrlParams();
if (params.album) { urlDriven = true; selectedSource = 'album'; selectedAlbumId = params.album; $btnStart.disabled = false; startSlideshow(); return; } if (params.album) { await autoLaunch('album', params.album, null); return; }
if (params.person) { urlDriven = true; selectedSource = 'person'; selectedPersonId = params.person; $btnStart.disabled = false; startSlideshow(); return; } if (params.person) { await autoLaunch('person', null, params.person); return; }
if (params.favorites === '' || params.favorites === 'true' || params.favorites === '1') { urlDriven = true; selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; } if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
if (params.random === '' || params.random === 'true' || params.random === '1') { urlDriven = true; selectedSource = 'random'; $btnStart.disabled = false; startSlideshow(); return; } if ('random' in params) { await autoLaunch('random', null, null); return; }
if (config.albumId) { selectedSource = 'album'; selectedAlbumId = config.albumId; $btnStart.disabled = false; startSlideshow(); return; } if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; } if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
await loadAlbums(); await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); } } catch (err) { showError('Failed to initialize: ' + err.message); }
} }
function showError(msg) { $setupContent.style.display = 'none'; $setupError.style.display = 'block'; $errorDetail.textContent = msg; } function showError(msg) { $setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg; }
async function loadAlbums() { try { var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='<p class="loading-text">No albums found</p>';return;}var html='';for(var i=0;i<albums.length;i++){var a=albums[i],thu=a.albumThumbnailAssetId?'/api/assets/'+a.albumThumbnailAssetId+'/thumbnail?size=thumbnail':'';html+='<div class="album-item" data-id="'+a.id+'" onclick="selectAlbum(\''+a.id+'\', this)">';html+=thu?'<img class="album-thumb" src="'+thu+'" alt="" loading="lazy">':'<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">📁</div>';html+='<div class="album-info"><div class="album-name">'+escapeHtml(a.albumName)+'</div><div class="album-count">'+a.assetCount+' items</div></div></div>';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='<p class="loading-text">Failed to load albums</p>';} }
async function loadAlbums() { 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;};
try { 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;};
var albums = await (await fetch('/api/albums')).json();
if (!albums.length) { $albumsList.innerHTML = '<p class="loading-text">No albums found</p>'; return; } async function loadAssets() { var res;if(selectedSource==='album'&&selectedAlbumId){res=await fetch('/api/albums/'+selectedAlbumId);if(!res.ok)throw new Error('Album fetch failed: '+res.status);var al=await res.json();assets=al.assets||[];}else if(selectedSource==='person'&&selectedPersonId){res=await fetch('/api/people/'+selectedPersonId);if(!res.ok)throw new Error('Person fetch failed: '+res.status);assets=await res.json();}else if(selectedSource==='favorites'){res=await fetch('/api/assets/favorites');if(!res.ok)throw new Error('Favorites fetch failed: '+res.status);assets=await res.json();}else{res=await fetch('/api/assets/random?count=100');if(!res.ok)throw new Error('Random fetch failed: '+res.status);assets=await res.json();}if(config.shuffle)shuffleArray(assets);console.log('[Frambe] Loaded '+assets.length+' assets'); }
var html = ''; function startRefreshTimer() { if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(async function(){try{var oldIds={};for(var i=0;i<assets.length;i++)oldIds[assets[i].id]=true;var nw,r;if(selectedSource==='album'&&selectedAlbumId){r=await(await fetch('/api/albums/'+selectedAlbumId)).json();nw=r.assets||[];}else if(selectedSource==='person'&&selectedPersonId){nw=await(await fetch('/api/people/'+selectedPersonId)).json();}else if(selectedSource==='favorites'){nw=await(await fetch('/api/assets/favorites')).json();}else return;var added=0;for(var j=0;j<nw.length;j++){if(!oldIds[nw[j].id]){assets.push(nw[j]);added++;}}if(added>0)console.log('[Frambe] Refresh added '+added+' new asset(s)');}catch(e){console.warn('[Frambe] Refresh failed: '+e.message);}}, (config.refreshInterval||300)*1000); }
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)">'; // CANVAS PILE
html += thu ? '<img class="album-thumb" src="' + thu + '" alt="" loading="lazy">' : '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem">📁</div>'; // =========================
html += '<div class="album-info"><div class="album-name">' + escapeHtml(a.albumName) + '</div><div class="album-count">' + a.assetCount + ' photos</div></div></div>'; function initPileCanvas() {
pileCanvas = document.getElementById('pile-canvas');
var dpr = window.devicePixelRatio || 1;
pileCanvas.width = window.innerWidth * dpr;
pileCanvas.height = window.innerHeight * dpr;
pileCanvas.style.width = window.innerWidth + 'px';
pileCanvas.style.height = window.innerHeight + 'px';
pileCtx = pileCanvas.getContext('2d');
pileCtx.scale(dpr, dpr);
}
function clearPileCanvas() {
if (pileCtx) { pileCtx.setTransform(1,0,0,1,0,0); pileCtx.clearRect(0, 0, pileCanvas.width, pileCanvas.height); pileCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); }
}
function dropPhotoPile(imgSrc) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
var vw = window.innerWidth, vh = window.innerHeight;
// Pile polaroid size: 18-25% of screen width
var polaroidW = vw * (0.18 + Math.random() * 0.07);
// Use shared proportions to match main frame
var pad = polaroidW * FRAME_PAD_RATIO;
var bottomPad = polaroidW * FRAME_BOTTOM_RATIO;
var innerW = polaroidW - pad * 2;
var innerH = innerW * (img.height / img.width);
var totalH = innerH + pad + bottomPad;
var cx = Math.random() * vw, cy = Math.random() * vh;
var rot = (Math.random() - 0.5) * 30;
var startTime = null, fadeDuration = 1200;
function drawFrame(timestamp) {
if (!startTime) startTime = timestamp;
var alpha = Math.min((timestamp - startTime) / fadeDuration, 1);
pileCtx.save();
pileCtx.globalAlpha = alpha;
pileCtx.translate(cx, cy);
pileCtx.rotate(rot * Math.PI / 180);
pileCtx.shadowColor = 'rgba(0,0,0,0.45)';
pileCtx.shadowBlur = 18;
pileCtx.shadowOffsetX = 3;
pileCtx.shadowOffsetY = 6;
pileCtx.fillStyle = FRAME_COLOR;
pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH);
pileCtx.shadowColor = 'transparent'; pileCtx.shadowBlur = 0; pileCtx.shadowOffsetX = 0; pileCtx.shadowOffsetY = 0;
pileCtx.drawImage(img, -polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
// Sepia wash
pileCtx.fillStyle = 'rgba(150, 120, 70, 0.2)';
pileCtx.fillRect(-polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
pileCtx.restore();
if (alpha < 1) requestAnimationFrame(drawFrame);
} }
$albumsList.innerHTML = html; requestAnimationFrame(drawFrame);
} catch (e) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; } };
img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
img.src = imgSrc;
} }
window.selectSource = function (src) { // =========================
selectedSource = src; selectedAlbumId = null; selectedPersonId = null; // SLIDESHOW ENGINE
document.getElementById('btn-all-photos').classList.toggle('selected', src === 'random'); // =========================
document.getElementById('btn-favorites').classList.toggle('selected', src === 'favorites'); async function doStartSlideshow() {
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() {
if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); }
else { assets = await (await fetch('/api/assets/random?count=100')).json(); }
if (config.shuffle) shuffleArray(assets);
}
function startRefreshTimer() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(async function () {
try {
var oldIds = {}; for (var i = 0; i < assets.length; i++) oldIds[assets[i].id] = true;
var nw, r;
if (selectedSource === 'album' && selectedAlbumId) { r = await (await fetch('/api/albums/' + selectedAlbumId)).json(); nw = r.assets || []; }
else if (selectedSource === 'person' && selectedPersonId) { nw = await (await fetch('/api/people/' + selectedPersonId)).json(); }
else if (selectedSource === 'favorites') { nw = await (await fetch('/api/assets/favorites')).json(); }
else return;
var added = 0;
for (var j = 0; j < nw.length; j++) { if (!oldIds[nw[j].id]) { assets.push(nw[j]); added++; } }
if (added > 0) console.log('Frambe: added ' + added + ' new photo(s)');
} catch (e) { console.warn('Frambe: refresh failed', e.message); }
}, (config.refreshInterval || 300) * 1000);
}
window.startSlideshow = async function () {
if (!selectedSource) return; if (!selectedSource) return;
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…'; $btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
try { try {
await loadAssets(); await loadAssets();
if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000); return; } if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000); return; }
$setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block'; $setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode'); isRunning = true; document.body.classList.remove('setup-mode'); isRunning = true;
var t = (config.transitionDuration || 2) * 1000; initPileCanvas();
$layerA.style.transition = 'opacity ' + t + 'ms ease'; $layerB.style.transition = 'opacity ' + t + 'ms ease'; if (!config.showClock) $clock.style.display = 'none';
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease'; if (!config.showDate) $dateDisplay.style.display = 'none';
if (!config.showClock) $clock.style.display = 'none'; if (!config.showDate) $dateDisplay.style.display = 'none'; if (!config.showExif) $exifInfo.style.display = 'none';
if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showProgress) $progressBar.style.display = 'none'; if (!config.showProgress) $progressBar.style.display = 'none';
if (!config.backgroundBlur) $bgBlur.style.display = 'none'; if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000); updateClock(); setInterval(updateClock, 1000);
currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer(); currentIndex = -1;
} catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); } showNextAsset(); scheduleOverlayHide(); startRefreshTimer();
}; } catch (err) { console.error('[Frambe] Start failed: '+err.message); $btnStart.textContent='Error: '+err.message; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000); }
}
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () { window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; } if (urlDriven) { window.location.href = window.location.pathname; return; }
isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer); isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode'); $slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; $btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = ''; $bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
}; };
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); } function showNextAsset() { currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex); }
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); } function showPrevAsset() { currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex); }
function showPhoto(idx) {
if (!assets[idx]) return; clearTimeout(slideshowTimer); function showAsset(index) {
var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview'; if (!assets[index]) return;
var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url; clearTimeout(slideshowTimer); stopVideo();
preloadNext(idx + 1); var asset = assets[index], isVideo = asset.type === 'VIDEO';
var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id));
if (currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) dropPhotoPile('/api/assets/' + assets[pi].id + '/thumbnail?size=thumbnail'); }
$mainFrame.classList.remove('visible');
var img = new Image();
img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); };
img.onerror = function () { setTimeout(showNextAsset, 500); };
img.src = thumbUrl;
var ni = index + 1; if (ni >= assets.length) ni = 0;
if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; }
} }
function displayImage(url, asset) {
var fit = config.imageFit || 'contain', inc, out; function displayAsset(asset, thumbUrl, isVideo) {
if (activeLayer === 'a') { inc = $layerB; out = $layerA; activeLayer = 'b'; } else { inc = $layerA; out = $layerB; activeLayer = 'a'; } if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); }
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit; $mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none';
inc.classList.add('active'); out.classList.remove('active'); if (isVideo) {
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); } $mainVideo.style.display = 'block';
updateExifInfo(asset); startProgress(); $mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl;
slideshowTimer = setTimeout(showNextPhoto, (config.slideshowInterval || 30) * 1000); $mainVideo.load();
$mainVideo.play().then(function(){ currentVideoPlaying=true; }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); });
$mainVideo.onended = function () { currentVideoPlaying=false; showNextAsset(); };
slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){showNextAsset();} }, Math.max((config.slideshowInterval||30)*3, 120)*1000);
} else {
$mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl;
slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000);
}
requestAnimationFrame(function () { $mainFrame.classList.add('visible'); });
updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000);
} }
function preloadNext(i) { if (i >= assets.length) i = 0; if (!assets[i]) return; var img = new Image(); img.src = '/api/assets/' + assets[i].id + '/thumbnail?size=preview'; }
function updateExifInfo(a) { function stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} }
if (!config.showExif || !a.exifInfo) { $exifInfo.textContent = ''; return; }
var p = [], e = a.exifInfo, loc = [e.city, e.state, e.country].filter(Boolean).join(', '); 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(' · '); }
if (loc) p.push('📍 ' + loc); 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%';} }
if (e.dateTimeOriginal) p.push(formatDate(new Date(e.dateTimeOriginal))); 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'}); }
else if (a.fileCreatedAt) p.push(formatDate(new Date(a.fileCreatedAt))); 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');}};
if (e.make || e.model) p.push('📷 ' + [e.make, e.model].filter(Boolean).join(' ')); function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
$exifInfo.textContent = p.join(' • '); window.nextPhoto = function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
} window.prevPhoto = function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
function startProgress() { 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;}});
if (!config.showProgress) return; 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();}}
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth; 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;}}
$progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + 'ms linear'; $progressFill.style.width = '100%'; 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 updateClock() { function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
var n = new Date(); async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
if (config.showClock) $clock.textContent = padZero(n.getHours()) + ':' + padZero(n.getMinutes()); document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
if (config.showDate) $dateDisplay.textContent = n.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); 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();
window.toggleOverlay = function () { overlayVisible = !overlayVisible; if (overlayVisible) { $overlay.classList.remove('hidden'); $btnSettings.classList.add('visible'); scheduleOverlayHide(); } else { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); } };
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
document.addEventListener('keydown', function (e) { if (!isRunning) return; switch (e.key) { case 'ArrowRight': case ' ': e.preventDefault(); nextPhoto(); break; case 'ArrowLeft': e.preventDefault(); prevPhoto(); break; case 'Escape': exitSlideshow(); break; case 'f': toggleFullscreen(); break; case 'i': toggleOverlay(); break; } });
function toggleFullscreen() { if (!document.fullscreenElement && !document.webkitFullscreenElement) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } }
function shuffleArray(arr) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } }
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
function formatDate(d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return d.getDate() + ' ' + m[d.getMonth()] + ' ' + d.getFullYear(); }
function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && isRunning) requestWakeLock(); });
function preventSleep() { try { var v = document.createElement('video'); v.setAttribute('playsinline',''); v.setAttribute('muted',''); v.setAttribute('loop',''); v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0.01'; v.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA'; document.body.appendChild(v); v.play().catch(function(){}); } catch(e){} }
init(); requestWakeLock(); preventSleep();
})(); })();
+60 -85
View File
@@ -3,8 +3,9 @@ const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.3.0';
const app = express(); const app = express();
const PORT = process.env.PORT || 3030; const PORT = process.env.PORT || 3000;
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || ''; const API_KEY = process.env.IMMICH_API_KEY || '';
@@ -20,131 +21,105 @@ const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || ''; const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300; const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
function immichHeaders() { function immichHeaders() {
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
} }
function log(msg) { console.log('[Frambe] ' + msg); }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
app.use(express.static(path.join(__dirname, 'public'))); app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0');
}
}
}));
app.use(express.json()); app.use(express.json());
app.get('/api/config', (_req, res) => {
res.json({
slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL,
connected: !!API_KEY,
});
});
app.get('/api/server-info', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json({ ok: true, version: await r.json() });
} catch (err) { res.status(502).json({ ok: false, error: err.message }); }
});
app.get('/api/albums', async (_req, res) => {
try {
const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const albums = await r.json();
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { res.status(502).json({ error: err.message }); }
});
function mapAsset(a) { function mapAsset(a) {
return { return {
id: a.id, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, 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, 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) {
if (INCLUDE_VIDEOS) return assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO');
return assets.filter(a => a.type === 'IMAGE');
}
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 connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v });
} catch (err) { logErr('Immich connection failed: ' + err.message); res.status(502).json({ ok: false, error: err.message }); }
});
app.get('/api/albums', async (_req, res) => {
try { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const albums = await r.json(); log('Listed ' + albums.length + ' albums'); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/albums/:id', async (req, res) => { app.get('/api/albums/:id', async (req, res) => {
try { try { log('Fetching album ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const album = await r.json(); const assets = filterAssets(album.assets || []).map(mapAsset); const vids = assets.filter(a => a.type === 'VIDEO').length; log('Album "' + album.albumName + '" returned ' + assets.length + ' assets (' + (assets.length - vids) + ' photos, ' + vids + ' videos)'); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); } catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const album = await r.json();
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people', async (_req, res) => { app.get('/api/people', async (_req, res) => {
try { try { const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })); log('Listed ' + people.length + ' people'); res.json(people);
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); } catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people/:id', async (req, res) => { app.get('/api/people/:id', async (req, res) => {
try { try { log('Fetching person ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const raw = await r.json(); const assets = filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset); log('Person returned ' + assets.length + ' assets'); res.json(assets);
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); } catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const assets = await r.json();
res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people/:id/thumbnail', async (req, res) => { app.get('/api/people/:id/thumbnail', async (req, res) => {
try { try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); } } catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/random', async (req, res) => { app.get('/api/assets/random', async (req, res) => {
try { try { const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const assets = filterAssets(await r.json()).map(mapAsset); log('Random returned ' + assets.length + ' assets'); res.json(assets);
const count = Math.min(parseInt(req.query.count, 10) || 50, 250); } catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/favorites', async (_req, res) => { app.get('/api/assets/favorites', async (_req, res) => {
try { try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const assets = filterAssets(data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })); log('Favorites returned ' + assets.length + ' assets'); res.json(assets);
const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) }); } catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json();
res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
} catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/:id/thumbnail', async (req, res) => { app.get('/api/assets/:id/thumbnail', async (req, res) => {
try { try { const size = req.query.size || 'preview'; const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
const size = req.query.size || 'preview';
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); } } catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/:id/video', async (req, res) => {
try { log('Streaming video ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/video/playback`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res);
} catch (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.message }); }
});
app.get('/api/assets/:id/original', async (req, res) => { app.get('/api/assets/:id/original', async (req, res) => {
try { try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
r.body.pipe(res);
} catch (err) { res.status(502).json({ error: err.message }); } } catch (err) { res.status(502).json({ error: err.message }); }
}); });
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`); log('--- Frambe v' + VERSION + ' ---');
console.log(`📡 Immich server: ${IMMICH_URL}`); log('Server listening on port ' + PORT);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); log('Immich URL: ' + IMMICH_URL);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`); log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's');
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
log('Waiting for requests...');
}); });