Compare commits
5 Commits
d2952c3ac0
...
643f9b3908
| Author | SHA1 | Date | |
|---|---|---|---|
| 643f9b3908 | |||
| 566138dedc | |||
| 56f60f261a | |||
| ece99f8e48 | |||
| a0ec67f22a |
87
app.py
87
app.py
@ -8,7 +8,7 @@ if FLASK_ENV == 'production':
|
|||||||
eventlet.monkey_patch()
|
eventlet.monkey_patch()
|
||||||
|
|
||||||
from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, make_response, abort
|
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
|
import io
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@ -542,7 +542,13 @@ def list_directory_contents(directory, subpath):
|
|||||||
music_exts = ('.mp3',)
|
music_exts = ('.mp3',)
|
||||||
image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp')
|
image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp')
|
||||||
|
|
||||||
blocked_filenames = ['Thumbs.db', '*.mrk']
|
blocked_filenames = [
|
||||||
|
'Thumbs.db',
|
||||||
|
'*.mrk', '*.md',
|
||||||
|
'*.arw', '*.cr2', '*.lrf',
|
||||||
|
'*.mov', '*.mp4', '*.avi', '*.mkv'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with os.scandir(directory) as it:
|
with os.scandir(directory) as it:
|
||||||
@ -879,6 +885,14 @@ def serve_file(subpath):
|
|||||||
with Image.open(full_path) as orig:
|
with Image.open(full_path) as orig:
|
||||||
|
|
||||||
img = ImageOps.exif_transpose(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 = {
|
variants = {
|
||||||
orig_key: (1920, 1920),
|
orig_key: (1920, 1920),
|
||||||
small_key: ( 480, 480),
|
small_key: ( 480, 480),
|
||||||
@ -889,8 +903,10 @@ def serve_file(subpath):
|
|||||||
if thumb.mode in ("RGBA", "P"):
|
if thumb.mode in ("RGBA", "P"):
|
||||||
thumb = thumb.convert("RGB")
|
thumb = thumb.convert("RGB")
|
||||||
bio = io.BytesIO()
|
bio = io.BytesIO()
|
||||||
|
save_kwargs = {'format': 'JPEG', 'quality': 85}
|
||||||
thumb.save(bio, format='JPEG', quality=85)
|
if key == orig_key and exif_bytes:
|
||||||
|
save_kwargs['exif'] = exif_bytes
|
||||||
|
thumb.save(bio, **save_kwargs)
|
||||||
bio.seek(0)
|
bio.seek(0)
|
||||||
cache.set(key, bio, read=True)
|
cache.set(key, bio, read=True)
|
||||||
# Read back the variant we need
|
# Read back the variant we need
|
||||||
@ -1081,6 +1097,69 @@ def serve_file(subpath):
|
|||||||
return response
|
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>")
|
@app.route("/transcript/<path:subpath>")
|
||||||
@auth.require_secret
|
@auth.require_secret
|
||||||
|
|||||||
27
auth.py
27
auth.py
@ -171,16 +171,23 @@ def require_secret(f):
|
|||||||
if 'device_id' not in session:
|
if 'device_id' not in session:
|
||||||
session['device_id'] = os.urandom(32).hex()
|
session['device_id'] = os.urandom(32).hex()
|
||||||
|
|
||||||
# AUTO-JUMP FOR TOKENS - Disabled for now to debug
|
# For token links, immediately open the single folder they grant access to.
|
||||||
# try:
|
if (
|
||||||
# if args_token and is_valid_token(args_token):
|
args_token
|
||||||
# token_item = decode_token(args_token)
|
and request.method == 'GET'
|
||||||
# target_foldername = token_item['folders'][0]['foldername']
|
and request.endpoint == 'index'
|
||||||
# # Mark session as modified to ensure it's saved before redirect
|
and is_valid_token(args_token)
|
||||||
# session.modified = True
|
):
|
||||||
# return redirect(f"/path/{target_foldername}")
|
try:
|
||||||
# except Exception as e:
|
token_item = decode_token(args_token)
|
||||||
# print(f"Error during auto-jump: {e}")
|
folders = token_item.get('folders', [])
|
||||||
|
if len(folders) == 1:
|
||||||
|
target_foldername = folders[0].get('foldername')
|
||||||
|
if target_foldername:
|
||||||
|
session.modified = True # ensure session persists before redirect
|
||||||
|
return redirect(f"/path/{target_foldername}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during token auto-open: {e}")
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -97,6 +97,33 @@ body {
|
|||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-nav .dropdown-toggle {
|
||||||
|
color: #ccc;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .dropdown-toggle::after {
|
||||||
|
border-top-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .dropdown-menu {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .dropdown-item {
|
||||||
|
color: #333;
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .dropdown-item:hover,
|
||||||
|
.admin-nav .dropdown-item:focus {
|
||||||
|
background-color: #e6e6e6;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
/* List Styles */
|
/* List Styles */
|
||||||
ul {
|
ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
@ -157,6 +184,11 @@ a.show-transcript:hover {
|
|||||||
.highlight {
|
.highlight {
|
||||||
background-color: yellow;
|
background-color: yellow;
|
||||||
}
|
}
|
||||||
|
#transcriptContent code.timecode-link {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
a.create-share {
|
a.create-share {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -315,6 +347,11 @@ footer {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnail-fallback {
|
||||||
|
object-fit: contain;
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
/* pop when loaded */
|
/* pop when loaded */
|
||||||
.image-item.loaded .thumbnail {
|
.image-item.loaded .thumbnail {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
|
|||||||
206
static/app.js
206
static/app.js
@ -2,6 +2,45 @@
|
|||||||
let currentMusicFiles = []; // Array of objects with at least { path, index }
|
let currentMusicFiles = []; // Array of objects with at least { path, index }
|
||||||
let currentMusicIndex = -1; // Index of the current music file
|
let currentMusicIndex = -1; // Index of the current music file
|
||||||
let currentTrackPath = "";
|
let currentTrackPath = "";
|
||||||
|
let activeTranscriptAudio = ""; // Audio file that matches the currently open transcript.
|
||||||
|
|
||||||
|
// Check thumb availability in parallel; sequentially generate missing ones to avoid concurrent creation.
|
||||||
|
function ensureFirstFivePreviews() {
|
||||||
|
const images = Array.from(document.querySelectorAll('.images-grid img')).slice(0, 5);
|
||||||
|
if (!images.length) return;
|
||||||
|
|
||||||
|
const checks = images.map(img => {
|
||||||
|
const thumbUrl = img.dataset.thumbUrl || img.src;
|
||||||
|
const fullUrl = img.dataset.fullUrl;
|
||||||
|
return fetch(thumbUrl, { method: 'GET', cache: 'no-store' })
|
||||||
|
.then(res => ({
|
||||||
|
img,
|
||||||
|
thumbUrl,
|
||||||
|
fullUrl,
|
||||||
|
hasThumb: res.ok && res.status !== 204
|
||||||
|
}))
|
||||||
|
.catch(() => ({ img, thumbUrl, fullUrl, hasThumb: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(checks).then(results => {
|
||||||
|
const missing = results.filter(r => !r.hasThumb && r.fullUrl);
|
||||||
|
if (!missing.length) return;
|
||||||
|
generateThumbsSequentially(missing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThumbsSequentially(entries) {
|
||||||
|
for (const { img, thumbUrl, fullUrl } of entries) {
|
||||||
|
try {
|
||||||
|
await fetch(fullUrl, { method: 'GET', cache: 'no-store' });
|
||||||
|
const cacheBust = `_=${Date.now()}`;
|
||||||
|
const separator = thumbUrl.includes('?') ? '&' : '?';
|
||||||
|
img.src = `${thumbUrl}${separator}${cacheBust}`;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore; best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache common DOM elements
|
// Cache common DOM elements
|
||||||
const mainContainer = document.querySelector('main');
|
const mainContainer = document.querySelector('main');
|
||||||
@ -32,9 +71,117 @@ function encodeSubpath(subpath) {
|
|||||||
.join('/');
|
.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a timecode string like "00:17" or "01:02:03" into total seconds.
|
||||||
|
function parseTimecode(text) {
|
||||||
|
const parts = text.trim().split(':').map(part => parseInt(part, 10));
|
||||||
|
if (parts.some(Number.isNaN) || parts.length < 2 || parts.length > 3) return null;
|
||||||
|
|
||||||
|
const hasHours = parts.length === 3;
|
||||||
|
const [hours, minutes, seconds] = hasHours ? parts : [0, parts[0], parts[1]];
|
||||||
|
|
||||||
|
if (seconds < 0 || seconds > 59) return null;
|
||||||
|
if (minutes < 0 || (hasHours && minutes > 59)) return null;
|
||||||
|
if (hours < 0) return null;
|
||||||
|
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handlers to transcript timecodes so they seek the matching audio.
|
||||||
|
function attachTranscriptTimecodes(audioPath) {
|
||||||
|
const container = document.getElementById('transcriptContent');
|
||||||
|
const targetAudio = audioPath || activeTranscriptAudio;
|
||||||
|
if (!container || !targetAudio) return;
|
||||||
|
|
||||||
|
container.querySelectorAll('code').forEach(codeEl => {
|
||||||
|
if (codeEl.closest('pre')) return;
|
||||||
|
const label = codeEl.textContent.trim();
|
||||||
|
const seconds = parseTimecode(label);
|
||||||
|
if (seconds === null) return;
|
||||||
|
|
||||||
|
codeEl.classList.add('timecode-link');
|
||||||
|
codeEl.title = `Zu ${label} springen`;
|
||||||
|
codeEl.addEventListener('click', () => jumpToTimecode(seconds, targetAudio));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we load the right track (if needed) and jump to the requested time.
|
||||||
|
function jumpToTimecode(seconds, relUrl) {
|
||||||
|
if (!relUrl || seconds === null) return;
|
||||||
|
|
||||||
|
const updateTrackState = () => {
|
||||||
|
currentTrackPath = relUrl;
|
||||||
|
const matchIdx = currentMusicFiles.findIndex(file => file.path === relUrl);
|
||||||
|
currentMusicIndex = matchIdx !== -1 ? matchIdx : -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekWhenReady = () => {
|
||||||
|
player.audio.currentTime = Math.min(
|
||||||
|
seconds,
|
||||||
|
Number.isFinite(player.audio.duration) ? player.audio.duration : seconds
|
||||||
|
);
|
||||||
|
player.audio.play();
|
||||||
|
player.updateTimeline();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTrackState();
|
||||||
|
|
||||||
|
if (player.currentRelUrl === relUrl) {
|
||||||
|
if (player.audio.readyState >= 1) {
|
||||||
|
seekWhenReady();
|
||||||
|
} else {
|
||||||
|
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
|
||||||
|
|
||||||
|
const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`);
|
||||||
|
if (trackLink) {
|
||||||
|
trackLink.click();
|
||||||
|
} else {
|
||||||
|
player.loadTrack(relUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Global variable for gallery images (updated from current folder)
|
// Global variable for gallery images (updated from current folder)
|
||||||
let currentGalleryImages = [];
|
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() {
|
function paintFile() {
|
||||||
// Highlight the currently playing file
|
// Highlight the currently playing file
|
||||||
@ -61,17 +208,20 @@ function renderContent(data) {
|
|||||||
});
|
});
|
||||||
document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML;
|
document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML;
|
||||||
|
|
||||||
|
const imageFiles = data.files.filter(file => file.file_type === 'image');
|
||||||
|
const nonImageFiles = data.files.filter(file => file.file_type !== 'image');
|
||||||
|
|
||||||
// Check for image-only directory (no subdirectories, at least one file, all images)
|
// Check for image-only directory (no subdirectories, at least one file, all images)
|
||||||
const isImageOnly = data.directories.length === 0
|
const isImageOnly = data.directories.length === 0
|
||||||
&& data.files.length > 0
|
&& imageFiles.length > 0
|
||||||
&& data.files.every(file => file.file_type === 'image');
|
&& nonImageFiles.length === 0;
|
||||||
|
|
||||||
let contentHTML = '';
|
let contentHTML = '';
|
||||||
|
|
||||||
if (isImageOnly) {
|
if (isImageOnly) {
|
||||||
// Display thumbnails grid
|
// Display thumbnails grid
|
||||||
contentHTML += '<div class="images-grid">';
|
contentHTML += '<div class="images-grid">';
|
||||||
data.files.forEach(file => {
|
imageFiles.forEach(file => {
|
||||||
const thumbUrl = `${file.path}?thumbnail=true`;
|
const thumbUrl = `${file.path}?thumbnail=true`;
|
||||||
contentHTML += `
|
contentHTML += `
|
||||||
<div class="image-item">
|
<div class="image-item">
|
||||||
@ -81,8 +231,11 @@ function renderContent(data) {
|
|||||||
data-file-type="${file.file_type}">
|
data-file-type="${file.file_type}">
|
||||||
<img
|
<img
|
||||||
src="/media/${thumbUrl}"
|
src="/media/${thumbUrl}"
|
||||||
|
data-thumb-url="/media/${thumbUrl}"
|
||||||
|
data-full-url="/media/${file.path}"
|
||||||
class="thumbnail"
|
class="thumbnail"
|
||||||
onload="this.closest('.image-item').classList.add('loaded')"
|
onload="handleThumbnailLoad(this)"
|
||||||
|
onerror="handleThumbnailError(this)"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<span class="file-name">${file.name}</span>
|
<span class="file-name">${file.name}</span>
|
||||||
@ -138,9 +291,9 @@ function renderContent(data) {
|
|||||||
|
|
||||||
// Render files (including music and non-image files)
|
// Render files (including music and non-image files)
|
||||||
currentMusicFiles = [];
|
currentMusicFiles = [];
|
||||||
if (data.files.length > 0) {
|
if (nonImageFiles.length > 0) {
|
||||||
contentHTML += '<ul>';
|
contentHTML += '<ul>';
|
||||||
data.files.forEach((file, idx) => {
|
nonImageFiles.forEach((file, idx) => {
|
||||||
let symbol = '📄';
|
let symbol = '📄';
|
||||||
if (file.file_type === 'music') {
|
if (file.file_type === 'music') {
|
||||||
symbol = '🔊';
|
symbol = '🔊';
|
||||||
@ -152,12 +305,39 @@ function renderContent(data) {
|
|||||||
contentHTML += `<li class="file-item">
|
contentHTML += `<li class="file-item">
|
||||||
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
|
||||||
if (file.has_transcript) {
|
if (file.has_transcript) {
|
||||||
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">📄</a>`;
|
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" data-audio-url="${file.path}" title="Show Transcript">📄</a>`;
|
||||||
}
|
}
|
||||||
contentHTML += `</li>`;
|
contentHTML += `</li>`;
|
||||||
});
|
});
|
||||||
contentHTML += '</ul>';
|
contentHTML += '</ul>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show images in preview grid after the file list
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
contentHTML += '<div class="images-grid">';
|
||||||
|
imageFiles.forEach(file => {
|
||||||
|
const thumbUrl = `${file.path}?thumbnail=true`;
|
||||||
|
contentHTML += `
|
||||||
|
<div class="image-item">
|
||||||
|
|
||||||
|
<a href="#" class="play-file image-link"
|
||||||
|
data-url="${file.path}"
|
||||||
|
data-file-type="${file.file_type}">
|
||||||
|
<img
|
||||||
|
src="/media/${thumbUrl}"
|
||||||
|
data-thumb-url="/media/${thumbUrl}"
|
||||||
|
data-full-url="/media/${file.path}"
|
||||||
|
class="thumbnail"
|
||||||
|
onload="handleThumbnailLoad(this)"
|
||||||
|
onerror="handleThumbnailError(this)"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<span class="file-name">${file.name}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
contentHTML += '</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert generated content
|
// Insert generated content
|
||||||
@ -174,7 +354,7 @@ function renderContent(data) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Attach event listeners for file items (including images)
|
// Attach event listeners for file items (including images)
|
||||||
if (isImageOnly) {
|
if (isImageOnly || imageFiles.length > 0) {
|
||||||
document.querySelectorAll('.image-item').forEach(item => {
|
document.querySelectorAll('.image-item').forEach(item => {
|
||||||
item.addEventListener('click', function(e) {
|
item.addEventListener('click', function(e) {
|
||||||
if (!e.target.closest('a')) {
|
if (!e.target.closest('a')) {
|
||||||
@ -195,9 +375,8 @@ function renderContent(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update gallery images for lightbox or similar
|
// Update gallery images for lightbox or similar
|
||||||
currentGalleryImages = data.files
|
currentGalleryImages = imageFiles.map(f => f.path);
|
||||||
.filter(f => f.file_type === 'image')
|
ensureFirstFivePreviews();
|
||||||
.map(f => f.path);
|
|
||||||
|
|
||||||
attachEventListeners();
|
attachEventListeners();
|
||||||
}
|
}
|
||||||
@ -319,6 +498,7 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
|
|
||||||
// Update the current music index.
|
// Update the current music index.
|
||||||
currentMusicIndex = index !== undefined ? parseInt(index) : -1;
|
currentMusicIndex = index !== undefined ? parseInt(index) : -1;
|
||||||
|
currentTrackPath = relUrl;
|
||||||
|
|
||||||
// Mark the clicked item as currently playing.
|
// Mark the clicked item as currently playing.
|
||||||
this.closest('.file-item').classList.add('currently-playing');
|
this.closest('.file-item').classList.add('currently-playing');
|
||||||
@ -347,7 +527,9 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
link.addEventListener('click', function (event) {
|
link.addEventListener('click', function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = this.getAttribute('data-url');
|
const url = this.getAttribute('data-url');
|
||||||
|
const audioUrl = this.getAttribute('data-audio-url') || '';
|
||||||
const highlight = this.getAttribute('highlight');
|
const highlight = this.getAttribute('highlight');
|
||||||
|
activeTranscriptAudio = audioUrl;
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -374,9 +556,11 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
|
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
|
||||||
|
attachTranscriptTimecodes(audioUrl);
|
||||||
document.getElementById('transcriptModal').style.display = 'block';
|
document.getElementById('transcriptModal').style.display = 'block';
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
activeTranscriptAudio = '';
|
||||||
document.getElementById('transcriptContent').innerHTML = '<p>Error loading transcription.</p>';
|
document.getElementById('transcriptContent').innerHTML = '<p>Error loading transcription.</p>';
|
||||||
document.getElementById('transcriptModal').style.display = 'block';
|
document.getElementById('transcriptModal').style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,12 @@
|
|||||||
outline: none; /* Disable outline on click/focus */
|
outline: none; /* Disable outline on click/focus */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property --play-progress {
|
||||||
|
syntax: '<angle>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
/* Gallery Modal Styles */
|
/* Gallery Modal Styles */
|
||||||
#gallery-modal {
|
#gallery-modal {
|
||||||
display: none; /* Hidden by default */
|
display: none; /* Hidden by default */
|
||||||
@ -58,6 +64,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 9999; /* Ensure it's on top */
|
z-index: 9999; /* Ensure it's on top */
|
||||||
|
pointer-events: none; /* Allow interacting with close button while loader shows */
|
||||||
}
|
}
|
||||||
|
|
||||||
#gallery-loader {
|
#gallery-loader {
|
||||||
@ -90,3 +97,138 @@
|
|||||||
|
|
||||||
.gallery-prev { left: 20px; }
|
.gallery-prev { left: 20px; }
|
||||||
.gallery-next { right: 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 initialPinchDistance = null;
|
||||||
let initialScale = 1;
|
let initialScale = 1;
|
||||||
let currentScale = 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.
|
// Assume that currentGalleryImages is already declared in app.js.
|
||||||
// Only declare currentGalleryIndex if it isn't defined already.
|
// Only declare currentGalleryIndex if it isn't defined already.
|
||||||
@ -10,6 +16,190 @@ if (typeof currentGalleryIndex === "undefined") {
|
|||||||
var currentGalleryIndex = -1;
|
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) {
|
function openGalleryModal(relUrl) {
|
||||||
document.body.style.overflow = 'hidden'; // Disable background scrolling.
|
document.body.style.overflow = 'hidden'; // Disable background scrolling.
|
||||||
currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
|
currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
|
||||||
@ -18,6 +208,13 @@ function openGalleryModal(relUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showGalleryImage(relUrl) {
|
function showGalleryImage(relUrl) {
|
||||||
|
if (isGalleryLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isGalleryLoading = true;
|
||||||
|
clearAutoPlayTimer();
|
||||||
|
updateNextButtonState();
|
||||||
|
loadExifData(relUrl);
|
||||||
const fullUrl = '/media/' + relUrl;
|
const fullUrl = '/media/' + relUrl;
|
||||||
const modal = document.getElementById('gallery-modal');
|
const modal = document.getElementById('gallery-modal');
|
||||||
const currentImage = document.getElementById('gallery-modal-content');
|
const currentImage = document.getElementById('gallery-modal-content');
|
||||||
@ -36,6 +233,7 @@ function showGalleryImage(relUrl) {
|
|||||||
tempImg.onload = function () {
|
tempImg.onload = function () {
|
||||||
clearTimeout(loaderTimeout);
|
clearTimeout(loaderTimeout);
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
|
updateFilenameDisplay(relUrl);
|
||||||
|
|
||||||
// Select all current images in the gallery modal
|
// Select all current images in the gallery modal
|
||||||
const existingImages = document.querySelectorAll('#gallery-modal img');
|
const existingImages = document.querySelectorAll('#gallery-modal img');
|
||||||
@ -84,6 +282,15 @@ function showGalleryImage(relUrl) {
|
|||||||
img.parentNode.removeChild(img); // Remove old images
|
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
|
}, 510); // 500ms for fade-out + small buffer
|
||||||
|
|
||||||
// Preload the next image
|
// Preload the next image
|
||||||
@ -94,6 +301,11 @@ function showGalleryImage(relUrl) {
|
|||||||
tempImg.onerror = function() {
|
tempImg.onerror = function() {
|
||||||
clearTimeout(loaderTimeout);
|
clearTimeout(loaderTimeout);
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
|
isGalleryLoading = false;
|
||||||
|
pendingAutoAdvance = false;
|
||||||
|
if (isAutoPlay) {
|
||||||
|
scheduleAutoPlayCycle();
|
||||||
|
}
|
||||||
console.error('Error loading image:', fullUrl);
|
console.error('Error loading image:', fullUrl);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -107,6 +319,8 @@ function preloadNextImage() {
|
|||||||
|
|
||||||
function closeGalleryModal() {
|
function closeGalleryModal() {
|
||||||
document.getElementById('gallery-modal').style.display = 'none';
|
document.getElementById('gallery-modal').style.display = 'none';
|
||||||
|
isGalleryLoading = false;
|
||||||
|
setAutoPlay(false);
|
||||||
// remove all images
|
// remove all images
|
||||||
const existingImages = document.querySelectorAll('#gallery-modal img');
|
const existingImages = document.querySelectorAll('#gallery-modal img');
|
||||||
existingImages.forEach(img => {
|
existingImages.forEach(img => {
|
||||||
@ -114,6 +328,11 @@ function closeGalleryModal() {
|
|||||||
img.parentNode.removeChild(img);
|
img.parentNode.removeChild(img);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const filenameEl = document.getElementById('gallery-filename');
|
||||||
|
if (filenameEl) {
|
||||||
|
filenameEl.textContent = '';
|
||||||
|
}
|
||||||
|
resetExifPanel(true);
|
||||||
// Restore scrolling.
|
// Restore scrolling.
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
@ -132,12 +351,12 @@ function handleGalleryImageClick(e) {
|
|||||||
|
|
||||||
const imgRect = this.getBoundingClientRect();
|
const imgRect = this.getBoundingClientRect();
|
||||||
if (e.clientX < imgRect.left + imgRect.width / 2) {
|
if (e.clientX < imgRect.left + imgRect.width / 2) {
|
||||||
if (currentGalleryIndex > 0) {
|
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||||||
currentGalleryIndex--;
|
currentGalleryIndex--;
|
||||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||||
currentGalleryIndex++;
|
currentGalleryIndex++;
|
||||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||||
}
|
}
|
||||||
@ -173,12 +392,12 @@ function initGallerySwipe() {
|
|||||||
const deltaX = touchEndX - touchStartX;
|
const deltaX = touchEndX - touchStartX;
|
||||||
if (Math.abs(deltaX) > 50) { // Swipe threshold.
|
if (Math.abs(deltaX) > 50) { // Swipe threshold.
|
||||||
if (deltaX > 0) { // Swipe right: previous image.
|
if (deltaX > 0) { // Swipe right: previous image.
|
||||||
if (currentGalleryIndex > 0) {
|
if (!isGalleryLoading && currentGalleryIndex > 0) {
|
||||||
currentGalleryIndex--;
|
currentGalleryIndex--;
|
||||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||||
}
|
}
|
||||||
} else { // Swipe left: next image.
|
} else { // Swipe left: next image.
|
||||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||||
currentGalleryIndex++;
|
currentGalleryIndex++;
|
||||||
showGalleryImage(currentGalleryImages[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) {
|
function handlePinchStart(e) {
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -229,18 +546,28 @@ function initGalleryControls() {
|
|||||||
const nextButton = document.querySelector('.gallery-next');
|
const nextButton = document.querySelector('.gallery-next');
|
||||||
const prevButton = document.querySelector('.gallery-prev');
|
const prevButton = document.querySelector('.gallery-prev');
|
||||||
const closeButton = document.getElementById('gallery-close');
|
const closeButton = document.getElementById('gallery-close');
|
||||||
|
const infoButton = document.getElementById('gallery-info');
|
||||||
|
const playButton = document.getElementById('gallery-play');
|
||||||
|
|
||||||
if (nextButton) {
|
if (nextButton) {
|
||||||
nextButton.addEventListener('click', function () {
|
nextButton.addEventListener('click', function () {
|
||||||
|
if (isGalleryLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
if (currentGalleryIndex < currentGalleryImages.length - 1) {
|
||||||
currentGalleryIndex++;
|
currentGalleryIndex++;
|
||||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||||
|
} else {
|
||||||
|
closeGalleryModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevButton) {
|
if (prevButton) {
|
||||||
prevButton.addEventListener('click', function () {
|
prevButton.addEventListener('click', function () {
|
||||||
|
if (isGalleryLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentGalleryIndex > 0) {
|
if (currentGalleryIndex > 0) {
|
||||||
currentGalleryIndex--;
|
currentGalleryIndex--;
|
||||||
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
|
||||||
@ -251,6 +578,20 @@ function initGalleryControls() {
|
|||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.addEventListener('click', closeGalleryModal);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<p><button onclick="window.open('/path/${file.relative_path}', '_self');" class="btn btn-light btn-sm" style="width:100%;">📁 ${parentFolder}</button></p>
|
<p><button onclick="window.open('/path/${file.relative_path}', '_self');" class="btn btn-light btn-sm" style="width:100%;">📁 ${parentFolder}</button></p>
|
||||||
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
||||||
${ file.performance_date !== undefined ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
${ file.performance_date !== undefined ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
||||||
${ file.transcript_hits !== undefined ? `<p class="card-text">Treffer im Transkript: ${file.transcript_hits} <a href="#" class="show-transcript" data-url="${transcriptURL}" highlight="${file.query}">📄</a></p>` : ``}
|
${ file.transcript_hits !== undefined ? `<p class="card-text">Treffer im Transkript: ${file.transcript_hits} <a href="#" class="show-transcript" data-url="${transcriptURL}" data-audio-url="${file.relative_path}" highlight="${file.query}">📄</a></p>` : ``}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
resultsDiv.appendChild(card);
|
resultsDiv.appendChild(card);
|
||||||
|
|||||||
@ -53,11 +53,20 @@
|
|||||||
<div class="admin-nav">
|
<div class="admin-nav">
|
||||||
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('connections') }}">Verbindungen</a>
|
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('dashboard') }}">Auswertung</a>
|
<div class="dropdown d-inline-block">
|
||||||
<span> | </span>
|
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a>
|
Auswertungen
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">Wiederholungen</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -244,9 +253,13 @@
|
|||||||
<!-- Gallery Modal for Images -->
|
<!-- Gallery Modal for Images -->
|
||||||
<div id="gallery-modal" style="display: none;">
|
<div id="gallery-modal" style="display: none;">
|
||||||
<button id="gallery-close">x</button>
|
<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="" />
|
<img id="gallery-modal-content" src="" />
|
||||||
<button class="gallery-nav gallery-prev">‹</button>
|
<button class="gallery-nav gallery-prev">‹</button>
|
||||||
<button class="gallery-nav gallery-next">›</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>
|
||||||
|
|
||||||
<div id="loader-container" style="display: none;">
|
<div id="loader-container" style="display: none;">
|
||||||
|
|||||||
@ -33,17 +33,20 @@
|
|||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('connections') }}">Verbindungen</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="{{ url_for('dashboard') }}">Dashbord</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="{{ url_for('file_access') }}">Dateizugriffe</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="{{ url_for('songs_dashboard') }}">Wiederholungen</a>
|
|
||||||
<span> | </span>
|
|
||||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a>
|
<div class="dropdown d-inline-block">
|
||||||
|
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Auswertungen
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">Wiederholungen</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user