diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..c5bfa2d --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,555 @@ +// === ImmichFrame - Frontend Application === +(function () { + 'use strict'; + + // --- State --- + var config = {}; + var assets = []; + var currentIndex = -1; + var activeLayer = 'a'; + var slideshowTimer = null; + var progressTimer = null; + var progressStart = 0; + var overlayVisible = true; + var overlayTimeout = null; + var selectedSource = null; + var selectedAlbumId = null; + var isRunning = false; + var preloadedImages = {}; + + // --- DOM Elements --- + var $setupScreen = document.getElementById('setup-screen'); + var $slideshowScreen = document.getElementById('slideshow-screen'); + var $connectionStatus = document.getElementById('connection-status'); + var $setupContent = document.getElementById('setup-content'); + var $setupError = document.getElementById('setup-error'); + var $errorDetail = document.getElementById('error-detail'); + var $albumsList = document.getElementById('albums-list'); + var $btnStart = document.getElementById('btn-start'); + var $layerA = document.getElementById('photo-layer-a'); + var $layerB = document.getElementById('photo-layer-b'); + var $bgBlur = document.getElementById('bg-blur'); + var $clock = document.getElementById('clock'); + var $dateDisplay = document.getElementById('date-display'); + var $exifInfo = document.getElementById('exif-info'); + var $progressFill = document.getElementById('progress-fill'); + var $overlay = document.getElementById('overlay'); + var $btnSettings = document.getElementById('btn-settings'); + var $progressBar = document.getElementById('progress-bar'); + + // --- Initialization --- + async function init() { + document.body.classList.add('setup-mode'); + try { + // Load config + var configRes = await fetch('/api/config'); + config = await configRes.json(); + + if (!config.connected) { + showError('API key not configured. Set IMMICH_API_KEY in your environment.'); + return; + } + + // Test connection + var serverRes = await fetch('/api/server-info'); + var serverInfo = await serverRes.json(); + + if (!serverInfo.ok) { + showError('Cannot reach Immich server: ' + serverInfo.error); + return; + } + + $connectionStatus.textContent = 'Connected to Immich v' + serverInfo.version.major + '.' + serverInfo.version.minor + '.' + serverInfo.version.patch; + $connectionStatus.classList.add('connected'); + + // Load albums + await loadAlbums(); + + // If a default album is set, auto-start + if (config.albumId) { + selectedSource = 'album'; + selectedAlbumId = config.albumId; + $btnStart.disabled = false; + startSlideshow(); + return; + } + + if (config.showFavoritesOnly) { + selectedSource = 'favorites'; + $btnStart.disabled = false; + startSlideshow(); + return; + } + + } catch (err) { + showError('Failed to initialize: ' + err.message); + } + } + + function showError(msg) { + $setupContent.style.display = 'none'; + $setupError.style.display = 'block'; + $errorDetail.textContent = msg; + } + + // --- Albums --- + async function loadAlbums() { + try { + var res = await fetch('/api/albums'); + var albums = await res.json(); + + if (!albums.length) { + $albumsList.innerHTML = '

No albums found

'; + return; + } + + var html = ''; + for (var i = 0; i < albums.length; i++) { + var a = albums[i]; + var thumbUrl = a.albumThumbnailAssetId + ? '/api/assets/' + a.albumThumbnailAssetId + '/thumbnail?size=thumbnail' + : ''; + html += '
'; + if (thumbUrl) { + html += ''; + } else { + html += '
📁
'; + } + html += '
'; + html += '
' + escapeHtml(a.albumName) + '
'; + html += '
' + a.assetCount + ' photos
'; + html += '
'; + } + $albumsList.innerHTML = html; + } catch (err) { + $albumsList.innerHTML = '

Failed to load albums

'; + } + } + + // --- Source Selection --- + window.selectSource = function (source) { + selectedSource = source; + selectedAlbumId = null; + + // Update button states + document.getElementById('btn-all-photos').classList.toggle('selected', source === 'random'); + document.getElementById('btn-favorites').classList.toggle('selected', source === 'favorites'); + + // Deselect albums + var albumItems = document.querySelectorAll('.album-item'); + for (var i = 0; i < albumItems.length; i++) { + albumItems[i].classList.remove('selected'); + } + + $btnStart.disabled = false; + }; + + window.selectAlbum = function (albumId, el) { + selectedSource = 'album'; + selectedAlbumId = albumId; + + // Update button states + document.getElementById('btn-all-photos').classList.remove('selected'); + document.getElementById('btn-favorites').classList.remove('selected'); + + var albumItems = document.querySelectorAll('.album-item'); + for (var i = 0; i < albumItems.length; i++) { + albumItems[i].classList.remove('selected'); + } + el.classList.add('selected'); + + $btnStart.disabled = false; + }; + + // --- Load Assets --- + async function loadAssets() { + var res; + if (selectedSource === 'album' && selectedAlbumId) { + res = await fetch('/api/albums/' + selectedAlbumId); + var album = await res.json(); + assets = album.assets || []; + } else if (selectedSource === 'favorites') { + res = await fetch('/api/assets/favorites'); + assets = await res.json(); + } else { + res = await fetch('/api/assets/random?count=100'); + assets = await res.json(); + } + + if (config.shuffle) { + shuffleArray(assets); + } + } + + // --- Slideshow Control --- + window.startSlideshow = async function () { + if (!selectedSource) return; + + $btnStart.disabled = true; + $btnStart.innerHTML = ' Loading…'; + + try { + await loadAssets(); + + if (!assets.length) { + $btnStart.textContent = 'No photos found'; + setTimeout(function () { + $btnStart.textContent = '▶ Start Slideshow'; + $btnStart.disabled = false; + }, 2000); + return; + } + + // Switch to slideshow + $setupScreen.style.display = 'none'; + $slideshowScreen.style.display = 'block'; + document.body.classList.remove('setup-mode'); + isRunning = true; + + // Apply config + var transMs = (config.transitionDuration || 2) * 1000; + $layerA.style.transition = 'opacity ' + transMs + 'ms ease'; + $layerB.style.transition = 'opacity ' + transMs + 'ms ease'; + $bgBlur.style.transition = 'opacity ' + (transMs * 0.75) + 'ms ease'; + + if (!config.showClock) $clock.style.display = 'none'; + if (!config.showDate) $dateDisplay.style.display = 'none'; + if (!config.showExif) $exifInfo.style.display = 'none'; + if (!config.showProgress) $progressBar.style.display = 'none'; + if (!config.backgroundBlur) $bgBlur.style.display = 'none'; + + // Start clock + updateClock(); + setInterval(updateClock, 1000); + + // Show first photo + currentIndex = -1; + showNextPhoto(); + + // Auto-hide overlay + scheduleOverlayHide(); + + } catch (err) { + $btnStart.textContent = 'Error: ' + err.message; + setTimeout(function () { + $btnStart.textContent = '▶ Start Slideshow'; + $btnStart.disabled = false; + }, 3000); + } + }; + + window.exitSlideshow = function () { + isRunning = false; + clearTimeout(slideshowTimer); + clearInterval(progressTimer); + + $slideshowScreen.style.display = 'none'; + $setupScreen.style.display = 'flex'; + document.body.classList.add('setup-mode'); + + $btnStart.textContent = '▶ Start Slideshow'; + $btnStart.disabled = false; + + // Reset layers + $layerA.style.backgroundImage = ''; + $layerB.style.backgroundImage = ''; + $bgBlur.style.backgroundImage = ''; + $layerA.classList.add('active'); + $layerB.classList.remove('active'); + $bgBlur.classList.remove('visible'); + activeLayer = 'a'; + }; + + // --- Photo Display --- + function showNextPhoto() { + currentIndex++; + if (currentIndex >= assets.length) { + // Reload and reshuffle for infinite loop + if (config.shuffle) shuffleArray(assets); + currentIndex = 0; + } + showPhoto(currentIndex); + } + + function showPrevPhoto() { + currentIndex--; + if (currentIndex < 0) currentIndex = assets.length - 1; + showPhoto(currentIndex); + } + + function showPhoto(index) { + if (!assets[index]) return; + + clearTimeout(slideshowTimer); + clearInterval(progressTimer); + + var asset = assets[index]; + var imageUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview'; + + // Preload the image before showing + var img = new Image(); + img.onload = function () { + displayImage(imageUrl, asset); + }; + img.onerror = function () { + // Skip broken images + setTimeout(showNextPhoto, 500); + }; + img.src = imageUrl; + + // Preload next image + preloadNext(index + 1); + } + + function displayImage(url, asset) { + var fitStyle = config.imageFit || 'contain'; + var incomingLayer, outgoingLayer; + + if (activeLayer === 'a') { + incomingLayer = $layerB; + outgoingLayer = $layerA; + activeLayer = 'b'; + } else { + incomingLayer = $layerA; + outgoingLayer = $layerB; + activeLayer = 'a'; + } + + // Set the image + incomingLayer.style.backgroundImage = 'url(' + url + ')'; + incomingLayer.style.backgroundSize = fitStyle; + + // Crossfade + incomingLayer.classList.add('active'); + outgoingLayer.classList.remove('active'); + + // Background blur + if (config.backgroundBlur) { + $bgBlur.style.backgroundImage = 'url(' + url + ')'; + $bgBlur.classList.add('visible'); + } + + // Update EXIF info + updateExifInfo(asset); + + // Progress bar + startProgress(); + + // Schedule next + var interval = (config.slideshowInterval || 30) * 1000; + slideshowTimer = setTimeout(showNextPhoto, interval); + } + + function preloadNext(index) { + if (index >= assets.length) index = 0; + if (!assets[index]) return; + var img = new Image(); + img.src = '/api/assets/' + assets[index].id + '/thumbnail?size=preview'; + } + + // --- EXIF Info --- + function updateExifInfo(asset) { + if (!config.showExif || !asset.exifInfo) { + $exifInfo.textContent = ''; + return; + } + + var parts = []; + var exif = asset.exifInfo; + + // Location + var location = [exif.city, exif.state, exif.country].filter(Boolean).join(', '); + if (location) parts.push('📍 ' + location); + + // Date + if (exif.dateTimeOriginal) { + var d = new Date(exif.dateTimeOriginal); + parts.push(formatDate(d)); + } else if (asset.fileCreatedAt) { + var d2 = new Date(asset.fileCreatedAt); + parts.push(formatDate(d2)); + } + + // Camera + if (exif.make || exif.model) { + var camera = [exif.make, exif.model].filter(Boolean).join(' '); + parts.push('📷 ' + camera); + } + + $exifInfo.textContent = parts.join(' • '); + } + + // --- Progress Bar --- + function startProgress() { + if (!config.showProgress) return; + + $progressFill.style.transition = 'none'; + $progressFill.style.width = '0%'; + + progressStart = Date.now(); + var duration = (config.slideshowInterval || 30) * 1000; + + // Force reflow + $progressFill.offsetWidth; + + $progressFill.style.transition = 'width ' + duration + 'ms linear'; + $progressFill.style.width = '100%'; + } + + // --- Clock --- + function updateClock() { + var now = new Date(); + + if (config.showClock) { + var h = now.getHours(); + var m = now.getMinutes(); + $clock.textContent = padZero(h) + ':' + padZero(m); + } + + if (config.showDate) { + var options = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }; + $dateDisplay.textContent = now.toLocaleDateString(undefined, options); + } + } + + // --- Overlay Toggle --- + window.toggleOverlay = function () { + overlayVisible = !overlayVisible; + + if (overlayVisible) { + $overlay.classList.remove('hidden'); + $btnSettings.classList.add('visible'); + scheduleOverlayHide(); + } else { + $overlay.classList.add('hidden'); + $btnSettings.classList.remove('visible'); + } + }; + + function scheduleOverlayHide() { + clearTimeout(overlayTimeout); + overlayTimeout = setTimeout(function () { + $overlay.classList.add('hidden'); + $btnSettings.classList.remove('visible'); + overlayVisible = false; + }, 8000); + } + + // --- Navigation --- + window.nextPhoto = function () { + showNextPhoto(); + if (overlayVisible) scheduleOverlayHide(); + }; + + window.prevPhoto = function () { + showPrevPhoto(); + if (overlayVisible) scheduleOverlayHide(); + }; + + // --- Keyboard Controls --- + document.addEventListener('keydown', function (e) { + if (!isRunning) return; + + switch (e.key) { + case 'ArrowRight': + case ' ': + e.preventDefault(); + nextPhoto(); + break; + case 'ArrowLeft': + e.preventDefault(); + prevPhoto(); + break; + case 'Escape': + exitSlideshow(); + break; + case 'f': + toggleFullscreen(); + break; + case 'i': + toggleOverlay(); + break; + } + }); + + // --- Fullscreen --- + function toggleFullscreen() { + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + var el = document.documentElement; + if (el.requestFullscreen) el.requestFullscreen(); + else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); + } else { + if (document.exitFullscreen) document.exitFullscreen(); + else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); + } + } + + // --- Utility --- + function shuffleArray(arr) { + for (var i = arr.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + } + + function padZero(n) { + return n < 10 ? '0' + n : '' + n; + } + + function formatDate(d) { + var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return d.getDate() + ' ' + months[d.getMonth()] + ' ' + d.getFullYear(); + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + // --- Keep screen awake (where supported) --- + async function requestWakeLock() { + try { + if ('wakeLock' in navigator) { + await navigator.wakeLock.request('screen'); + } + } catch (e) { + // Not supported or permission denied - that's fine + } + } + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible' && isRunning) { + requestWakeLock(); + } + }); + + // --- Prevent screen sleep on older devices via video trick --- + function preventSleep() { + try { + var noSleep = document.createElement('video'); + noSleep.setAttribute('playsinline', ''); + noSleep.setAttribute('muted', ''); + noSleep.setAttribute('loop', ''); + noSleep.style.position = 'absolute'; + noSleep.style.width = '1px'; + noSleep.style.height = '1px'; + noSleep.style.opacity = '0.01'; + // Tiny transparent video data URI + noSleep.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAA' + + 'htZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAA' + + 'AAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hk' + + 'AAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAA' + + 'AAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA'; + document.body.appendChild(noSleep); + noSleep.play().catch(function () {}); + } catch (e) {} + } + + // --- Boot --- + init(); + requestWakeLock(); + preventSleep(); +})();