improve gallery
This commit is contained in:
parent
ece99f8e48
commit
56f60f261a
84
app.py
84
app.py
@ -8,7 +8,7 @@ if FLASK_ENV == 'production':
|
||||
eventlet.monkey_patch()
|
||||
|
||||
from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, make_response, abort
|
||||
from PIL import Image, ImageOps
|
||||
from PIL import Image, ImageOps, ExifTags
|
||||
import io
|
||||
from functools import wraps
|
||||
import mimetypes
|
||||
@ -544,8 +544,9 @@ def list_directory_contents(directory, subpath):
|
||||
|
||||
blocked_filenames = [
|
||||
'Thumbs.db',
|
||||
'*.mrk',
|
||||
'*.arw', '*.cr2', '*.lrf'
|
||||
'*.mrk', '*.md',
|
||||
'*.arw', '*.cr2', '*.lrf',
|
||||
'*.mov', '*.mp4', '*.avi', '*.mkv'
|
||||
]
|
||||
|
||||
|
||||
@ -884,6 +885,14 @@ def serve_file(subpath):
|
||||
with Image.open(full_path) as orig:
|
||||
|
||||
img = ImageOps.exif_transpose(orig)
|
||||
exif_bytes = None
|
||||
try:
|
||||
exif = img.getexif()
|
||||
if exif:
|
||||
exif.pop(0x0112, None) # Drop orientation tag since we already rotated the image
|
||||
exif_bytes = exif.tobytes()
|
||||
except Exception:
|
||||
exif_bytes = None
|
||||
variants = {
|
||||
orig_key: (1920, 1920),
|
||||
small_key: ( 480, 480),
|
||||
@ -894,8 +903,10 @@ def serve_file(subpath):
|
||||
if thumb.mode in ("RGBA", "P"):
|
||||
thumb = thumb.convert("RGB")
|
||||
bio = io.BytesIO()
|
||||
|
||||
thumb.save(bio, format='JPEG', quality=85)
|
||||
save_kwargs = {'format': 'JPEG', 'quality': 85}
|
||||
if key == orig_key and exif_bytes:
|
||||
save_kwargs['exif'] = exif_bytes
|
||||
thumb.save(bio, **save_kwargs)
|
||||
bio.seek(0)
|
||||
cache.set(key, bio, read=True)
|
||||
# Read back the variant we need
|
||||
@ -1086,6 +1097,69 @@ def serve_file(subpath):
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/media_exif/<path:subpath>")
|
||||
@auth.require_secret
|
||||
def media_exif(subpath):
|
||||
root, *relative_parts = subpath.split('/')
|
||||
base_path = session.get('folders', {}).get(root)
|
||||
if not base_path:
|
||||
return jsonify({'error': 'Directory not found'}), 404
|
||||
|
||||
full_path = os.path.join(base_path, *relative_parts)
|
||||
try:
|
||||
full_path = check_path(full_path)
|
||||
except (ValueError, PermissionError) as e:
|
||||
return jsonify({'error': str(e)}), 403
|
||||
|
||||
if not os.path.isfile(full_path):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
mime, _ = mimetypes.guess_type(full_path)
|
||||
if not mime or not mime.startswith('image/'):
|
||||
return jsonify({'error': 'Not an image'}), 400
|
||||
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
exif_data = img.getexif()
|
||||
if not exif_data:
|
||||
return jsonify({'exif': {}})
|
||||
|
||||
readable = {}
|
||||
|
||||
ignore_tags = {'Orientation', 'ExifOffset', 'MakerNote', 'PrintImageMatching'}
|
||||
|
||||
def add_items(exif_dict):
|
||||
for tag_id, value in exif_dict.items():
|
||||
tag = ExifTags.TAGS.get(tag_id, str(tag_id))
|
||||
if tag in ignore_tags:
|
||||
continue
|
||||
try:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
readable[tag] = str(value)
|
||||
|
||||
add_items(exif_data)
|
||||
|
||||
# Include Exif and GPS sub-IFDs when available to surface exposure/ISO/lens details.
|
||||
try:
|
||||
if hasattr(ExifTags, 'IFD'):
|
||||
for ifd in (ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo):
|
||||
try:
|
||||
sub_ifd = exif_data.get_ifd(ifd)
|
||||
add_items(sub_ifd)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
app.logger.warning(f"EXIF read failed for {subpath}: {e}")
|
||||
return jsonify({'exif': {}})
|
||||
|
||||
return jsonify({'exif': readable})
|
||||
|
||||
|
||||
|
||||
@app.route("/transcript/<path:subpath>")
|
||||
@auth.require_secret
|
||||
|
||||
@ -315,6 +315,11 @@ footer {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-fallback {
|
||||
object-fit: contain;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
/* pop when loaded */
|
||||
.image-item.loaded .thumbnail {
|
||||
transform: scale(1.05);
|
||||
|
||||
@ -73,6 +73,41 @@ function encodeSubpath(subpath) {
|
||||
// Global variable for gallery images (updated from current folder)
|
||||
let currentGalleryImages = [];
|
||||
|
||||
// Fallback thumbnail (camera icon) when thumbnail generation is unavailable
|
||||
const thumbnailFallbackData = 'data:image/svg+xml;utf8,' + encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200">
|
||||
<rect width="100%" height="100%" fill="#e5e5e5"/>
|
||||
<g fill="#333" stroke="#333" stroke-width="4">
|
||||
<rect x="100" y="60" width="120" height="80" rx="10" ry="10" fill="none"/>
|
||||
<rect x="125" y="40" width="30" height="20" rx="4" ry="4" fill="#333" stroke="none"/>
|
||||
<circle cx="160" cy="100" r="24" fill="none"/>
|
||||
<circle cx="160" cy="100" r="10" fill="#333" stroke="none"/>
|
||||
<circle cx="205" cy="75" r="5" fill="#333" stroke="none"/>
|
||||
</g>
|
||||
</svg>`
|
||||
);
|
||||
|
||||
function handleThumbnailError(imgEl) {
|
||||
imgEl.onerror = null; // Prevent infinite loop
|
||||
imgEl.src = thumbnailFallbackData;
|
||||
imgEl.classList.add('thumbnail-fallback');
|
||||
const item = imgEl.closest('.image-item');
|
||||
if (item) {
|
||||
item.classList.add('loaded');
|
||||
}
|
||||
}
|
||||
|
||||
function handleThumbnailLoad(imgEl) {
|
||||
// Some servers return 204 for missing thumbs, which triggers load with zero dimensions
|
||||
if (!imgEl.naturalWidth || !imgEl.naturalHeight) {
|
||||
handleThumbnailError(imgEl);
|
||||
return;
|
||||
}
|
||||
const item = imgEl.closest('.image-item');
|
||||
if (item) {
|
||||
item.classList.add('loaded');
|
||||
}
|
||||
}
|
||||
|
||||
function paintFile() {
|
||||
// Highlight the currently playing file
|
||||
@ -125,7 +160,8 @@ function renderContent(data) {
|
||||
data-thumb-url="/media/${thumbUrl}"
|
||||
data-full-url="/media/${file.path}"
|
||||
class="thumbnail"
|
||||
onload="this.closest('.image-item').classList.add('loaded')"
|
||||
onload="handleThumbnailLoad(this)"
|
||||
onerror="handleThumbnailError(this)"
|
||||
/>
|
||||
</a>
|
||||
<span class="file-name">${file.name}</span>
|
||||
@ -218,7 +254,8 @@ function renderContent(data) {
|
||||
data-thumb-url="/media/${thumbUrl}"
|
||||
data-full-url="/media/${file.path}"
|
||||
class="thumbnail"
|
||||
onload="this.closest('.image-item').classList.add('loaded')"
|
||||
onload="handleThumbnailLoad(this)"
|
||||
onerror="handleThumbnailError(this)"
|
||||
/>
|
||||
</a>
|
||||
<span class="file-name">${file.name}</span>
|
||||
|
||||
@ -8,6 +8,12 @@
|
||||
outline: none; /* Disable outline on click/focus */
|
||||
}
|
||||
|
||||
@property --play-progress {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Gallery Modal Styles */
|
||||
#gallery-modal {
|
||||
display: none; /* Hidden by default */
|
||||
@ -58,6 +64,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999; /* Ensure it's on top */
|
||||
pointer-events: none; /* Allow interacting with close button while loader shows */
|
||||
}
|
||||
|
||||
#gallery-loader {
|
||||
@ -90,3 +97,138 @@
|
||||
|
||||
.gallery-prev { left: 20px; }
|
||||
.gallery-next { right: 20px; }
|
||||
|
||||
#gallery-info {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 120;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#gallery-info.loading {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
#gallery-exif {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 20px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
color: #fff;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
max-width: 320px;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
#gallery-exif.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.exif-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.exif-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.exif-key {
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.exif-value {
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.exif-placeholder {
|
||||
font-style: italic;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
#gallery-filename {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#gallery-play {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 120;
|
||||
font-size: 1.2rem;
|
||||
--autoplay-duration: 5s;
|
||||
--play-progress: 0deg;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#gallery-play.playing::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(
|
||||
rgba(255, 255, 255, 0.9) 0deg,
|
||||
rgba(255, 255, 255, 0.9) var(--play-progress, 0deg),
|
||||
rgba(255, 255, 255, 0.2) var(--play-progress, 0deg),
|
||||
rgba(255, 255, 255, 0.2) 360deg
|
||||
);
|
||||
mask: radial-gradient(circle at center, transparent 60%, #000 70%);
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#gallery-play.playing.playing-anim::after {
|
||||
animation: gallery-play-fill var(--autoplay-duration) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes gallery-play-fill {
|
||||
from { --play-progress: 0deg; }
|
||||
to { --play-progress: 360deg; }
|
||||
}
|
||||
|
||||
@ -3,6 +3,12 @@ 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.
|
||||
@ -10,6 +16,190 @@ 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);
|
||||
@ -18,6 +208,13 @@ function openGalleryModal(relUrl) {
|
||||
}
|
||||
|
||||
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');
|
||||
@ -36,6 +233,7 @@ function showGalleryImage(relUrl) {
|
||||
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');
|
||||
@ -84,6 +282,15 @@ function showGalleryImage(relUrl) {
|
||||
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
|
||||
@ -94,6 +301,11 @@ function showGalleryImage(relUrl) {
|
||||
tempImg.onerror = function() {
|
||||
clearTimeout(loaderTimeout);
|
||||
loader.style.display = 'none';
|
||||
isGalleryLoading = false;
|
||||
pendingAutoAdvance = false;
|
||||
if (isAutoPlay) {
|
||||
scheduleAutoPlayCycle();
|
||||
}
|
||||
console.error('Error loading image:', fullUrl);
|
||||
};
|
||||
}
|
||||
@ -107,6 +319,8 @@ function preloadNextImage() {
|
||||
|
||||
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 => {
|
||||
@ -114,6 +328,11 @@ function closeGalleryModal() {
|
||||
img.parentNode.removeChild(img);
|
||||
}
|
||||
});
|
||||
const filenameEl = document.getElementById('gallery-filename');
|
||||
if (filenameEl) {
|
||||
filenameEl.textContent = '';
|
||||
}
|
||||
resetExifPanel(true);
|
||||
// Restore scrolling.
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
@ -132,12 +351,12 @@ function handleGalleryImageClick(e) {
|
||||
|
||||
const imgRect = this.getBoundingClientRect();
|
||||
if (e.clientX < imgRect.left + imgRect.width / 2) {
|
||||
if (currentGalleryIndex > 0) {
|
||||
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||||
currentGalleryIndex--;
|
||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||
}
|
||||
} else {
|
||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||
currentGalleryIndex++;
|
||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||
}
|
||||
@ -173,12 +392,12 @@ function initGallerySwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
if (Math.abs(deltaX) > 50) { // Swipe threshold.
|
||||
if (deltaX > 0) { // Swipe right: previous image.
|
||||
if (currentGalleryIndex > 0) {
|
||||
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||||
currentGalleryIndex--;
|
||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||
}
|
||||
} else { // Swipe left: next image.
|
||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||
currentGalleryIndex++;
|
||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||
}
|
||||
@ -187,6 +406,104 @@ function initGallerySwipe() {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@ -229,18 +546,28 @@ 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]);
|
||||
@ -251,6 +578,20 @@ function initGalleryControls() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -244,9 +244,13 @@
|
||||
<!-- Gallery Modal for Images -->
|
||||
<div id="gallery-modal" style="display: none;">
|
||||
<button id="gallery-close">x</button>
|
||||
<button id="gallery-info" aria-expanded="false" aria-controls="gallery-exif">i</button>
|
||||
<img id="gallery-modal-content" src="" />
|
||||
<button class="gallery-nav gallery-prev">‹</button>
|
||||
<button class="gallery-nav gallery-next">›</button>
|
||||
<button id="gallery-play" aria-pressed="false">▶</button>
|
||||
<div id="gallery-exif" aria-live="polite"></div>
|
||||
<div id="gallery-filename" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div id="loader-container" style="display: none;">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user