40 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
jessikitty 3cfcc3f983 feat: port 3030, add REFRESH_INTERVAL 2026-05-19 16:01:48 +10:00
jessikitty 245766d841 feat: change default port to 3030 2026-05-19 16:01:14 +10:00
jessikitty d32180d352 feat: port 3030, add REFRESH_INTERVAL config 2026-05-19 16:01:01 +10:00
jessikitty 4feaaf30d0 feat: URL params (?album=, ?person=, ?favorites, ?random), periodic refresh, person support 2026-05-19 15:59:43 +10:00
jessikitty be6f0fc30f feat: add app icon, apple-touch-icon, theme-color meta 2026-05-19 15:57:35 +10:00
jessikitty 00bfa926e4 feat: port 3030, person API endpoints, refresh interval config, mapAsset helper 2026-05-19 15:56:43 +10:00
jessikitty ea2828d071 feat(1.2.0): bump version — port 3030, URL params, person support, auto-refresh, app icon 2026-05-19 15:56:04 +10:00
jessikitty 2409ab882e rebrand: rename ImmichFrame to Frambe throughout README 2026-05-19 14:54:07 +10:00
jessikitty 2c4a64c73c rebrand: rename ImmichFrame to Frambe in .env.example header 2026-05-19 14:52:58 +10:00
jessikitty 1756035cf8 rebrand: rename service and container from immich-frame to frambe 2026-05-19 14:52:20 +10:00
jessikitty 96d9c0960a rebrand: rename ImmichFrame to Frambe in Dockerfile labels 2026-05-19 14:51:44 +10:00
jessikitty d31abb68c8 rebrand: rename ImmichFrame to Frambe in app.js header 2026-05-19 14:51:13 +10:00
jessikitty 597c5fd883 rebrand: rename ImmichFrame to Frambe in title and heading 2026-05-19 14:49:27 +10:00
jessikitty 7009c6e3fa rebrand: rename ImmichFrame to Frambe in server startup logs 2026-05-19 14:49:02 +10:00
jessikitty a9c55ba861 rebrand: rename project from ImmichFrame to Frambe 2026-05-19 14:48:15 +10:00
11 changed files with 447 additions and 1216 deletions
+4 -3
View File
@@ -1,4 +1,4 @@
# === ImmichFrame Configuration === # === Frambe Configuration ===
# REQUIRED # REQUIRED
IMMICH_URL=http://your-immich-server:2283 IMMICH_URL=http://your-immich-server:2283
@@ -10,6 +10,7 @@ TRANSITION_DURATION=2
IMAGE_FIT=contain IMAGE_FIT=contain
SHUFFLE=true SHUFFLE=true
BACKGROUND_BLUR=true BACKGROUND_BLUR=true
REFRESH_INTERVAL=300
# Overlays # Overlays
SHOW_CLOCK=true SHOW_CLOCK=true
@@ -17,9 +18,9 @@ SHOW_DATE=true
SHOW_EXIF=true SHOW_EXIF=true
SHOW_PROGRESS=true SHOW_PROGRESS=true
# Auto-start (optional) # Auto-start (optional — or use URL params instead)
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server # Server (internal port — Docker maps externally via docker-compose)
PORT=3000 PORT=3000
+2 -9
View File
@@ -1,28 +1,21 @@
FROM node:18-alpine FROM node:18-alpine
LABEL maintainer="ImmichFrame" LABEL maintainer="Frambe"
LABEL description="Lightweight digital photo frame for Immich" LABEL description="Frambe — lightweight digital photo frame for Immich"
WORKDIR /app WORKDIR /app
# Copy package files first for better caching
COPY package.json ./ COPY package.json ./
# Install dependencies
RUN npm install --production && npm cache clean --force RUN npm install --production && npm cache clean --force
# Copy application
COPY server.js ./ COPY server.js ./
COPY public/ ./public/ COPY public/ ./public/
# Expose port
EXPOSE 3000 EXPOSE 3000
# Health check
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:3000/api/config || exit 1 CMD wget -qO- http://localhost:3000/api/config || exit 1
# Run as non-root
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
USER appuser USER appuser
+39 -16
View File
@@ -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. 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,21 +46,34 @@ 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
```bash ```bash
docker build -t immich-frame . docker build -t frambe .
docker run -d \ docker run -d \
--name immich-frame \ --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 \
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 ## ⚙️ 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
@@ -101,15 +121,18 @@ All settings are via environment variables:
``` ```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ ImmichFrame │ 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.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
## 📄 License ## 📄 License
+19 -19
View File
@@ -1,31 +1,31 @@
version: "3.8" version: "3.8"
services: services:
immich-frame: frambe:
build: . build: .
container_name: immich-frame container_name: frambe
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3030:3000"
environment: environment:
# REQUIRED: Your Immich server URL (no trailing slash) # REQUIRED
- IMMICH_URL=http://your-immich-server:2283 - IMMICH_URL=http://your-immich-server:2283
# REQUIRED: Your Immich API key
- IMMICH_API_KEY=your-api-key-here - IMMICH_API_KEY=your-api-key-here
# OPTIONAL: Slideshow settings # Slideshow
- SLIDESHOW_INTERVAL=30 # Seconds between photos - SLIDESHOW_INTERVAL=30
- TRANSITION_DURATION=2 # Crossfade duration in seconds - TRANSITION_DURATION=2
- IMAGE_FIT=contain # 'contain' or 'cover' - IMAGE_FIT=contain
- SHUFFLE=true # Randomise photo order - SHUFFLE=true
- BACKGROUND_BLUR=true # Blurred background behind photos - BACKGROUND_BLUR=true
- REFRESH_INTERVAL=300 # Seconds between album/person refresh checks
# OPTIONAL: Overlay settings # Overlays
- SHOW_CLOCK=true # Show time overlay - SHOW_CLOCK=true
- SHOW_DATE=true # Show date overlay - SHOW_DATE=true
- SHOW_EXIF=true # Show photo location/camera info - SHOW_EXIF=true
- SHOW_PROGRESS=true # Show progress bar - SHOW_PROGRESS=true
# OPTIONAL: Auto-start with specific album or favorites # Auto-start (optional — or use URL params instead)
# - ALBUM_ID= # Auto-start with this album UUID # - ALBUM_ID=
# - SHOW_FAVORITES_ONLY=false # Auto-start showing only favorites # - SHOW_FAVORITES_ONLY=false
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "immich-frame", "name": "frambe",
"version": "1.0.0", "version": "1.3.0",
"description": "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": {
"start": "node server.js" "start": "node server.js"
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

+22 -35
View File
@@ -6,43 +6,31 @@
<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">
<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"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<!-- Setup Screen -->
<div id="setup-screen" class="screen"> <div id="setup-screen" class="screen">
<div class="setup-container"> <div class="setup-container">
<div class="setup-header"> <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> <p class="subtitle" id="connection-status">Connecting to Immich…</p>
</div> </div>
<div id="setup-content" class="setup-content"> <div id="setup-content" class="setup-content">
<!-- Album Selection -->
<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> <button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon"></span><span>Favorites</span></button>
<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>
<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>
<!-- Start Button -->
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">
▶ Start Slideshow
</button>
</div> </div>
<div id="setup-error" class="setup-error" style="display:none"> <div id="setup-error" class="setup-error" style="display:none">
<p>⚠️ Cannot connect to Immich</p> <p>⚠️ Cannot connect to Immich</p>
<p class="error-detail" id="error-detail"></p> <p class="error-detail" id="error-detail"></p>
@@ -51,16 +39,21 @@
</div> </div>
</div> </div>
<!-- Slideshow Screen -->
<div id="slideshow-screen" class="screen" style="display:none"> <div id="slideshow-screen" class="screen" style="display:none">
<!-- Background blur layer -->
<div id="bg-blur" class="bg-blur"></div> <div id="bg-blur" class="bg-blur"></div>
<canvas id="pile-canvas"></canvas>
<div class="bg-vignette"></div>
<!-- Photo layers (double-buffered for crossfade) --> <!-- Flexbox wrapper handles centering; animation lives on inner frame -->
<div id="photo-layer-a" class="photo-layer active"></div> <div class="main-frame-wrapper">
<div id="photo-layer-b" class="photo-layer"></div> <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 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>
@@ -68,21 +61,15 @@
</div> </div>
<div class="overlay-bottom"> <div class="overlay-bottom">
<div id="exif-info" class="exif-info"></div> <div id="exif-info" class="exif-info"></div>
<div id="progress-bar" class="progress-bar"> <div id="progress-bar" class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
<div id="progress-fill" class="progress-fill"></div>
</div>
</div> </div>
</div> </div>
<!-- Touch/Click controls (invisible) -->
<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>
<!-- Settings button (hidden until overlay shown) -->
<button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button> <button id="btn-settings" class="settings-btn" onclick="exitSlideshow()"></button>
</div> </div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>
+166 -513
View File
@@ -1,555 +1,208 @@
// === ImmichFrame - Frontend Application === // === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
(function () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false;
var pileCanvas, pileCtx;
// Shared polaroid proportions (match main frame CSS: ~3% sides, ~10% bottom)
var FRAME_PAD_RATIO = 0.03;
var FRAME_BOTTOM_RATIO = 0.10;
var FRAME_COLOR = '#ede8df';
// --- State --- var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen');
var config = {}; var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
var assets = []; var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
var currentIndex = -1; var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
var activeLayer = 'a'; var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame');
var slideshowTimer = null; var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video');
var progressTimer = null; var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display');
var progressStart = 0; var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill');
var overlayVisible = true; var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings');
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'); var $progressBar = document.getElementById('progress-bar');
// --- Initialization --- function getUrlParams() { var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p; }
async function autoLaunch(src, aid, pid) { urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launching: source='+src+(aid?' album='+aid:'')+(pid?' person='+pid:''));await doStartSlideshow(); }
async function init() { async function init() {
document.body.classList.add('setup-mode'); document.body.classList.add('setup-mode');
try { try {
// Load config config = await (await fetch('/api/config')).json();
var configRes = await fetch('/api/config'); console.log('[Frambe] Running version ' + (config.version || 'unknown'));
config = await configRes.json(); if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
var si = await (await fetch('/api/server-info')).json();
if (!config.connected) { if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
showError('API key not configured. Set IMMICH_API_KEY in your environment.'); $connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
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'); $connectionStatus.classList.add('connected');
var params = getUrlParams();
// Load albums if (params.album) { await autoLaunch('album', params.album, null); return; }
if (params.person) { await autoLaunch('person', null, params.person); return; }
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
if ('random' in params) { await autoLaunch('random', null, null); return; }
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
await loadAlbums(); await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); }
// 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) { function showError(msg) { $setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg; }
$setupContent.style.display = 'none'; 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>';} }
$setupError.style.display = 'block';
$errorDetail.textContent = msg; 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 fetch failed: '+res.status);var al=await res.json();assets=al.assets||[];}else if(selectedSource==='person'&&selectedPersonId){res=await fetch('/api/people/'+selectedPersonId);if(!res.ok)throw new Error('Person fetch failed: '+res.status);assets=await res.json();}else if(selectedSource==='favorites'){res=await fetch('/api/assets/favorites');if(!res.ok)throw new Error('Favorites fetch failed: '+res.status);assets=await res.json();}else{res=await fetch('/api/assets/random?count=100');if(!res.ok)throw new Error('Random fetch failed: '+res.status);assets=await res.json();}if(config.shuffle)shuffleArray(assets);console.log('[Frambe] Loaded '+assets.length+' assets'); }
function startRefreshTimer() { if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(async function(){try{var oldIds={};for(var i=0;i<assets.length;i++)oldIds[assets[i].id]=true;var nw,r;if(selectedSource==='album'&&selectedAlbumId){r=await(await fetch('/api/albums/'+selectedAlbumId)).json();nw=r.assets||[];}else if(selectedSource==='person'&&selectedPersonId){nw=await(await fetch('/api/people/'+selectedPersonId)).json();}else if(selectedSource==='favorites'){nw=await(await fetch('/api/assets/favorites')).json();}else return;var added=0;for(var j=0;j<nw.length;j++){if(!oldIds[nw[j].id]){assets.push(nw[j]);added++;}}if(added>0)console.log('[Frambe] Refresh added '+added+' new asset(s)');}catch(e){console.warn('[Frambe] Refresh failed: '+e.message);}}, (config.refreshInterval||300)*1000); }
// =========================
// CANVAS PILE
// =========================
function initPileCanvas() {
pileCanvas = document.getElementById('pile-canvas');
var 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);
} }
// --- Albums --- function clearPileCanvas() {
async function loadAlbums() { 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); }
try { }
var res = await fetch('/api/albums');
var albums = await res.json();
if (!albums.length) { function dropPhotoPile(imgSrc) {
$albumsList.innerHTML = '<p class="loading-text">No albums found</p>'; var img = new Image();
return; img.crossOrigin = 'anonymous';
img.onload = function () {
var vw = window.innerWidth, vh = window.innerHeight;
// Pile polaroid size: 18-25% of screen width
var polaroidW = vw * (0.18 + Math.random() * 0.07);
// Use shared proportions to match main frame
var pad = polaroidW * FRAME_PAD_RATIO;
var bottomPad = polaroidW * FRAME_BOTTOM_RATIO;
var innerW = polaroidW - pad * 2;
var innerH = innerW * (img.height / img.width);
var totalH = innerH + pad + bottomPad;
var cx = Math.random() * vw, cy = Math.random() * vh;
var rot = (Math.random() - 0.5) * 30;
var startTime = null, fadeDuration = 1200;
function drawFrame(timestamp) {
if (!startTime) startTime = timestamp;
var alpha = Math.min((timestamp - startTime) / fadeDuration, 1);
pileCtx.save();
pileCtx.globalAlpha = alpha;
pileCtx.translate(cx, cy);
pileCtx.rotate(rot * Math.PI / 180);
pileCtx.shadowColor = 'rgba(0,0,0,0.45)';
pileCtx.shadowBlur = 18;
pileCtx.shadowOffsetX = 3;
pileCtx.shadowOffsetY = 6;
pileCtx.fillStyle = FRAME_COLOR;
pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH);
pileCtx.shadowColor = 'transparent'; pileCtx.shadowBlur = 0; pileCtx.shadowOffsetX = 0; pileCtx.shadowOffsetY = 0;
pileCtx.drawImage(img, -polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
// Sepia wash
pileCtx.fillStyle = 'rgba(150, 120, 70, 0.2)';
pileCtx.fillRect(-polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH);
pileCtx.restore();
if (alpha < 1) requestAnimationFrame(drawFrame);
} }
requestAnimationFrame(drawFrame);
var html = ''; };
for (var i = 0; i < albums.length; i++) { img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
var a = albums[i]; img.src = imgSrc;
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) { // SLIDESHOW ENGINE
selectedSource = source; // =========================
selectedAlbumId = null; async function doStartSlideshow() {
// 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; 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) { $setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
$btnStart.textContent = 'No photos found'; document.body.classList.remove('setup-mode'); isRunning = true;
setTimeout(function () { initPileCanvas();
$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.showClock) $clock.style.display = 'none';
if (!config.showDate) $dateDisplay.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);
// Start clock
updateClock();
setInterval(updateClock, 1000);
// Show first photo
currentIndex = -1; currentIndex = -1;
showNextPhoto(); 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); }
// Auto-hide overlay }
scheduleOverlayHide();
} catch (err) {
$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 () {
isRunning = false; if (urlDriven) { window.location.href = window.location.pathname; return; }
clearTimeout(slideshowTimer); isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();
clearInterval(progressTimer); $slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');
$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;
$slideshowScreen.style.display = 'none'; $bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();
$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 showNextAsset() { currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex); }
function showNextPhoto() { function showPrevAsset() { currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex); }
currentIndex++;
if (currentIndex >= assets.length) {
// Reload and reshuffle for infinite loop
if (config.shuffle) shuffleArray(assets);
currentIndex = 0;
}
showPhoto(currentIndex);
}
function showPrevPhoto() { function showAsset(index) {
currentIndex--;
if (currentIndex < 0) currentIndex = assets.length - 1;
showPhoto(currentIndex);
}
function showPhoto(index) {
if (!assets[index]) return; if (!assets[index]) return;
clearTimeout(slideshowTimer); stopVideo();
clearTimeout(slideshowTimer); var asset = assets[index], isVideo = asset.type === 'VIDEO';
clearInterval(progressTimer); var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id));
var asset = assets[index]; 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'); }
var imageUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview'; $mainFrame.classList.remove('visible');
// Preload the image before showing
var img = new Image(); var img = new Image();
img.onload = function () { img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); };
displayImage(imageUrl, asset); img.onerror = function () { setTimeout(showNextAsset, 500); };
}; img.src = thumbUrl;
img.onerror = function () { var ni = index + 1; if (ni >= assets.length) ni = 0;
// Skip broken images if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; }
setTimeout(showNextPhoto, 500);
};
img.src = imageUrl;
// Preload next image
preloadNext(index + 1);
} }
function displayImage(url, asset) { function displayAsset(asset, thumbUrl, isVideo) {
var fitStyle = config.imageFit || 'contain'; if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); }
var incomingLayer, outgoingLayer; $mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none';
if (isVideo) {
if (activeLayer === 'a') { $mainVideo.style.display = 'block';
incomingLayer = $layerB; $mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl;
outgoingLayer = $layerA; $mainVideo.load();
activeLayer = 'b'; $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 { } else {
incomingLayer = $layerA; $mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl;
outgoingLayer = $layerB; slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000);
activeLayer = 'a';
} }
requestAnimationFrame(function () { $mainFrame.classList.add('visible'); });
// Set the image updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000);
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) { function stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} }
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(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 updateExifInfo(asset) { 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 (!config.showExif || !asset.exifInfo) { 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'}); }
$exifInfo.textContent = ''; 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');}};
return; 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();};
var parts = []; 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;}});
var exif = asset.exifInfo; 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;}}
// Location function padZero(n){return n<10?'0'+n:''+n;}
var location = [exif.city, exif.state, exif.country].filter(Boolean).join(', '); 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();}
if (location) parts.push('📍 ' + location); 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){}}
// Date document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
if (exif.dateTimeOriginal) { 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){}}
var d = new Date(exif.dateTimeOriginal); init();requestWakeLock();preventSleep();
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();
})(); })();
+71 -195
View File
@@ -3,10 +3,10 @@ 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 || 3000; const PORT = process.env.PORT || 3000;
// --- Configuration ---
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || ''; const API_KEY = process.env.IMMICH_API_KEY || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30; const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
@@ -20,230 +20,106 @@ const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
const SHUFFLE = process.env.SHUFFLE !== 'false'; 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 INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
// --- Shared headers for Immich API ---
function immichHeaders() { function immichHeaders() {
return { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
'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('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.use(express.static(path.join(__dirname, 'public'))); 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());
// --- API: Config endpoint (sends safe config to frontend) --- 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) {
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) => { app.get('/api/config', (_req, res) => {
res.json({ 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 });
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,
});
}); });
// --- API: Server info / connectivity check ---
app.get('/api/server-info', async (_req, res) => { app.get('/api/server-info', async (_req, res) => {
try { 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 });
const response = await fetch(`${IMMICH_URL}/api/server/version`, { } catch (err) { logErr('Immich connection failed: ' + err.message); res.status(502).json({ ok: false, error: err.message }); }
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 });
}
}); });
// --- API: List albums ---
app.get('/api/albums', async (_req, res) => { app.get('/api/albums', async (_req, res) => {
try { 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 })));
const response = await fetch(`${IMMICH_URL}/api/albums`, { } catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
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) => { 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 response = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { } catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
headers: immichHeaders(), });
});
if (!response.ok) throw new Error(`Immich returned ${response.status}`); app.get('/api/people', async (_req, res) => {
const album = await response.json(); 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);
// Filter to images only, return simplified asset data } catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
const assets = (album.assets || []) });
.filter((a) => a.type === 'IMAGE')
.map((a) => ({ app.get('/api/people/:id', async (req, res) => {
id: a.id, 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);
originalFileName: a.originalFileName, } catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
fileCreatedAt: a.fileCreatedAt, });
exifInfo: a.exifInfo
? { app.get('/api/people/:id/thumbnail', async (req, res) => {
make: a.exifInfo.make, 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);
model: a.exifInfo.model, } catch (err) { res.status(502).json({ error: err.message }); }
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) => { 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 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) => { 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 body = JSON.stringify({ } catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
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) => { 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'; // 'thumbnail' or 'preview' } catch (err) { res.status(502).json({ error: err.message }); }
const response = await fetch( });
`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`,
{ headers: { 'x-api-key': API_KEY } } 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);
if (!response.ok) throw new Error(`Immich returned ${response.status}`); } catch (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.message }); }
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) => { 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 response = await fetch( } catch (err) { res.status(502).json({ error: err.message }); }
`${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')); });
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// --- Start ---
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ ImmichFrame 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 interval: ${SLIDESHOW_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...');
}); });