From c2b40db84340e75f136e86952bf42e9d7b91e87a Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 19 May 2026 21:43:24 +1000 Subject: [PATCH 01/13] feat(1.3.0): video support, INCLUDE_VIDEOS config, video streaming endpoint, asset type in mapAsset --- server.js | 142 ++++++++++++++---------------------------------------- 1 file changed, 37 insertions(+), 105 deletions(-) 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...'); From 9ab2dc7578a65fda10eacf046041e511b96b48c7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 19 May 2026 21:44:28 +1000 Subject: [PATCH 02/13] feat(1.3.0): new DOM structure with photo pile, main polaroid/filmstrip frame, video element --- public/index.html | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/public/index.html b/public/index.html index f924a15..4967e60 100644 --- a/public/index.html +++ b/public/index.html @@ -24,16 +24,10 @@

Select Photo Source

- - -
-
-

Loading albums…

+ +
+

Loading albums…

@@ -47,8 +41,25 @@ '; - } - $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(); })(); From d6b646417107088cbd2c96b23f17e838c89e9190 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 20 May 2026 09:45:56 +1000 Subject: [PATCH 05/13] feat: enlarge main frame to ~90% screen, remove filmstrip, add canvas pile, unified polaroid style --- public/css/style.css | 89 ++++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index dbb3393..a1c781e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -38,67 +38,75 @@ body.setup-mode { cursor: default; } @keyframes spin { to { transform: rotate(360deg); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -/* === SLIDESHOW - VINTAGE POLAROID PILE === */ +/* ============================================= + SLIDESHOW - VINTAGE POLAROID PILE + ============================================= */ #slideshow-screen { background: #1a1510; } +/* Warm blurred bg behind everything */ .bg-blur { position: absolute; top: -30px; left: -30px; width: calc(100% + 60px); height: calc(100% + 60px); background-size: cover; background-position: center; - filter: blur(40px) brightness(0.3) saturate(0.6) sepia(0.3); + filter: blur(40px) brightness(0.25) saturate(0.5) sepia(0.3); opacity: 0; transition: opacity 2s ease; z-index: 1; } .bg-blur.visible { opacity: 1; } -.bg-vignette { +/* Canvas pile of accumulated polaroids */ +#pile-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: radial-gradient(ellipse at center, transparent 40%, rgba(15,12,8,0.7) 100%); z-index: 2; pointer-events: none; } -/* --- Photo pile --- */ -#photo-pile { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 3; pointer-events: none; } -.pile-frame { position: absolute; top: 50%; left: 50%; width: 30vmin; height: 34vmin; transform: translate(-50%, -50%); opacity: 0; transition: all 1.5s ease; pointer-events: none; } -.pile-frame.visible { opacity: 1; } +/* Vignette on top of pile */ +.bg-vignette { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + background: radial-gradient(ellipse at center, transparent 50%, rgba(15,12,8,0.6) 100%); + z-index: 3; pointer-events: none; +} -/* --- Main floating frame --- */ -.main-frame { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; opacity: 0; transition: opacity 1.2s ease; animation: float 25s ease-in-out infinite; } +/* --- Main floating frame (nearly full screen) --- */ +.main-frame { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + z-index: 5; opacity: 0; + transition: opacity 1.2s ease; + animation: float 25s ease-in-out infinite; +} .main-frame.visible { opacity: 1; } @keyframes float { - 0% { transform: translate(-50%, -50%) rotate(0deg) translateX(0) translateY(0); } - 15% { transform: translate(-50%, -50%) rotate(0.8deg) translateX(8px) translateY(-5px); } - 30% { transform: translate(-50%, -50%) rotate(-0.5deg) translateX(-5px) translateY(6px); } - 50% { transform: translate(-50%, -50%) rotate(0.3deg) translateX(6px) translateY(3px); } - 65% { transform: translate(-50%, -50%) rotate(-0.7deg) translateX(-8px) translateY(-4px); } - 80% { transform: translate(-50%, -50%) rotate(0.4deg) translateX(4px) translateY(7px); } - 100% { transform: translate(-50%, -50%) rotate(0deg) translateX(0) translateY(0); } + 0% { transform: translate(-50%, -50%) rotate(0deg) translateX(0) translateY(0); } + 15% { transform: translate(-50%, -50%) rotate(0.6deg) translateX(6px) translateY(-4px); } + 30% { transform: translate(-50%, -50%) rotate(-0.4deg) translateX(-4px) translateY(5px); } + 50% { transform: translate(-50%, -50%) rotate(0.3deg) translateX(5px) translateY(2px); } + 65% { transform: translate(-50%, -50%) rotate(-0.5deg) translateX(-6px) translateY(-3px); } + 80% { transform: translate(-50%, -50%) rotate(0.3deg) translateX(3px) translateY(5px); } + 100% { transform: translate(-50%, -50%) rotate(0deg) translateX(0) translateY(0); } } -/* Polaroid */ -.main-frame.polaroid .frame-border { - background: #f5f0e8; padding: 12px 12px 44px 12px; - box-shadow: 0 4px 30px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.2), inset 0 0 0 1px rgba(0,0,0,0.05); - border-radius: 2px; position: relative; +/* Polaroid frame — used for both photos and videos */ +.main-frame .frame-border { + background: #f5f0e8; + padding: 10px 10px 36px 10px; + 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; } -.main-frame.polaroid .frame-media { display: block; max-width: 72vmin; max-height: 54vmin; width: auto; height: auto; object-fit: contain; background: #2a2520; min-width: 40vmin; min-height: 30vmin; } -.main-frame.polaroid .filmstrip-top, .main-frame.polaroid .filmstrip-bottom { display: none; } -/* Film strip */ -.main-frame.filmstrip .frame-border { - background: #1a1a1a; padding: 0; - box-shadow: 0 4px 30px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.2); - border-radius: 2px; position: relative; -} -.main-frame.filmstrip .frame-media { display: block; max-width: 72vmin; max-height: 54vmin; width: auto; height: auto; object-fit: contain; background: #000; min-width: 40vmin; min-height: 30vmin; } - -.filmstrip-top, .filmstrip-bottom { display: none; position: absolute; left: 0; width: 100%; height: 22px; background: #1a1a1a; z-index: 6; } -.filmstrip-top { top: -22px; border-radius: 2px 2px 0 0; } -.filmstrip-bottom { bottom: -22px; border-radius: 0 0 2px 2px; } -.main-frame.filmstrip .filmstrip-top, .main-frame.filmstrip .filmstrip-bottom { +.main-frame .frame-media { display: block; - background-image: repeating-linear-gradient(90deg, transparent 0px, transparent 10px, #333 10px, #333 12px, transparent 12px, transparent 16px, rgba(255,255,255,0.08) 16px, rgba(255,255,255,0.08) 26px, transparent 26px, transparent 28px, #333 28px, #333 30px, transparent 30px, transparent 40px); - background-size: 40px 22px; background-position: 5px center; + max-width: 88vw; + max-height: 78vh; + width: auto; height: auto; + object-fit: contain; + background: #2a2520; + min-width: 50vw; + min-height: 40vh; } /* === OVERLAY === */ @@ -128,9 +136,8 @@ body.setup-mode { cursor: default; } .overlay-top-right { top: 1rem; right: 1rem; } .overlay-bottom { padding: 1rem 1rem 0.5rem; } .source-buttons { flex-direction: column; } - .main-frame.polaroid .frame-border { padding: 8px 8px 32px 8px; } - .main-frame.polaroid .frame-media, .main-frame.filmstrip .frame-media { max-width: 85vmin; max-height: 60vmin; } - .pile-frame { width: 25vmin; height: 29vmin; } + .main-frame .frame-border { padding: 6px 6px 24px 6px; } + .main-frame .frame-media { max-width: 94vw; max-height: 82vh; } } .albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; } From 65993839a7143a835e361342397bfcc6d0cd3dc0 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 20 May 2026 09:46:24 +1000 Subject: [PATCH 06/13] feat: replace pile divs with canvas, remove filmstrip elements, unified polaroid frame --- public/index.html | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/public/index.html b/public/index.html index 4967e60..f992544 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ - + Frambe @@ -41,23 +41,14 @@