diff --git a/package.json b/package.json index b92e934..abbeb9e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/css/style.css b/public/css/style.css index 60c69e7..3683f10 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } diff --git a/public/index.html b/public/index.html index f924a15..13856a3 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ - + Frambe @@ -24,16 +24,10 @@

Select Photo Source

- - -
-
-

Loading albums…

+ +
+

Loading albums…

@@ -47,8 +41,19 @@ ';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';} } - async function loadAlbums() { - try { - var albums = await (await fetch('/api/albums')).json(); - if (!albums.length) { $albumsList.innerHTML = '

No albums found

'; 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 += '
'; - html += thu ? '' : '
📁
'; - html += '
' + escapeHtml(a.albumName) + '
' + a.assetCount + ' photos
'; + 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;i0)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 = '

Failed to load albums

'; } + 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 = ' 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(); })(); diff --git a/server.js b/server.js index d434698..d561328 100644 --- a/server.js +++ b/server.js @@ -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...');