diff --git a/app.py b/app.py index c7837b6..aee4bf0 100755 --- a/app.py +++ b/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/") +@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/") @auth.require_secret diff --git a/static/app.css b/static/app.css index 0e865f1..3178388 100644 --- a/static/app.css +++ b/static/app.css @@ -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); diff --git a/static/app.js b/static/app.js index a7450c3..96f681f 100644 --- a/static/app.js +++ b/static/app.js @@ -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( + ` + + + + + + + + + ` +); + +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)" /> ${file.name} @@ -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)" /> ${file.name} diff --git a/static/gallery.css b/static/gallery.css index 5750671..c27f0f5 100644 --- a/static/gallery.css +++ b/static/gallery.css @@ -8,6 +8,12 @@ outline: none; /* Disable outline on click/focus */ } +@property --play-progress { + syntax: ''; + 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 { @@ -89,4 +96,139 @@ } .gallery-prev { left: 20px; } - .gallery-next { right: 20px; } \ No newline at end of file + .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; } + } diff --git a/static/gallery.js b/static/gallery.js index 9315abd..e37e292 100644 --- a/static/gallery.js +++ b/static/gallery.js @@ -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 = '
No EXIF data
'; + 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 = '
No EXIF data
'; + return; + } + + exifPanel.innerHTML = rows.map(([key, value]) => { + const safeKey = escapeHtml(key); + const safeValue = escapeHtml(Array.isArray(value) ? value.join(', ') : value); + return `
${safeKey}${safeValue}
`; + }).join(''); +} + +function showExifPlaceholder(message) { + const exifPanel = document.getElementById('gallery-exif'); + if (!exifPanel) { + return; + } + exifPanel.innerHTML = `
${message}
`; +} + +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); + }); + } } diff --git a/templates/app.html b/templates/app.html index 8729289..edc037c 100644 --- a/templates/app.html +++ b/templates/app.html @@ -244,9 +244,13 @@