619 lines
17 KiB
JavaScript
619 lines
17 KiB
JavaScript
let isPinching = false;
|
||
let pinchZoomOccurred = false;
|
||
let initialPinchDistance = null;
|
||
let initialScale = 1;
|
||
let currentScale = 1;
|
||
let exifAbortController = null;
|
||
let isGalleryLoading = false;
|
||
let autoPlayTimer = null;
|
||
let isAutoPlay = false;
|
||
const AUTO_PLAY_INTERVAL_MS = 5000;
|
||
let pendingAutoAdvance = false;
|
||
|
||
// Assume that currentGalleryImages is already declared in app.js.
|
||
// Only declare currentGalleryIndex if it isn't defined already.
|
||
if (typeof currentGalleryIndex === "undefined") {
|
||
var currentGalleryIndex = -1;
|
||
}
|
||
|
||
let currentExifRequestId = 0;
|
||
|
||
function encodeGalleryPath(relUrl) {
|
||
if (!relUrl) {
|
||
return '';
|
||
}
|
||
return relUrl
|
||
.split('/')
|
||
.map(segment => encodeURIComponent(segment))
|
||
.join('/');
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[char]));
|
||
}
|
||
|
||
function renderExifData(exif) {
|
||
const exifPanel = document.getElementById('gallery-exif');
|
||
if (!exifPanel) {
|
||
return;
|
||
}
|
||
|
||
const entries = exif && Object.keys(exif).length > 0 ? exif : null;
|
||
if (!entries) {
|
||
exifPanel.innerHTML = '<div class="exif-row exif-placeholder">No EXIF data</div>';
|
||
return;
|
||
}
|
||
|
||
const importantKeys = [
|
||
'Model',
|
||
'Make',
|
||
'LensModel',
|
||
'DateTimeOriginal',
|
||
'ExposureTime',
|
||
'FNumber',
|
||
'ISOSpeedRatings',
|
||
'FocalLength',
|
||
'FocalLengthIn35mmFilm',
|
||
'ExposureBiasValue',
|
||
'WhiteBalance',
|
||
'Software'
|
||
];
|
||
const rows = [];
|
||
|
||
importantKeys.forEach(key => {
|
||
const value = exif[key];
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
rows.push([key, value]);
|
||
}
|
||
});
|
||
|
||
if (rows.length === 0) {
|
||
exifPanel.innerHTML = '<div class="exif-row exif-placeholder">No EXIF data</div>';
|
||
return;
|
||
}
|
||
|
||
exifPanel.innerHTML = rows.map(([key, value]) => {
|
||
const safeKey = escapeHtml(key);
|
||
const safeValue = escapeHtml(Array.isArray(value) ? value.join(', ') : value);
|
||
return `<div class="exif-row"><span class="exif-key">${safeKey}</span><span class="exif-value">${safeValue}</span></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function showExifPlaceholder(message) {
|
||
const exifPanel = document.getElementById('gallery-exif');
|
||
if (!exifPanel) {
|
||
return;
|
||
}
|
||
exifPanel.innerHTML = `<div class="exif-row exif-placeholder">${message}</div>`;
|
||
}
|
||
|
||
function loadExifData(relUrl) {
|
||
const exifPanel = document.getElementById('gallery-exif');
|
||
const infoButton = document.getElementById('gallery-info');
|
||
if (!exifPanel || !infoButton) {
|
||
return;
|
||
}
|
||
|
||
const requestId = ++currentExifRequestId;
|
||
if (exifAbortController) {
|
||
try {
|
||
exifAbortController.abort();
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
exifAbortController = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||
|
||
infoButton.classList.add('loading');
|
||
showExifPlaceholder('Loading EXIF...');
|
||
|
||
fetch(`/media_exif/${encodeGalleryPath(relUrl)}`, exifAbortController ? { signal: exifAbortController.signal } : {})
|
||
.then(res => {
|
||
if (!res.ok) {
|
||
throw new Error('EXIF fetch failed');
|
||
}
|
||
return res.json();
|
||
})
|
||
.then(data => {
|
||
if (requestId !== currentExifRequestId) {
|
||
return;
|
||
}
|
||
infoButton.classList.remove('loading');
|
||
renderExifData((data && data.exif) || {});
|
||
})
|
||
.catch((err) => {
|
||
if (err && err.name === 'AbortError') {
|
||
return;
|
||
}
|
||
if (requestId !== currentExifRequestId) {
|
||
return;
|
||
}
|
||
infoButton.classList.remove('loading');
|
||
showExifPlaceholder('EXIF not available');
|
||
});
|
||
}
|
||
|
||
function toggleExifPanel() {
|
||
const exifPanel = document.getElementById('gallery-exif');
|
||
const infoButton = document.getElementById('gallery-info');
|
||
if (!exifPanel || !infoButton) {
|
||
return;
|
||
}
|
||
const isOpen = exifPanel.classList.toggle('open');
|
||
infoButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||
}
|
||
|
||
function resetExifPanel(clearOpen = true) {
|
||
const exifPanel = document.getElementById('gallery-exif');
|
||
const infoButton = document.getElementById('gallery-info');
|
||
currentExifRequestId++;
|
||
if (exifAbortController) {
|
||
try {
|
||
exifAbortController.abort();
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
exifAbortController = null;
|
||
}
|
||
if (exifPanel) {
|
||
exifPanel.innerHTML = '';
|
||
if (clearOpen) {
|
||
exifPanel.classList.remove('open');
|
||
}
|
||
}
|
||
if (infoButton && clearOpen) {
|
||
infoButton.setAttribute('aria-expanded', 'false');
|
||
infoButton.classList.remove('loading');
|
||
}
|
||
}
|
||
|
||
function updateFilenameDisplay(relUrl) {
|
||
const filenameEl = document.getElementById('gallery-filename');
|
||
if (!filenameEl) {
|
||
return;
|
||
}
|
||
|
||
if (!relUrl) {
|
||
filenameEl.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const parts = relUrl.split('/');
|
||
const filename = parts[parts.length - 1] || relUrl;
|
||
filenameEl.textContent = filename;
|
||
}
|
||
|
||
function updateNextButtonState() {
|
||
const nextButton = document.querySelector('.gallery-next');
|
||
if (!nextButton) {
|
||
return;
|
||
}
|
||
|
||
const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1;
|
||
nextButton.textContent = atLastImage ? 'x' : '›';
|
||
nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image');
|
||
}
|
||
|
||
function openGalleryModal(relUrl) {
|
||
document.body.style.overflow = 'hidden'; // Disable background scrolling.
|
||
currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
|
||
showGalleryImage(relUrl);
|
||
document.getElementById('gallery-modal').style.display = 'flex';
|
||
}
|
||
|
||
function showGalleryImage(relUrl) {
|
||
if (isGalleryLoading) {
|
||
return;
|
||
}
|
||
isGalleryLoading = true;
|
||
clearAutoPlayTimer();
|
||
updateNextButtonState();
|
||
loadExifData(relUrl);
|
||
const fullUrl = '/media/' + relUrl;
|
||
const modal = document.getElementById('gallery-modal');
|
||
const currentImage = document.getElementById('gallery-modal-content');
|
||
const loader = document.getElementById('loader-container');
|
||
const closeBtn = document.getElementById('gallery-close');
|
||
|
||
// Set a timeout for showing the loader after 500ms.
|
||
let loaderTimeout = setTimeout(() => {
|
||
loader.style.display = 'flex';
|
||
}, 500);
|
||
|
||
// Preload the new image.
|
||
const tempImg = new Image();
|
||
tempImg.src = fullUrl;
|
||
|
||
tempImg.onload = function () {
|
||
clearTimeout(loaderTimeout);
|
||
loader.style.display = 'none';
|
||
updateFilenameDisplay(relUrl);
|
||
|
||
// Select all current images in the gallery modal
|
||
const existingImages = document.querySelectorAll('#gallery-modal img');
|
||
|
||
// Create a new image element for the transition
|
||
const newImage = document.createElement('img');
|
||
newImage.src = fullUrl;
|
||
newImage.style.position = 'absolute';
|
||
newImage.style.top = '50%'; // Center vertically
|
||
newImage.style.left = '50%'; // Center horizontally
|
||
newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering
|
||
newImage.style.maxWidth = '95vw'; // Prevent overflow on large screens
|
||
newImage.style.maxHeight = '95vh'; // Prevent overflow on tall screens
|
||
newImage.style.transition = 'opacity 0.5s ease-in-out';
|
||
newImage.style.opacity = 0; // Start fully transparent
|
||
newImage.id = 'gallery-modal-content';
|
||
|
||
// Add pinch-to-zoom event listeners
|
||
newImage.addEventListener('touchstart', handlePinchStart, { passive: false });
|
||
newImage.addEventListener('touchmove', handlePinchMove, { passive: false });
|
||
newImage.addEventListener('touchend', handlePinchEnd, { passive: false });
|
||
newImage.addEventListener('click', handleGalleryImageClick);
|
||
|
||
|
||
// Reset pinch variables for the new image
|
||
initialPinchDistance = null;
|
||
initialScale = 1;
|
||
currentScale = 1;
|
||
pinchZoomOccurred = false;
|
||
|
||
// Append new image on top of existing ones
|
||
modal.appendChild(newImage);
|
||
|
||
// Fade out all old images and fade in the new one
|
||
setTimeout(() => {
|
||
existingImages.forEach(img => {
|
||
img.style.opacity = 0; // Apply fade-out
|
||
});
|
||
newImage.style.opacity = 1; // Fade in the new image
|
||
}, 10);
|
||
|
||
// Wait for fade-out to finish, then remove all old images
|
||
setTimeout(() => {
|
||
existingImages.forEach(img => {
|
||
if (img.parentNode) {
|
||
img.parentNode.removeChild(img); // Remove old images
|
||
}
|
||
});
|
||
isGalleryLoading = false;
|
||
if (pendingAutoAdvance && isAutoPlay) {
|
||
pendingAutoAdvance = false;
|
||
attemptAutoAdvance();
|
||
return;
|
||
}
|
||
if (isAutoPlay) {
|
||
scheduleAutoPlayCycle();
|
||
}
|
||
}, 510); // 500ms for fade-out + small buffer
|
||
|
||
// Preload the next image
|
||
preloadNextImage();
|
||
};
|
||
|
||
|
||
tempImg.onerror = function() {
|
||
clearTimeout(loaderTimeout);
|
||
loader.style.display = 'none';
|
||
isGalleryLoading = false;
|
||
pendingAutoAdvance = false;
|
||
if (isAutoPlay) {
|
||
scheduleAutoPlayCycle();
|
||
}
|
||
console.error('Error loading image:', fullUrl);
|
||
};
|
||
}
|
||
|
||
function preloadNextImage() {
|
||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||
const nextImage = new Image();
|
||
nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1];
|
||
}
|
||
}
|
||
|
||
function closeGalleryModal() {
|
||
document.getElementById('gallery-modal').style.display = 'none';
|
||
isGalleryLoading = false;
|
||
setAutoPlay(false);
|
||
// remove all images
|
||
const existingImages = document.querySelectorAll('#gallery-modal img');
|
||
existingImages.forEach(img => {
|
||
if (img.parentNode) {
|
||
img.parentNode.removeChild(img);
|
||
}
|
||
});
|
||
const filenameEl = document.getElementById('gallery-filename');
|
||
if (filenameEl) {
|
||
filenameEl.textContent = '';
|
||
}
|
||
resetExifPanel(true);
|
||
// Restore scrolling.
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function handleGalleryImageClick(e) {
|
||
// If a pinch zoom occurred, don't change the image.
|
||
if (pinchZoomOccurred) {
|
||
pinchZoomOccurred = false; // Reset the flag for the next gesture.
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
if (e.target.classList.contains('gallery-next') || e.target.classList.contains('gallery-prev')) {
|
||
return; // Prevent conflict with navigation buttons.
|
||
}
|
||
|
||
const imgRect = this.getBoundingClientRect();
|
||
if (e.clientX < imgRect.left + imgRect.width / 2) {
|
||
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||
currentGalleryIndex--;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
}
|
||
} else {
|
||
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||
currentGalleryIndex++;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
function initGallerySwipe() {
|
||
let touchStartX = 0;
|
||
let touchEndX = 0;
|
||
const galleryModal = document.getElementById('gallery-modal');
|
||
|
||
galleryModal.addEventListener('touchstart', function(e) {
|
||
// If more than one finger is on screen, ignore swipe tracking.
|
||
if (e.touches.length > 1) {
|
||
return;
|
||
}
|
||
touchStartX = e.changedTouches[0].screenX;
|
||
}, false);
|
||
|
||
galleryModal.addEventListener('touchend', function(e) {
|
||
// If the event was part of a multi-touch (pinch) gesture or a pinch was detected, skip swipe.
|
||
if (e.changedTouches.length > 1 || isPinching || pinchZoomOccurred) {
|
||
return;
|
||
}
|
||
touchEndX = e.changedTouches[0].screenX;
|
||
handleSwipe();
|
||
}, false);
|
||
|
||
function handleSwipe() {
|
||
const deltaX = touchEndX - touchStartX;
|
||
if (Math.abs(deltaX) > 50) { // Swipe threshold.
|
||
if (deltaX > 0) { // Swipe right: previous image.
|
||
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||
currentGalleryIndex--;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
}
|
||
} else { // Swipe left: next image.
|
||
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||
currentGalleryIndex++;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearAutoPlayTimer() {
|
||
if (autoPlayTimer) {
|
||
clearInterval(autoPlayTimer);
|
||
autoPlayTimer = null;
|
||
}
|
||
}
|
||
|
||
function setAutoPlay(active) {
|
||
const playButton = document.getElementById('gallery-play');
|
||
isAutoPlay = active;
|
||
if (playButton) {
|
||
playButton.textContent = active ? '||' : '>';
|
||
playButton.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
playButton.classList.toggle('playing', active);
|
||
}
|
||
pendingAutoAdvance = false;
|
||
clearAutoPlayTimer();
|
||
if (active) {
|
||
scheduleAutoPlayCycle();
|
||
} else {
|
||
setPlayRingProgress(0);
|
||
}
|
||
}
|
||
|
||
function setPlayRingProgress(deg) {
|
||
const playButton = document.getElementById('gallery-play');
|
||
if (playButton) {
|
||
playButton.style.setProperty('--play-progress', `${deg}deg`);
|
||
}
|
||
}
|
||
|
||
function restartPlayAnimation() {
|
||
const playButton = document.getElementById('gallery-play');
|
||
if (!playButton) {
|
||
return;
|
||
}
|
||
playButton.classList.remove('playing-anim');
|
||
// force reflow to restart animation
|
||
void playButton.offsetWidth;
|
||
setPlayRingProgress(0);
|
||
playButton.classList.add('playing-anim');
|
||
}
|
||
|
||
function completePlayRing() {
|
||
const playButton = document.getElementById('gallery-play');
|
||
if (!playButton) {
|
||
return;
|
||
}
|
||
playButton.classList.remove('playing-anim');
|
||
setPlayRingProgress(360);
|
||
}
|
||
|
||
function scheduleAutoPlayCycle() {
|
||
if (!isAutoPlay) {
|
||
return;
|
||
}
|
||
clearAutoPlayTimer();
|
||
restartPlayAnimation();
|
||
autoPlayTimer = setTimeout(() => {
|
||
triggerAutoAdvance();
|
||
}, AUTO_PLAY_INTERVAL_MS);
|
||
}
|
||
|
||
function triggerAutoAdvance() {
|
||
completePlayRing();
|
||
if (isGalleryLoading) {
|
||
pendingAutoAdvance = true;
|
||
return;
|
||
}
|
||
attemptAutoAdvance();
|
||
}
|
||
|
||
function attemptAutoAdvance() {
|
||
pendingAutoAdvance = false;
|
||
if (!isAutoPlay) {
|
||
return;
|
||
}
|
||
if (currentGalleryIndex >= currentGalleryImages.length - 1) {
|
||
setAutoPlay(false);
|
||
return;
|
||
}
|
||
const nextUrl = currentGalleryImages[currentGalleryIndex + 1];
|
||
const img = new Image();
|
||
img.onload = function() {
|
||
if (!isAutoPlay) {
|
||
return;
|
||
}
|
||
currentGalleryIndex++;
|
||
showGalleryImage(nextUrl);
|
||
};
|
||
img.onerror = function() {
|
||
if (isAutoPlay) {
|
||
scheduleAutoPlayCycle();
|
||
}
|
||
};
|
||
img.src = '/media/' + nextUrl;
|
||
}
|
||
|
||
function handlePinchStart(e) {
|
||
if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
isPinching = true;
|
||
pinchZoomOccurred = false; // Reset at the start of a pinch gesture.
|
||
const dx = e.touches[0].pageX - e.touches[1].pageX;
|
||
const dy = e.touches[0].pageY - e.touches[1].pageY;
|
||
initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
}
|
||
|
||
function handlePinchMove(e) {
|
||
if (e.touches.length === 2 && initialPinchDistance) {
|
||
e.preventDefault();
|
||
const dx = e.touches[0].pageX - e.touches[1].pageX;
|
||
const dy = e.touches[0].pageY - e.touches[1].pageY;
|
||
const currentDistance = Math.sqrt(dx * dx + dy * dy);
|
||
let scale = currentDistance / initialPinchDistance;
|
||
scale = Math.max(0.5, Math.min(scale * initialScale, 3));
|
||
// If the scale has changed enough, mark that a pinch zoom occurred.
|
||
if (Math.abs(scale - initialScale) > 0.05) {
|
||
pinchZoomOccurred = true;
|
||
}
|
||
currentScale = scale;
|
||
e.currentTarget.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||
}
|
||
}
|
||
|
||
function handlePinchEnd(e) {
|
||
if (e.touches.length < 2) {
|
||
initialScale = currentScale;
|
||
initialPinchDistance = null;
|
||
isPinching = false;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function initGalleryControls() {
|
||
const nextButton = document.querySelector('.gallery-next');
|
||
const prevButton = document.querySelector('.gallery-prev');
|
||
const closeButton = document.getElementById('gallery-close');
|
||
const infoButton = document.getElementById('gallery-info');
|
||
const playButton = document.getElementById('gallery-play');
|
||
|
||
if (nextButton) {
|
||
nextButton.addEventListener('click', function () {
|
||
if (isGalleryLoading) {
|
||
return;
|
||
}
|
||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||
currentGalleryIndex++;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
} else {
|
||
closeGalleryModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (prevButton) {
|
||
prevButton.addEventListener('click', function () {
|
||
if (isGalleryLoading) {
|
||
return;
|
||
}
|
||
if (currentGalleryIndex > 0) {
|
||
currentGalleryIndex--;
|
||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (closeButton) {
|
||
closeButton.addEventListener('click', closeGalleryModal);
|
||
}
|
||
|
||
if (infoButton) {
|
||
infoButton.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
toggleExifPanel();
|
||
});
|
||
}
|
||
|
||
if (playButton) {
|
||
playButton.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
setAutoPlay(!isAutoPlay);
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
function initGallery() {
|
||
|
||
initGallerySwipe();
|
||
initGalleryControls();
|
||
|
||
// Close modal when clicking outside the image.
|
||
const galleryModal = document.getElementById('gallery-modal');
|
||
galleryModal.addEventListener('click', function(e) {
|
||
if (e.target === galleryModal) {
|
||
closeGalleryModal();
|
||
}
|
||
});
|
||
|
||
}
|
||
|
||
// Initialize the gallery once the DOM content is loaded.
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener('DOMContentLoaded', initGallery);
|
||
} else {
|
||
initGallery();
|
||
}
|