feat: add frontend slideshow with crossfade, touch controls, clock overlay, wake lock
This commit is contained in:
@@ -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 = '<p class="loading-text">No albums found</p>';
|
||||
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 += '<div class="album-item" data-id="' + a.id + '" onclick="selectAlbum(\'' + a.id + '\', this)">';
|
||||
if (thumbUrl) {
|
||||
html += '<img class="album-thumb" src="' + thumbUrl + '" alt="" loading="lazy">';
|
||||
} else {
|
||||
html += '<div class="album-thumb" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem;">📁</div>';
|
||||
}
|
||||
html += '<div class="album-info">';
|
||||
html += '<div class="album-name">' + escapeHtml(a.albumName) + '</div>';
|
||||
html += '<div class="album-count">' + a.assetCount + ' photos</div>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
$albumsList.innerHTML = html;
|
||||
} catch (err) {
|
||||
$albumsList.innerHTML = '<p class="loading-text">Failed to load albums</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 = '<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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user