Files
frambe/public/js/app.js
T

245 lines
18 KiB
JavaScript

// === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) ===
(function () {
'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null;
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false;
var pileCanvas, pileCtx;
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 $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; }
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');
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; }
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); }
}
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>';} }
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);
}
}
// Animate a polaroid fading onto the pile canvas
function dropPhotoPile(imgSrc) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
var vw = window.innerWidth, vh = window.innerHeight;
var polaroidW = vw * (0.18 + Math.random() * 0.07);
var padding = polaroidW * 0.04;
var bottomPad = polaroidW * 0.12;
var innerW = polaroidW - padding * 2;
var innerH = innerW * (img.height / img.width);
var totalH = innerH + padding + bottomPad;
var cx = Math.random() * vw;
var cy = Math.random() * vh;
var rot = (Math.random() - 0.5) * 30;
// Fade in over ~1 second
var startTime = null;
var fadeDuration = 1000;
function drawFrame(timestamp) {
if (!startTime) startTime = timestamp;
var elapsed = timestamp - startTime;
var alpha = Math.min(elapsed / fadeDuration, 1);
// We need to redraw this specific polaroid with increasing alpha
// To avoid clearing the whole canvas, we draw on a temp canvas and composite
pileCtx.save();
pileCtx.globalAlpha = alpha;
pileCtx.translate(cx, cy);
pileCtx.rotate(rot * Math.PI / 180);
// Shadow
pileCtx.shadowColor = 'rgba(0,0,0,0.45)';
pileCtx.shadowBlur = 18;
pileCtx.shadowOffsetX = 3;
pileCtx.shadowOffsetY = 6;
// White polaroid border
pileCtx.fillStyle = '#ede8df';
pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH);
// Clear shadow for image
pileCtx.shadowColor = 'transparent';
pileCtx.shadowBlur = 0;
pileCtx.shadowOffsetX = 0;
pileCtx.shadowOffsetY = 0;
// Photo
pileCtx.drawImage(img, -polaroidW/2 + padding, -totalH/2 + padding, innerW, innerH);
// Sepia/warm wash over the photo
pileCtx.fillStyle = 'rgba(160, 130, 80, 0.15)';
pileCtx.fillRect(-polaroidW/2 + padding, -totalH/2 + padding, innerW, innerH);
pileCtx.restore();
if (alpha < 1) {
requestAnimationFrame(drawFrame);
}
}
requestAnimationFrame(drawFrame);
};
img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); };
img.src = imgSrc;
}
// =========================
// 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; }
$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';
if (!config.showProgress) $progressBar.style.display = 'none';
if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000);
currentIndex = -1;
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); }
}
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; }
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 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));
// Drop previous photo onto pile
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');
}
// Fade out main
$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 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; 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(); };
slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){console.log('[Frambe] Video timeout');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 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();
})();