1013 lines
28 KiB
JavaScript
1013 lines
28 KiB
JavaScript
let isPinching = false;
|
|
let pinchZoomOccurred = false;
|
|
let initialPinchDistance = null;
|
|
let initialScale = 1;
|
|
let currentScale = 1;
|
|
let currentTranslateX = 0;
|
|
let currentTranslateY = 0;
|
|
let isPanning = false;
|
|
let panStartX = 0;
|
|
let panStartY = 0;
|
|
let panOriginX = 0;
|
|
let panOriginY = 0;
|
|
let pinchStartMidX = 0;
|
|
let pinchStartMidY = 0;
|
|
let pinchStartTranslateX = 0;
|
|
let pinchStartTranslateY = 0;
|
|
let exifAbortController = null;
|
|
let isGalleryLoading = false;
|
|
let autoPlayTimer = null;
|
|
let isAutoPlay = false;
|
|
let navHideTimer = null;
|
|
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;
|
|
const previewRefreshInFlight = new Set();
|
|
|
|
function encodeGalleryPath(relUrl) {
|
|
if (!relUrl) {
|
|
return '';
|
|
}
|
|
return relUrl
|
|
.split('/')
|
|
.map(segment => encodeURIComponent(segment))
|
|
.join('/');
|
|
}
|
|
|
|
function findBrowserThumbnailByRelUrl(relUrl) {
|
|
if (!relUrl) {
|
|
return null;
|
|
}
|
|
const links = document.querySelectorAll('.image-link[data-url]');
|
|
for (const link of links) {
|
|
if ((link.dataset.url || '') === relUrl) {
|
|
return link.querySelector('img.thumbnail');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isFallbackThumbnail(imgEl) {
|
|
if (!imgEl) {
|
|
return false;
|
|
}
|
|
const src = imgEl.getAttribute('src') || '';
|
|
return imgEl.classList.contains('thumbnail-fallback') || src.startsWith('data:image/');
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function refreshBrowserThumbnailIfMissing(relUrl) {
|
|
if (!relUrl || previewRefreshInFlight.has(relUrl)) {
|
|
return;
|
|
}
|
|
|
|
const thumbImg = findBrowserThumbnailByRelUrl(relUrl);
|
|
if (!thumbImg || !isFallbackThumbnail(thumbImg)) {
|
|
return;
|
|
}
|
|
|
|
const baseThumbUrl = thumbImg.dataset.thumbUrl;
|
|
if (!baseThumbUrl) {
|
|
return;
|
|
}
|
|
|
|
previewRefreshInFlight.add(relUrl);
|
|
const separator = baseThumbUrl.includes('?') ? '&' : '?';
|
|
|
|
try {
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
const refreshUrl = `${baseThumbUrl}${separator}_=${Date.now()}-${attempt}`;
|
|
const response = await fetch(refreshUrl, { method: 'GET', cache: 'no-store' }).catch(() => null);
|
|
if (response && response.ok && response.status !== 204) {
|
|
thumbImg.classList.remove('thumbnail-fallback');
|
|
if (typeof handleThumbnailLoad === 'function') {
|
|
thumbImg.onload = () => handleThumbnailLoad(thumbImg);
|
|
}
|
|
if (typeof handleThumbnailError === 'function') {
|
|
thumbImg.onerror = () => handleThumbnailError(thumbImg);
|
|
}
|
|
thumbImg.src = refreshUrl;
|
|
return;
|
|
}
|
|
if (attempt === 0) {
|
|
await delay(250);
|
|
}
|
|
}
|
|
} finally {
|
|
previewRefreshInFlight.delete(relUrl);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
const filenameTextEl = filenameEl.querySelector('.filename-text');
|
|
|
|
if (!relUrl) {
|
|
if (filenameTextEl) {
|
|
filenameTextEl.textContent = '';
|
|
} else {
|
|
filenameEl.textContent = '';
|
|
}
|
|
filenameEl.dataset.filename = '';
|
|
return;
|
|
}
|
|
|
|
const parts = relUrl.split('/');
|
|
const filename = parts[parts.length - 1] || relUrl;
|
|
if (filenameTextEl) {
|
|
filenameTextEl.textContent = filename;
|
|
} else {
|
|
filenameEl.textContent = filename;
|
|
}
|
|
filenameEl.dataset.filename = filename;
|
|
}
|
|
|
|
function updateFilenameProgress() {
|
|
const filenameEl = document.getElementById('gallery-filename');
|
|
if (!filenameEl) {
|
|
return;
|
|
}
|
|
|
|
const total = currentGalleryImages.length;
|
|
const hasValidIndex = currentGalleryIndex >= 0 && currentGalleryIndex < total;
|
|
const progress = total > 0 && hasValidIndex
|
|
? ((currentGalleryIndex + 1) / total) * 100
|
|
: 0;
|
|
filenameEl.style.setProperty('--filename-progress', `${progress}%`);
|
|
}
|
|
|
|
function updateGalleryPosition() {
|
|
const positionEl = document.getElementById('gallery-position');
|
|
if (!positionEl) {
|
|
return;
|
|
}
|
|
|
|
const total = currentGalleryImages.length;
|
|
if (total <= 0 || currentGalleryIndex < 0 || currentGalleryIndex >= total) {
|
|
positionEl.textContent = '';
|
|
return;
|
|
}
|
|
positionEl.textContent = `${currentGalleryIndex + 1} / ${total}`;
|
|
}
|
|
|
|
function updateDownloadLink(relUrl) {
|
|
const downloadBtn = document.getElementById('gallery-download');
|
|
if (!downloadBtn) {
|
|
return;
|
|
}
|
|
if (!relUrl) {
|
|
downloadBtn.removeAttribute('data-relurl');
|
|
return;
|
|
}
|
|
// Store relUrl for the click handler (which fetches a short-lived token)
|
|
downloadBtn.dataset.relurl = relUrl;
|
|
}
|
|
|
|
function updateNextButtonState() {
|
|
const nextButton = document.querySelector('.gallery-next');
|
|
if (!nextButton) {
|
|
return;
|
|
}
|
|
|
|
const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1;
|
|
nextButton.textContent = atLastImage ? '\u00D7' : '\u203A';
|
|
nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image');
|
|
nextButton.classList.toggle('is-close', atLastImage);
|
|
}
|
|
|
|
function showGalleryNav() {
|
|
clearTimeout(navHideTimer);
|
|
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
|
|
if (currentScale > 1.01) {
|
|
return;
|
|
}
|
|
navHideTimer = setTimeout(() => {
|
|
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
|
|
}, 1000);
|
|
}
|
|
|
|
function hideGalleryNav() {
|
|
clearTimeout(navHideTimer);
|
|
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function getPanBounds(image, scale) {
|
|
const stage = document.getElementById('gallery-stage');
|
|
if (!stage || !image) {
|
|
return { maxX: 0, maxY: 0 };
|
|
}
|
|
const baseWidth = image.clientWidth || 0;
|
|
const baseHeight = image.clientHeight || 0;
|
|
const scaledWidth = baseWidth * scale;
|
|
const scaledHeight = baseHeight * scale;
|
|
|
|
return {
|
|
maxX: Math.max(0, (scaledWidth - stage.clientWidth) / 2),
|
|
maxY: Math.max(0, (scaledHeight - stage.clientHeight) / 2)
|
|
};
|
|
}
|
|
|
|
function applyImageTransform(image) {
|
|
if (!image) {
|
|
return;
|
|
}
|
|
const { maxX, maxY } = getPanBounds(image, currentScale);
|
|
currentTranslateX = clamp(currentTranslateX, -maxX, maxX);
|
|
currentTranslateY = clamp(currentTranslateY, -maxY, maxY);
|
|
image.style.transform = `translate(-50%, -50%) translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${currentScale})`;
|
|
}
|
|
|
|
function resetImageTransformState(image = null) {
|
|
isPinching = false;
|
|
isPanning = false;
|
|
pinchZoomOccurred = false;
|
|
initialPinchDistance = null;
|
|
initialScale = 1;
|
|
currentScale = 1;
|
|
currentTranslateX = 0;
|
|
currentTranslateY = 0;
|
|
panStartX = 0;
|
|
panStartY = 0;
|
|
panOriginX = 0;
|
|
panOriginY = 0;
|
|
pinchStartMidX = 0;
|
|
pinchStartMidY = 0;
|
|
pinchStartTranslateX = 0;
|
|
pinchStartTranslateY = 0;
|
|
if (image) {
|
|
applyImageTransform(image);
|
|
}
|
|
}
|
|
|
|
function openGalleryModal(relUrl) {
|
|
const modal = document.getElementById('gallery-modal');
|
|
if (!modal) {
|
|
return;
|
|
}
|
|
document.body.style.overflow = 'hidden'; // Disable background scrolling.
|
|
currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
|
|
if (currentGalleryIndex < 0) {
|
|
currentGalleryIndex = 0;
|
|
}
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
showGalleryImage(relUrl);
|
|
modal.style.display = 'flex';
|
|
showGalleryNav();
|
|
}
|
|
|
|
function showGalleryImage(relUrl) {
|
|
if (isGalleryLoading) {
|
|
return;
|
|
}
|
|
isGalleryLoading = true;
|
|
clearAutoPlayTimer();
|
|
updateNextButtonState();
|
|
updateGalleryPosition();
|
|
updateFilenameProgress();
|
|
loadExifData(relUrl);
|
|
const fullUrl = '/media/' + relUrl;
|
|
const stage = document.getElementById('gallery-stage');
|
|
const loader = document.getElementById('loader-container');
|
|
if (!stage || !loader) {
|
|
isGalleryLoading = false;
|
|
return;
|
|
}
|
|
updateDownloadLink(relUrl);
|
|
// 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);
|
|
refreshBrowserThumbnailIfMissing(relUrl);
|
|
|
|
// Select all current images in the gallery stage
|
|
const existingImages = stage.querySelectorAll('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.transition = 'opacity 0.5s ease-in-out';
|
|
newImage.style.opacity = 0; // Start fully transparent
|
|
newImage.id = 'gallery-modal-content';
|
|
newImage.alt = '';
|
|
|
|
// 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('touchcancel', handlePinchEnd, { passive: false });
|
|
|
|
|
|
// Reset pinch variables for the new image
|
|
resetImageTransformState(newImage);
|
|
|
|
// Append new image on top of existing ones
|
|
stage.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 nextRelUrl = currentGalleryImages[currentGalleryIndex + 1];
|
|
const nextImage = new Image();
|
|
nextImage.onload = function () {
|
|
refreshBrowserThumbnailIfMissing(nextRelUrl);
|
|
};
|
|
nextImage.src = '/media/' + nextRelUrl;
|
|
}
|
|
}
|
|
|
|
function closeGalleryModal() {
|
|
clearTimeout(navHideTimer);
|
|
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
|
|
const modal = document.getElementById('gallery-modal');
|
|
const stage = document.getElementById('gallery-stage');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
}
|
|
isGalleryLoading = false;
|
|
setAutoPlay(false);
|
|
updateDownloadLink(null);
|
|
// remove all images
|
|
if (stage) {
|
|
const existingImages = stage.querySelectorAll('img');
|
|
existingImages.forEach(img => {
|
|
if (img.parentNode) {
|
|
img.parentNode.removeChild(img);
|
|
}
|
|
});
|
|
}
|
|
const filenameEl = document.getElementById('gallery-filename');
|
|
if (filenameEl) {
|
|
const filenameTextEl = filenameEl.querySelector('.filename-text');
|
|
if (filenameTextEl) {
|
|
filenameTextEl.textContent = '';
|
|
} else {
|
|
filenameEl.textContent = '';
|
|
}
|
|
filenameEl.dataset.filename = '';
|
|
filenameEl.style.setProperty('--filename-progress', '0%');
|
|
}
|
|
const positionEl = document.getElementById('gallery-position');
|
|
if (positionEl) {
|
|
positionEl.textContent = '';
|
|
}
|
|
resetExifPanel(true);
|
|
resetImageTransformState();
|
|
// Restore scrolling.
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
function navigateByScreenHalf(clientX) {
|
|
if (isGalleryLoading) {
|
|
return;
|
|
}
|
|
|
|
const midpoint = window.innerWidth / 2;
|
|
if (clientX < midpoint) {
|
|
if (currentGalleryIndex > 0) {
|
|
currentGalleryIndex--;
|
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
|
currentGalleryIndex++;
|
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
|
} else {
|
|
closeGalleryModal();
|
|
}
|
|
}
|
|
|
|
function handleGalleryScreenClick(e) {
|
|
// If a pinch zoom occurred, don't change the image.
|
|
if (pinchZoomOccurred) {
|
|
pinchZoomOccurred = false;
|
|
return;
|
|
}
|
|
if (currentScale > 1.01 || isPinching || isPanning) {
|
|
return;
|
|
}
|
|
|
|
const target = e.target;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
// Do not interpret clicks on controls/panels as navigation.
|
|
if (target.closest('.gallery-control') || target.closest('.gallery-nav') || target.closest('#gallery-exif')) {
|
|
return;
|
|
}
|
|
|
|
navigateByScreenHalf(e.clientX);
|
|
}
|
|
|
|
|
|
|
|
|
|
function initGallerySwipe() {
|
|
let touchStartX = 0;
|
|
let touchEndX = 0;
|
|
const galleryModal = document.getElementById('gallery-modal');
|
|
if (!galleryModal) {
|
|
return;
|
|
}
|
|
|
|
galleryModal.addEventListener('touchstart', function(e) {
|
|
// If more than one finger is on screen, ignore swipe tracking.
|
|
if (e.touches.length > 1) {
|
|
return;
|
|
}
|
|
if (currentScale > 1.01 || isPinching || isPanning) {
|
|
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 || isPanning || pinchZoomOccurred || currentScale > 1.01) {
|
|
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) {
|
|
clearTimeout(autoPlayTimer);
|
|
autoPlayTimer = null;
|
|
}
|
|
}
|
|
|
|
function setAutoPlay(active) {
|
|
const playButton = document.getElementById('gallery-play');
|
|
isAutoPlay = active;
|
|
if (playButton) {
|
|
playButton.textContent = active ? '\u2225' : '\u25B6';
|
|
playButton.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
playButton.setAttribute('aria-label', active ? 'Pause slideshow' : 'Start slideshow');
|
|
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;
|
|
isPanning = false;
|
|
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);
|
|
initialScale = currentScale;
|
|
pinchStartMidX = (e.touches[0].pageX + e.touches[1].pageX) / 2;
|
|
pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
|
|
pinchStartTranslateX = currentTranslateX;
|
|
pinchStartTranslateY = currentTranslateY;
|
|
return;
|
|
}
|
|
|
|
if (e.touches.length === 1 && currentScale > 1.01) {
|
|
e.preventDefault();
|
|
isPanning = true;
|
|
panStartX = e.touches[0].pageX;
|
|
panStartY = e.touches[0].pageY;
|
|
panOriginX = currentTranslateX;
|
|
panOriginY = currentTranslateY;
|
|
showGalleryNav();
|
|
}
|
|
}
|
|
|
|
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(1, Math.min(scale * initialScale, 4));
|
|
// If the scale has changed enough, mark that a pinch zoom occurred.
|
|
if (Math.abs(scale - currentScale) > 0.01) {
|
|
pinchZoomOccurred = true;
|
|
}
|
|
currentScale = scale;
|
|
const midX = (e.touches[0].pageX + e.touches[1].pageX) / 2;
|
|
const midY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
|
|
currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX);
|
|
currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY);
|
|
applyImageTransform(e.currentTarget);
|
|
if (currentScale > 1.01) {
|
|
showGalleryNav();
|
|
} else {
|
|
hideGalleryNav();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.touches.length === 1 && isPanning && currentScale > 1.01) {
|
|
e.preventDefault();
|
|
const deltaX = e.touches[0].pageX - panStartX;
|
|
const deltaY = e.touches[0].pageY - panStartY;
|
|
currentTranslateX = panOriginX + deltaX;
|
|
currentTranslateY = panOriginY + deltaY;
|
|
pinchZoomOccurred = true;
|
|
applyImageTransform(e.currentTarget);
|
|
showGalleryNav();
|
|
}
|
|
}
|
|
|
|
function handlePinchEnd(e) {
|
|
if (e.touches.length === 1 && currentScale > 1.01) {
|
|
// Transition from pinch to one-finger pan.
|
|
isPinching = false;
|
|
isPanning = true;
|
|
initialPinchDistance = null;
|
|
panStartX = e.touches[0].pageX;
|
|
panStartY = e.touches[0].pageY;
|
|
panOriginX = currentTranslateX;
|
|
panOriginY = currentTranslateY;
|
|
showGalleryNav();
|
|
return;
|
|
}
|
|
|
|
if (e.touches.length < 1) {
|
|
isPinching = false;
|
|
isPanning = false;
|
|
initialPinchDistance = null;
|
|
initialScale = currentScale;
|
|
const image = e.currentTarget;
|
|
if (currentScale <= 1.01) {
|
|
currentScale = 1;
|
|
currentTranslateX = 0;
|
|
currentTranslateY = 0;
|
|
applyImageTransform(image);
|
|
hideGalleryNav();
|
|
return;
|
|
}
|
|
applyImageTransform(image);
|
|
showGalleryNav();
|
|
}
|
|
}
|
|
|
|
function handleGalleryKeydown(e) {
|
|
const modal = document.getElementById('gallery-modal');
|
|
if (!modal || modal.style.display !== 'flex') {
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
closeGalleryModal();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
|
currentGalleryIndex--;
|
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
|
currentGalleryIndex++;
|
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
|
} else if (!isGalleryLoading) {
|
|
closeGalleryModal();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
setAutoPlay(!isAutoPlay);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
const downloadButton = document.getElementById('gallery-download');
|
|
if (downloadButton) {
|
|
downloadButton.addEventListener('click', async function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const relUrl = downloadButton.dataset.relurl;
|
|
if (!relUrl) return;
|
|
|
|
const encodedSubpath = relUrl
|
|
.replace(/^\/+/, '')
|
|
.split('/')
|
|
.map(segment => encodeURIComponent(decodeURIComponent(segment)))
|
|
.join('/');
|
|
|
|
// Fetch a short-lived download token (works in Telegram's internal browser)
|
|
let tokenizedUrl;
|
|
try {
|
|
const resp = await fetch(`/create_dltoken/${encodedSubpath}`);
|
|
if (!resp.ok) return;
|
|
tokenizedUrl = (await resp.text()).trim();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (!tokenizedUrl) return;
|
|
|
|
// Append download=true so the server returns the full-size original as attachment
|
|
const sep = tokenizedUrl.includes('?') ? '&' : '?';
|
|
const downloadUrl = new URL(tokenizedUrl + sep + 'download=true', window.location.href);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl.toString();
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
});
|
|
}
|
|
|
|
if (infoButton) {
|
|
infoButton.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
toggleExifPanel();
|
|
});
|
|
}
|
|
|
|
if (playButton) {
|
|
playButton.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
setAutoPlay(!isAutoPlay);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
function initGallery() {
|
|
const galleryModal = document.getElementById('gallery-modal');
|
|
if (!galleryModal || galleryModal.dataset.galleryBound) return;
|
|
|
|
initGallerySwipe();
|
|
initGalleryControls();
|
|
|
|
galleryModal.addEventListener('click', handleGalleryScreenClick);
|
|
document.addEventListener('keydown', handleGalleryKeydown);
|
|
galleryModal.dataset.galleryBound = '1';
|
|
}
|
|
|
|
window.initGallery = initGallery;
|