From 0f4a995cdcb5ad23b83c73a2f278645dcb07fe73 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 19 May 2026 21:48:53 +1000 Subject: [PATCH] feat(1.3.0): Polaroid pile slideshow engine, video playback with filmstrip frame, floating animation --- public/js/app.js | 280 ++++++++++++++++++----------------------------- 1 file changed, 104 insertions(+), 176 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 0dd9ba2..41bc6fe 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,236 +1,164 @@ -// === Frambe - Frontend Application === +// === Frambe v1.3.0 - Vintage Polaroid Pile === (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 pileHistory = [], PILE_SIZE = 5, currentVideoPlaying = false; 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; - } + var pilePositions = [ + { x: -18, y: -12, rot: -11, scale: 0.85 }, { x: 22, y: -8, rot: 7, scale: 0.80 }, + { x: -14, y: 15, rot: 14, scale: 0.78 }, { x: 20, y: 12, rot: -9, scale: 0.82 }, + { x: -5, y: -20, rot: 4, scale: 0.75 } + ]; - // --- 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'; - html += thu ? '' : '
📁
'; - html += '
' + escapeHtml(a.albumName) + '
' + a.assetCount + ' photos
'; - } - $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';html+=thu?'':'
📁
';html+='
'+escapeHtml(a.albumName)+'
'+a.assetCount+' items
';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';} } - 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; - }; + 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 0) console.log('Frambe: added ' + added + ' new photo(s)'); - } catch (e) { console.warn('Frambe: refresh failed', e.message); } - }, (config.refreshInterval || 300) * 1000); - } + function startRefreshTimer() { if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(async function(){try{var oldIds={};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); } - // --- Core slideshow start (used by both button click and auto-launch) --- 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; 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'; 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); - } + currentIndex = -1; pileHistory = []; + 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');clearPile(); }; - 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 (pileHistory.length > 0 || currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) addToPile(assets[pi]); } + $mainFrame.classList.remove('visible'); + var img = new Image(); + img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 400); }; + 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'); } + if (isVideo) { + $mainFrame.classList.remove('polaroid'); $mainFrame.classList.add('filmstrip'); + $mainPhoto.style.display = 'none'; $mainVideo.style.display = 'block'; + $mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl; + $mainVideo.load(); + $mainVideo.play().then(function(){ currentVideoPlaying=true; console.log('[Frambe] Video playing'); }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); }); + $mainVideo.onended = function () { console.log('[Frambe] Video ended'); currentVideoPlaying=false; showNextAsset(); }; + var maxDur = Math.max((config.slideshowInterval||30)*3, 120)*1000; + slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){console.log('[Frambe] Video timeout');showNextAsset();} }, maxDur); + } else { + $mainFrame.classList.remove('filmstrip'); $mainFrame.classList.add('polaroid'); + $mainVideo.style.display = 'none'; $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 stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} } + + function addToPile(asset) { + pileHistory.push(asset.id); if (pileHistory.length > PILE_SIZE) pileHistory.shift(); + for (var i = 0; i < PILE_SIZE; i++) { + var $pile = document.getElementById('pile-' + i), $inner = $pile.querySelector('.pile-inner'); + var histIdx = pileHistory.length - 1 - i; + if (histIdx >= 0 && pileHistory[histIdx]) { + var aid = pileHistory[histIdx], pos = pilePositions[i]; + $inner.style.cssText = 'width:100%;height:100%;background-color:#f5f0e8;padding:4% 4% 14% 4%;box-shadow:0 2px 15px rgba(0,0,0,0.4),0 0 1px rgba(0,0,0,0.2);border-radius:2px;'; + $inner.innerHTML = '
'; + $pile.style.transform = 'translate(calc(-50% + '+pos.x+'vmin), calc(-50% + '+pos.y+'vmin)) rotate('+pos.rot+'deg) scale('+pos.scale+')'; + $pile.style.opacity = 0.35 - (i * 0.04); $pile.classList.add('visible'); + } else { $pile.classList.remove('visible'); } + } } - 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 clearPile() { pileHistory=[];for(var i=0;i0;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(); })();