Compare commits
13 Commits
v1.2.2
...
v1.3.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fc470f4df2 | |||
| 7f7ea978b9 | |||
| dc1f3402d6 | |||
| c1ebbaef98 | |||
| 1b45afb6f4 | |||
| d1163511fa | |||
| 804c6cfd86 | |||
| 65993839a7 | |||
| d6b6464171 | |||
| 0f4a995cdc | |||
| ad7a2b8250 | |||
| 9ab2dc7578 | |||
| c2b40db843 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frambe",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"description": "Frambe — a lightweight digital photo frame web app for Immich",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+121
-423
@@ -1,449 +1,147 @@
|
||||
/* === Reset & Base === */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* === Reset === */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; }
|
||||
body.setup-mode { cursor: default; }
|
||||
.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
cursor: none;
|
||||
}
|
||||
/* === SETUP === */
|
||||
#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; }
|
||||
.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; }
|
||||
.subtitle { font-size: 0.95rem; color: #888; }
|
||||
.subtitle.connected { color: #4ade80; }
|
||||
.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
|
||||
.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; }
|
||||
.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); }
|
||||
.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; }
|
||||
.source-icon { font-size: 1.5rem; }
|
||||
.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; }
|
||||
.loading-text { text-align: center; color: #666; padding: 1rem; }
|
||||
.album-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 10px; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s ease; animation: fadeIn 0.3s ease forwards; }
|
||||
.album-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; }
|
||||
.album-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; background: #222; }
|
||||
.album-info { flex: 1; }
|
||||
.album-name { font-size: 1rem; font-weight: 500; }
|
||||
.album-count { font-size: 0.8rem; color: #888; margin-top: 2px; }
|
||||
.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; }
|
||||
.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); }
|
||||
.start-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.setup-error { text-align: center; padding: 2rem; }
|
||||
.setup-error p { margin-bottom: 0.75rem; }
|
||||
.setup-error .error-detail { color: #888; font-size: 0.85rem; }
|
||||
.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; }
|
||||
.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
body.setup-mode {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* === Screens === */
|
||||
.screen {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
/* === Setup Screen === */
|
||||
#setup-screen {
|
||||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.setup-container {
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setup-header h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.subtitle.connected {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.subtitle.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* === Source Buttons === */
|
||||
.section h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.source-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.source-btn {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.source-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.source-btn.selected {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* === Albums List === */
|
||||
.albums-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.album-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.album-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.album-item.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.album-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.album-count {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* === Start Button === */
|
||||
.start-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.start-btn:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.start-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* === Setup Error === */
|
||||
.setup-error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-error p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.setup-error button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 2rem;
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* === Slideshow === */
|
||||
#slideshow-screen {
|
||||
background: #000;
|
||||
}
|
||||
/* =============================================
|
||||
SLIDESHOW - VINTAGE POLAROID PILE
|
||||
============================================= */
|
||||
#slideshow-screen { background: #1e1a14; overflow: hidden; }
|
||||
|
||||
/* Background — near-full sepia */
|
||||
.bg-blur {
|
||||
position: absolute;
|
||||
top: -20px; left: -20px;
|
||||
width: calc(100% + 40px);
|
||||
height: calc(100% + 40px);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(30px) brightness(0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease;
|
||||
z-index: 1;
|
||||
position: absolute; top: -30px; left: -30px;
|
||||
width: calc(100% + 60px); height: calc(100% + 60px);
|
||||
background-size: cover; background-position: center;
|
||||
filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0);
|
||||
opacity: 0; transition: opacity 3s ease; z-index: 1;
|
||||
}
|
||||
.bg-blur.visible { opacity: 1; }
|
||||
|
||||
.bg-blur.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
/* Canvas pile */
|
||||
#pile-canvas {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 2; pointer-events: none;
|
||||
transition: opacity 2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.photo-layer.active {
|
||||
opacity: 1;
|
||||
/* Vignette */
|
||||
.bg-vignette {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%);
|
||||
z-index: 3; pointer-events: none;
|
||||
}
|
||||
|
||||
/* === Overlay === */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
/* --- Centering wrapper (flexbox — works on all resolutions) --- */
|
||||
.main-frame-wrapper {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 5; pointer-events: none; overflow: visible;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
/* --- Main frame — no transform centering, flexbox does it --- */
|
||||
.main-frame {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.overlay-top-right {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 2rem;
|
||||
text-align: right;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8), 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.date-display {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.overlay-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.exif-info {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.75rem;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s linear;
|
||||
}
|
||||
|
||||
/* === Touch Zones === */
|
||||
.touch-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.touch-left {
|
||||
left: 0;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.touch-center {
|
||||
left: 20%;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.touch-right {
|
||||
right: 0;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* === Settings Button === */
|
||||
.settings-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-btn.visible {
|
||||
opacity: 1;
|
||||
transition: opacity 1.2s ease;
|
||||
animation: float 90s linear infinite;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.main-frame.visible { opacity: 1; }
|
||||
|
||||
/* === Scrollbar Styling === */
|
||||
.albums-list::-webkit-scrollbar,
|
||||
.setup-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
/* Slow drift + slight constant rotation */
|
||||
@keyframes float {
|
||||
0% { transform: translate(0, 0) rotate(1.5deg); }
|
||||
100% { transform: translate(8px, -5px) rotate(1.5deg); }
|
||||
}
|
||||
|
||||
.albums-list::-webkit-scrollbar-track,
|
||||
.setup-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */
|
||||
.main-frame .frame-border {
|
||||
background: #ede8df;
|
||||
padding: 1.2vmin 1.2vmin 4vmin 1.2vmin;
|
||||
box-shadow:
|
||||
0 6px 40px rgba(0,0,0,0.6),
|
||||
0 2px 6px rgba(0,0,0,0.3),
|
||||
inset 0 0 0 1px rgba(0,0,0,0.05);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.albums-list::-webkit-scrollbar-thumb,
|
||||
.setup-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
/* Large main image — allowed to overhang slightly */
|
||||
.main-frame .frame-media {
|
||||
display: block;
|
||||
max-width: 93vw;
|
||||
max-height: 85vh;
|
||||
width: auto; height: auto;
|
||||
object-fit: contain;
|
||||
background: #2a2520;
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
/* === OVERLAY === */
|
||||
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; }
|
||||
.overlay.hidden { opacity: 0; }
|
||||
.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); }
|
||||
.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; }
|
||||
.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; }
|
||||
.overlay-bottom { position: absolute; bottom: 0; left: 0; width: 100%; padding: 1.5rem 2rem 1rem; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); }
|
||||
.exif-info { font-size: 0.85rem; font-weight: 300; color: rgba(255,255,255,0.75); margin-bottom: 0.75rem; text-shadow: 0 1px 4px rgba(0,0,0,0.8); }
|
||||
.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; }
|
||||
|
||||
/* === CONTROLS === */
|
||||
.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; }
|
||||
.touch-left { left: 0; width: 20%; }
|
||||
.touch-center { left: 20%; width: 60%; }
|
||||
.touch-right { right: 0; width: 20%; }
|
||||
.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
|
||||
.settings-btn.visible { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 600px) {
|
||||
.setup-container {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.setup-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.clock {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.overlay-top-right {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
.overlay-bottom {
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
.source-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
.setup-container { padding: 1.25rem; }
|
||||
.setup-header h1 { font-size: 1.6rem; }
|
||||
.clock { font-size: 1.8rem; }
|
||||
.overlay-top-right { top: 1rem; right: 1rem; }
|
||||
.overlay-bottom { padding: 1rem 1rem 0.5rem; }
|
||||
.source-buttons { flex-direction: column; }
|
||||
.main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; }
|
||||
.main-frame .frame-media { max-width: 96vw; max-height: 88vh; }
|
||||
}
|
||||
|
||||
/* === Loading Spinner === */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* === Fade-in Animation === */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.album-item {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; }
|
||||
.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; }
|
||||
.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
||||
|
||||
+18
-12
@@ -6,7 +6,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0f0f1a">
|
||||
<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">
|
||||
@@ -24,16 +24,10 @@
|
||||
<div class="section">
|
||||
<h2>Select Photo Source</h2>
|
||||
<div class="source-buttons">
|
||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')">
|
||||
<span class="source-icon">🎲</span><span>Random Photos</span>
|
||||
</button>
|
||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')">
|
||||
<span class="source-icon">⭐</span><span>Favorites</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="albums-list" class="albums-list">
|
||||
<p class="loading-text">Loading albums…</p>
|
||||
<button id="btn-all-photos" class="source-btn" onclick="selectSource('random')"><span class="source-icon">🎲</span><span>Random Photos</span></button>
|
||||
<button id="btn-favorites" class="source-btn" onclick="selectSource('favorites')"><span class="source-icon">⭐</span><span>Favorites</span></button>
|
||||
</div>
|
||||
<div id="albums-list" class="albums-list"><p class="loading-text">Loading albums…</p></div>
|
||||
</div>
|
||||
<button id="btn-start" class="start-btn" disabled onclick="startSlideshow()">▶ Start Slideshow</button>
|
||||
</div>
|
||||
@@ -47,8 +41,19 @@
|
||||
|
||||
<div id="slideshow-screen" class="screen" style="display:none">
|
||||
<div id="bg-blur" class="bg-blur"></div>
|
||||
<div id="photo-layer-a" class="photo-layer active"></div>
|
||||
<div id="photo-layer-b" class="photo-layer"></div>
|
||||
<canvas id="pile-canvas"></canvas>
|
||||
<div class="bg-vignette"></div>
|
||||
|
||||
<!-- Flexbox wrapper handles centering; animation lives on inner frame -->
|
||||
<div class="main-frame-wrapper">
|
||||
<div 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 class="overlay-top-right">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="touch-zone touch-left" onclick="prevPhoto()"></div>
|
||||
<div class="touch-zone touch-center" onclick="toggleOverlay()"></div>
|
||||
<div class="touch-zone touch-right" onclick="nextPhoto()"></div>
|
||||
|
||||
+151
-179
@@ -1,158 +1,134 @@
|
||||
// === Frambe - Frontend Application ===
|
||||
// === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
|
||||
(function () {
|
||||
'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 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 $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content');
|
||||
var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail');
|
||||
var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start');
|
||||
var $layerA = document.getElementById('photo-layer-a'), $layerB = document.getElementById('photo-layer-b');
|
||||
var $bgBlur = document.getElementById('bg-blur'), $clock = document.getElementById('clock');
|
||||
var $dateDisplay = document.getElementById('date-display'), $exifInfo = document.getElementById('exif-info');
|
||||
var $progressFill = document.getElementById('progress-fill'), $overlay = document.getElementById('overlay');
|
||||
var $btnSettings = document.getElementById('btn-settings'), $progressBar = document.getElementById('progress-bar');
|
||||
var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame');
|
||||
var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video');
|
||||
var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display');
|
||||
var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill');
|
||||
var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings');
|
||||
var $progressBar = document.getElementById('progress-bar');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Auto-launch helper: sets source and immediately starts slideshow ---
|
||||
async function autoLaunch(source, albumId, personId) {
|
||||
urlDriven = true;
|
||||
selectedSource = source;
|
||||
selectedAlbumId = albumId || null;
|
||||
selectedPersonId = personId || null;
|
||||
console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
|
||||
await doStartSlideshow();
|
||||
}
|
||||
function getUrlParams() { var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i<pairs.length;i++){var kv=pairs[i].split('=');p[decodeURIComponent(kv[0])]=decodeURIComponent(kv[1]||'');}return p; }
|
||||
async function autoLaunch(src, aid, pid) { urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launching: source='+src+(aid?' album='+aid:'')+(pid?' person='+pid:''));await doStartSlideshow(); }
|
||||
|
||||
async function init() {
|
||||
document.body.classList.add('setup-mode');
|
||||
try {
|
||||
config = await (await fetch('/api/config')).json();
|
||||
console.log('[Frambe] Running version ' + (config.version || 'unknown'));
|
||||
if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; }
|
||||
var si = await (await fetch('/api/server-info')).json();
|
||||
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
|
||||
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
|
||||
$connectionStatus.classList.add('connected');
|
||||
|
||||
// Check URL parameters for direct launch (zero-touch)
|
||||
var params = getUrlParams();
|
||||
if (params.album) { await autoLaunch('album', params.album, null); return; }
|
||||
if (params.person) { await autoLaunch('person', null, params.person); return; }
|
||||
if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
|
||||
if ('random' in params) { await autoLaunch('random', null, null); return; }
|
||||
|
||||
// Check env-based auto-start
|
||||
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
|
||||
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
|
||||
|
||||
// No auto-start — show setup screen with album picker
|
||||
await loadAlbums();
|
||||
} 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() {
|
||||
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 + ' photos</div></div></div>';
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (e) { $albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>'; }
|
||||
requestAnimationFrame(drawFrame);
|
||||
};
|
||||
img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
|
||||
img.src = imgSrc;
|
||||
}
|
||||
|
||||
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 + ' photo(s) from ' + selectedSource);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Core slideshow start (used by both button click and auto-launch) ---
|
||||
// =========================
|
||||
// SLIDESHOW ENGINE
|
||||
// =========================
|
||||
async function doStartSlideshow() {
|
||||
if (!selectedSource) return;
|
||||
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
|
||||
try {
|
||||
await loadAssets();
|
||||
if (!assets.length) {
|
||||
$btnStart.textContent = 'No photos found';
|
||||
setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000);
|
||||
return;
|
||||
}
|
||||
// Switch to slideshow view
|
||||
$setupScreen.style.display = 'none';
|
||||
$slideshowScreen.style.display = 'block';
|
||||
document.body.classList.remove('setup-mode');
|
||||
isRunning = true;
|
||||
var t = (config.transitionDuration || 2) * 1000;
|
||||
$layerA.style.transition = 'opacity ' + t + 'ms ease';
|
||||
$layerB.style.transition = 'opacity ' + t + 'ms ease';
|
||||
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
|
||||
if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000); return; }
|
||||
$setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block';
|
||||
document.body.classList.remove('setup-mode'); isRunning = true;
|
||||
initPileCanvas();
|
||||
if (!config.showClock) $clock.style.display = 'none';
|
||||
if (!config.showDate) $dateDisplay.style.display = 'none';
|
||||
if (!config.showExif) $exifInfo.style.display = 'none';
|
||||
@@ -160,77 +136,73 @@
|
||||
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
|
||||
updateClock(); setInterval(updateClock, 1000);
|
||||
currentIndex = -1;
|
||||
showNextPhoto();
|
||||
scheduleOverlayHide();
|
||||
startRefreshTimer();
|
||||
} catch (err) {
|
||||
console.error('Frambe: slideshow start failed', 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); }
|
||||
}
|
||||
|
||||
// Exposed for the button onclick
|
||||
window.startSlideshow = function () { doStartSlideshow(); };
|
||||
|
||||
window.exitSlideshow = function () {
|
||||
if (urlDriven) { window.location.href = window.location.pathname; return; }
|
||||
isRunning = false; clearTimeout(slideshowTimer); if (refreshTimer) clearInterval(refreshTimer);
|
||||
$slideshowScreen.style.display = 'none'; $setupScreen.style.display = 'flex'; document.body.classList.add('setup-mode');
|
||||
$btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false;
|
||||
$layerA.style.backgroundImage = ''; $layerB.style.backgroundImage = ''; $bgBlur.style.backgroundImage = '';
|
||||
$layerA.classList.add('active'); $layerB.classList.remove('active'); $bgBlur.classList.remove('visible'); activeLayer = 'a';
|
||||
isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();
|
||||
$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');
|
||||
$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;
|
||||
$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();
|
||||
};
|
||||
|
||||
function showNextPhoto() { currentIndex++; if (currentIndex >= assets.length) { if (config.shuffle) shuffleArray(assets); currentIndex = 0; } showPhoto(currentIndex); }
|
||||
function showPrevPhoto() { currentIndex--; if (currentIndex < 0) currentIndex = assets.length - 1; showPhoto(currentIndex); }
|
||||
function showPhoto(idx) {
|
||||
if (!assets[idx]) return; clearTimeout(slideshowTimer);
|
||||
var a = assets[idx], url = '/api/assets/' + a.id + '/thumbnail?size=preview';
|
||||
var img = new Image(); img.onload = function () { displayImage(url, a); }; img.onerror = function () { setTimeout(showNextPhoto, 500); }; img.src = url;
|
||||
preloadNext(idx + 1);
|
||||
function showNextAsset() { currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex); }
|
||||
function showPrevAsset() { currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex); }
|
||||
|
||||
function showAsset(index) {
|
||||
if (!assets[index]) return;
|
||||
clearTimeout(slideshowTimer); stopVideo();
|
||||
var asset = assets[index], isVideo = asset.type === 'VIDEO';
|
||||
var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview';
|
||||
console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id));
|
||||
if (currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) dropPhotoPile('/api/assets/' + assets[pi].id + '/thumbnail?size=thumbnail'); }
|
||||
$mainFrame.classList.remove('visible');
|
||||
var img = new Image();
|
||||
img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); };
|
||||
img.onerror = function () { setTimeout(showNextAsset, 500); };
|
||||
img.src = thumbUrl;
|
||||
var ni = index + 1; if (ni >= assets.length) ni = 0;
|
||||
if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; }
|
||||
}
|
||||
function displayImage(url, asset) {
|
||||
var fit = config.imageFit || 'contain', inc, out;
|
||||
if (activeLayer === 'a') { inc = $layerB; out = $layerA; activeLayer = 'b'; } else { inc = $layerA; out = $layerB; activeLayer = 'a'; }
|
||||
inc.style.backgroundImage = 'url(' + url + ')'; inc.style.backgroundSize = fit;
|
||||
inc.classList.add('active'); out.classList.remove('active');
|
||||
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + url + ')'; $bgBlur.classList.add('visible'); }
|
||||
updateExifInfo(asset); startProgress();
|
||||
slideshowTimer = setTimeout(showNextPhoto, (config.slideshowInterval || 30) * 1000);
|
||||
|
||||
function displayAsset(asset, thumbUrl, isVideo) {
|
||||
if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); }
|
||||
$mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none';
|
||||
if (isVideo) {
|
||||
$mainVideo.style.display = 'block';
|
||||
$mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl;
|
||||
$mainVideo.load();
|
||||
$mainVideo.play().then(function(){ currentVideoPlaying=true; }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); });
|
||||
$mainVideo.onended = function () { currentVideoPlaying=false; showNextAsset(); };
|
||||
slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){showNextAsset();} }, Math.max((config.slideshowInterval||30)*3, 120)*1000);
|
||||
} else {
|
||||
$mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl;
|
||||
slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000);
|
||||
}
|
||||
requestAnimationFrame(function () { $mainFrame.classList.add('visible'); });
|
||||
updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000);
|
||||
}
|
||||
function preloadNext(i) { if (i >= assets.length) i = 0; if (!assets[i]) return; var img = new Image(); img.src = '/api/assets/' + assets[i].id + '/thumbnail?size=preview'; }
|
||||
function updateExifInfo(a) {
|
||||
if (!config.showExif || !a.exifInfo) { $exifInfo.textContent = ''; return; }
|
||||
var p = [], e = a.exifInfo, loc = [e.city, e.state, e.country].filter(Boolean).join(', ');
|
||||
if (loc) p.push('📍 ' + loc);
|
||||
if (e.dateTimeOriginal) p.push(formatDate(new Date(e.dateTimeOriginal)));
|
||||
else if (a.fileCreatedAt) p.push(formatDate(new Date(a.fileCreatedAt)));
|
||||
if (e.make || e.model) p.push('📷 ' + [e.make, e.model].filter(Boolean).join(' '));
|
||||
$exifInfo.textContent = p.join(' • ');
|
||||
}
|
||||
function startProgress() {
|
||||
if (!config.showProgress) return;
|
||||
$progressFill.style.transition = 'none'; $progressFill.style.width = '0%'; $progressFill.offsetWidth;
|
||||
$progressFill.style.transition = 'width ' + ((config.slideshowInterval || 30) * 1000) + 'ms linear'; $progressFill.style.width = '100%';
|
||||
}
|
||||
function updateClock() {
|
||||
var n = new Date();
|
||||
if (config.showClock) $clock.textContent = padZero(n.getHours()) + ':' + padZero(n.getMinutes());
|
||||
if (config.showDate) $dateDisplay.textContent = n.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
window.toggleOverlay = function () { overlayVisible = !overlayVisible; if (overlayVisible) { $overlay.classList.remove('hidden'); $btnSettings.classList.add('visible'); scheduleOverlayHide(); } else { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); } };
|
||||
function scheduleOverlayHide() { clearTimeout(overlayTimeout); overlayTimeout = setTimeout(function () { $overlay.classList.add('hidden'); $btnSettings.classList.remove('visible'); overlayVisible = false; }, 8000); }
|
||||
window.nextPhoto = function () { showNextPhoto(); if (overlayVisible) scheduleOverlayHide(); };
|
||||
window.prevPhoto = function () { showPrevPhoto(); if (overlayVisible) scheduleOverlayHide(); };
|
||||
document.addEventListener('keydown', function (e) { if (!isRunning) return; switch (e.key) { case 'ArrowRight': case ' ': e.preventDefault(); nextPhoto(); break; case 'ArrowLeft': e.preventDefault(); prevPhoto(); break; case 'Escape': exitSlideshow(); break; case 'f': toggleFullscreen(); break; case 'i': toggleOverlay(); break; } });
|
||||
function toggleFullscreen() { if (!document.fullscreenElement && !document.webkitFullscreenElement) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } }
|
||||
function shuffleArray(arr) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } }
|
||||
function padZero(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
function formatDate(d) { var m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return d.getDate() + ' ' + m[d.getMonth()] + ' ' + d.getFullYear(); }
|
||||
function escapeHtml(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; }
|
||||
async function requestWakeLock() { try { if ('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch (e) {} }
|
||||
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && isRunning) requestWakeLock(); });
|
||||
function preventSleep() { try { var v = document.createElement('video'); v.setAttribute('playsinline',''); v.setAttribute('muted',''); v.setAttribute('loop',''); v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0.01'; v.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA'; document.body.appendChild(v); v.play().catch(function(){}); } catch(e){} }
|
||||
init(); requestWakeLock(); preventSleep();
|
||||
|
||||
function stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} }
|
||||
|
||||
function updateExifInfo(a) { if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · '); }
|
||||
function startProgress(ms) { if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';} }
|
||||
function updateClock() { var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'}); }
|
||||
window.toggleOverlay = function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}};
|
||||
function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);}
|
||||
window.nextPhoto = function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
window.prevPhoto = function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();};
|
||||
document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case 'ArrowRight':case ' ':e.preventDefault();nextPhoto();break;case 'ArrowLeft':e.preventDefault();prevPhoto();break;case 'Escape':exitSlideshow();break;case 'f':toggleFullscreen();break;case 'i':toggleOverlay();break;}});
|
||||
function toggleFullscreen(){if(!document.fullscreenElement&&!document.webkitFullscreenElement){var el=document.documentElement;if(el.requestFullscreen)el.requestFullscreen();else if(el.webkitRequestFullscreen)el.webkitRequestFullscreen();}else{if(document.exitFullscreen)document.exitFullscreen();else if(document.webkitExitFullscreen)document.webkitExitFullscreen();}}
|
||||
function shuffleArray(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}}
|
||||
function padZero(n){return n<10?'0'+n:''+n;}
|
||||
function formatDate(d){var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d.getDate()+' '+m[d.getMonth()]+' '+d.getFullYear();}
|
||||
function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;}
|
||||
async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}}
|
||||
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();});
|
||||
function preventSleep(){try{var v=document.createElement('video');v.setAttribute('playsinline','');v.setAttribute('muted','');v.setAttribute('loop','');v.style.cssText='position:absolute;width:1px;height:1px;opacity:0.01';v.src='data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';document.body.appendChild(v);v.play().catch(function(){});}catch(e){}}
|
||||
init();requestWakeLock();preventSleep();
|
||||
})();
|
||||
|
||||
@@ -3,7 +3,7 @@ const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const VERSION = '1.2.2';
|
||||
const VERSION = '1.3.0';
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -21,161 +21,92 @@ const SHUFFLE = process.env.SHUFFLE !== 'false';
|
||||
const ALBUM_ID = process.env.ALBUM_ID || '';
|
||||
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
|
||||
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
||||
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
||||
|
||||
function immichHeaders() {
|
||||
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
function log(msg) { console.log('[Frambe] ' + msg); }
|
||||
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
|
||||
|
||||
// --- Request logging for API calls ---
|
||||
app.use('/api', (req, _res, next) => {
|
||||
log('API ' + req.method + ' ' + req.originalUrl);
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Static files with no-cache on HTML/JS/CSS (prevents stale browser cache) ---
|
||||
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');
|
||||
res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0');
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// --- API: Config ---
|
||||
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,
|
||||
connected: !!API_KEY,
|
||||
});
|
||||
});
|
||||
|
||||
// --- API: Server info ---
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API: Albums ---
|
||||
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 }); }
|
||||
});
|
||||
|
||||
function mapAsset(a) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
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) => {
|
||||
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 = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||
log('Album "' + album.albumName + '" returned ' + assets.length + ' images');
|
||||
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
|
||||
try { log('Fetching album ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const album = await r.json(); const assets = filterAssets(album.assets || []).map(mapAsset); const vids = assets.filter(a => a.type === 'VIDEO').length; log('Album "' + album.albumName + '" returned ' + assets.length + ' assets (' + (assets.length - vids) + ' photos, ' + vids + ' videos)'); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
|
||||
} catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people', async (_req, res) => {
|
||||
try {
|
||||
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
const data = await r.json();
|
||||
const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }));
|
||||
log('Listed ' + people.length + ' people');
|
||||
res.json(people);
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })); log('Listed ' + people.length + ' people'); res.json(people);
|
||||
} catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people/:id', async (req, res) => {
|
||||
try {
|
||||
log('Fetching person ' + req.params.id);
|
||||
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
const assets = await r.json();
|
||||
const images = (Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||
log('Person returned ' + images.length + ' images');
|
||||
res.json(images);
|
||||
try { log('Fetching person ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const raw = await r.json(); const assets = filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset); log('Person returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/people/:id/thumbnail', async (req, res) => {
|
||||
try {
|
||||
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
r.body.pipe(res);
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/random', async (req, res) => {
|
||||
try {
|
||||
const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
|
||||
const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
const images = (await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset);
|
||||
log('Random returned ' + images.length + ' images');
|
||||
res.json(images);
|
||||
try { const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const assets = filterAssets(await r.json()).map(mapAsset); log('Random returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/favorites', async (_req, res) => {
|
||||
try {
|
||||
const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
const data = await r.json();
|
||||
const images = (data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }));
|
||||
log('Favorites returned ' + images.length + ' images');
|
||||
res.json(images);
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const data = await r.json(); const assets = filterAssets(data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })); log('Favorites returned ' + assets.length + ' assets'); res.json(assets);
|
||||
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/thumbnail', async (req, res) => {
|
||||
try {
|
||||
const size = req.query.size || 'preview';
|
||||
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
r.body.pipe(res);
|
||||
try { const size = req.query.size || 'preview'; const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/video', async (req, res) => {
|
||||
try { log('Streaming video ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/video/playback`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res);
|
||||
} catch (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id/original', async (req, res) => {
|
||||
try {
|
||||
const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } });
|
||||
if (!r.ok) throw new Error(`Immich returned ${r.status}`);
|
||||
res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
r.body.pipe(res);
|
||||
try { const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res);
|
||||
} catch (err) { res.status(502).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
@@ -187,6 +118,7 @@ app.listen(PORT, '0.0.0.0', () => {
|
||||
log('Immich URL: ' + IMMICH_URL);
|
||||
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
||||
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's');
|
||||
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
|
||||
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
|
||||
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
|
||||
log('Waiting for requests...');
|
||||
|
||||
Reference in New Issue
Block a user