Compare commits

...

13 Commits

Author SHA1 Message Date
ffc5f67bde atempt fix timeline in hardware 2026-01-24 16:59:47 +00:00
a5930cb506 improve indexing 2026-01-24 16:07:44 +00:00
c7c52f0dc2 less print 2026-01-24 15:21:20 +00:00
c8c0db493f central skip 2026-01-24 15:18:50 +00:00
063f14a7b4 clean loadTrack 2026-01-24 15:01:07 +00:00
65341717cd version 2026-01-24 14:19:08 +00:00
f0346d82ad fix timeline 2026-01-24 14:16:50 +00:00
6b8f27ad19 improve system folder skip 2026-01-24 13:25:10 +00:00
4abf9c14b1 improve style 2026-01-24 12:25:01 +00:00
6630a4f300 background logo 2026-01-23 21:11:30 +00:00
4db1b8d927 audioplayer glass effect 2026-01-23 20:31:15 +00:00
77d1672efb index and search all files 2026-01-23 15:15:42 +00:00
f8e705ab20 fix caching issue: no partial files for files other then audio/image 2026-01-23 11:19:47 +00:00
10 changed files with 582 additions and 212 deletions

140
app.py
View File

@ -15,7 +15,6 @@ import mimetypes
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import diskcache import diskcache
import threading import threading
import time
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
import geoip2.database import geoip2.database
from functools import lru_cache from functools import lru_cache
@ -563,7 +562,8 @@ def list_directory_contents(directory, subpath):
continue continue
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
if entry.name in ["Transkription", "@eaDir", ".ai"]: # skip system/hidden helper folders
if entry.name in ["Transkription", "@eaDir", ".app", "#recycle"]:
continue continue
rel_path = os.path.join(subpath, entry.name) if subpath else entry.name rel_path = os.path.join(subpath, entry.name) if subpath else entry.name
@ -939,87 +939,23 @@ def serve_file(subpath):
_mark_request_logged(req_id) _mark_request_logged(req_id)
return response return response
# 5) Non-image branch: check if cached, otherwise create partial cache file # 5) Non-image branch: check if cached; if not, fill cache synchronously and then serve from cache
try: try:
with cache.read(subpath) as reader: with cache.read(subpath) as reader:
file_path = reader.name file_path = reader.name
cached_hit = True cached_hit = True
except KeyError: except KeyError:
cached_hit = False cached_hit = False
# Create a temporary cache file that we'll write to and serve simultaneously
import hashlib
import tempfile
# Generate cache key similar to diskcache
cache_key = hashlib.md5(subpath.encode('utf-8')).hexdigest()
cache_dir = os.path.join(cache.directory, cache_key[:2])
os.makedirs(cache_dir, exist_ok=True)
fd, cache_file_path = tempfile.mkstemp(
prefix=f"{cache_key}_",
suffix=".tmp",
dir=cache_dir
)
os.close(fd)
# Write an initial chunk synchronously so the temp file exists with data
initial_bytes = 0
try: try:
with open(full_path, 'rb') as source, open(cache_file_path, 'wb') as dest: # Copy full file into diskcache (blocks the request; avoids serving partial content)
chunk = source.read(1024 * 1024) # 1MB with open(full_path, 'rb') as source:
if chunk: cache.set(subpath, source, read=True)
dest.write(chunk) with cache.read(subpath) as reader:
dest.flush() file_path = reader.name
initial_bytes = len(chunk) app.logger.info(f"Cached {subpath}")
except Exception as e: except Exception as e:
app.logger.error(f"Failed to prime cache file for {subpath}: {e}") app.logger.error(f"Cache fill failed for {subpath}: {e}")
if os.path.exists(cache_file_path): abort(503, description="Service temporarily unavailable - cache population failed")
try:
os.remove(cache_file_path)
except:
pass
abort(503, description="Service temporarily unavailable - cache initialization failed")
# Start copying to our cache file in chunks
def copy_to_cache_chunked(start_offset):
try:
with open(full_path, 'rb') as source, open(cache_file_path, 'ab') as dest:
source.seek(start_offset)
while True:
chunk = source.read(1024 * 1024) # 1MB chunks
if not chunk:
break
dest.write(chunk)
dest.flush() # Ensure data is written to disk immediately
# Once complete, register with diskcache for proper management
try:
if subpath in cache:
if os.path.exists(cache_file_path):
os.remove(cache_file_path)
app.logger.info(f"Cache already populated for {subpath}, skipped duplicate registration")
return
with open(cache_file_path, 'rb') as f:
cache.set(subpath, f, read=True)
# Remove our temp file since diskcache now has it
if os.path.exists(cache_file_path):
os.remove(cache_file_path)
app.logger.info(f"Finished caching {subpath}")
except Exception as e:
app.logger.error(f"Failed to register with diskcache: {e}")
except Exception as e:
app.logger.error(f"Caching failed for {subpath}: {e}")
if os.path.exists(cache_file_path):
try:
os.remove(cache_file_path)
except:
pass
# Start the background copy
cache_thread = threading.Thread(target=copy_to_cache_chunked, args=(initial_bytes,), daemon=True)
cache_thread.start()
file_path = cache_file_path
# 6) Build response for non-image # 6) Build response for non-image
if as_attachment: if as_attachment:
@ -1031,49 +967,19 @@ def serve_file(subpath):
# Single send_file call with proper attachment handling # Single send_file call with proper attachment handling
# For partial cache files, we need to handle this differently response = send_file(
if not cached_hit: file_path,
# Stream from the cache file as it's being written mimetype=mimetype,
def generate(): conditional=True,
bytes_sent = 0 as_attachment=as_attachment,
with open(file_path, 'rb') as f: download_name=filename if as_attachment else None
while bytes_sent < filesize: )
# Read what's available
chunk = f.read(1024 * 1024) # 1MB chunks if as_attachment:
if chunk: response.headers['X-Content-Type-Options'] = 'nosniff'
bytes_sent += len(chunk) response.headers['Content-Disposition'] = 'attachment'
yield chunk
else:
# No data available yet, wait a bit
time.sleep(0.1)
if request.method == 'HEAD':
response = make_response('', 200)
else:
response = make_response(generate())
response.headers['Content-Type'] = mimetype
response.headers['Content-Length'] = str(filesize)
response.headers['Accept-Ranges'] = 'bytes'
if as_attachment:
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
response.headers['X-Content-Type-Options'] = 'nosniff'
else:
response.headers['Content-Disposition'] = 'inline'
else: else:
# Cached file - use normal send_file response.headers['Content-Disposition'] = 'inline'
response = send_file(
file_path,
mimetype=mimetype,
conditional=True,
as_attachment=as_attachment,
download_name=filename if as_attachment else None
)
if as_attachment:
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Disposition'] = 'attachment'
else:
response.headers['Content-Disposition'] = 'inline'
response.headers['Cache-Control'] = 'public, max-age=86400' response.headers['Cache-Control'] = 'public, max-age=86400'

View File

@ -2,12 +2,22 @@ import os
import json import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from time import monotonic
import re import re
import helperfunctions as hf import helperfunctions as hf
SEARCH_DB_NAME = 'search.db' SEARCH_DB_NAME = 'search.db'
ACCESS_LOG_DB_NAME = 'access_log.db' ACCESS_LOG_DB_NAME = 'access_log.db'
FOLDER_CONFIG = 'folder_secret_config.json' FOLDER_CONFIG = 'folder_secret_config.json'
TRANSCRIPT_DIRNAME = "Transkription"
TRANSCRIPT_EXT = ".md"
IGNORED_DIRS = {TRANSCRIPT_DIRNAME, "@eaDir", ".app", "#recycle"}
# Logging/progress tuning (keep output light by default)
LOG_STRUCTURE_DEPTH = int(os.getenv("INDEX_LOG_STRUCTURE_DEPTH", "0") or 0)
PROGRESS_EVERY_SECS = float(os.getenv("INDEX_PROGRESS_SECS", "30"))
PROGRESS_EVERY_FILES = int(os.getenv("INDEX_PROGRESS_FILES", "5000"))
MAX_ERROR_LOGS = int(os.getenv("INDEX_MAX_ERROR_LOGS", "5"))
# Connect to the search database. # Connect to the search database.
search_db = sqlite3.connect(SEARCH_DB_NAME, check_same_thread=False) search_db = sqlite3.connect(SEARCH_DB_NAME, check_same_thread=False)
@ -17,6 +27,30 @@ search_db.row_factory = sqlite3.Row
access_log_db = sqlite3.connect(f'file:{ACCESS_LOG_DB_NAME}?mode=ro', uri=True) access_log_db = sqlite3.connect(f'file:{ACCESS_LOG_DB_NAME}?mode=ro', uri=True)
access_log_db.row_factory = sqlite3.Row access_log_db.row_factory = sqlite3.Row
def log(message: str):
"""Small helper to ensure console output is flushed immediately."""
print(message, flush=True)
def log_permission_error(path: str, stats: dict):
"""Log permission errors sparingly to avoid noisy output."""
stats["perm_errors"] += 1
if stats["perm_errors"] <= MAX_ERROR_LOGS:
log(f"Permission denied: {path}")
elif stats["perm_errors"] == MAX_ERROR_LOGS + 1:
log("Further permission errors suppressed.")
def skip_dir(name: str) -> bool:
"""Return True when a directory name should be skipped during traversal/logging."""
return name.startswith('.') or name in IGNORED_DIRS
def format_duration(seconds: float) -> str:
total_secs = int(seconds)
minutes, secs = divmod(total_secs, 60)
return f"{minutes}m {secs:02d}s"
def init_db(): def init_db():
"""Initializes the database with the required schema.""" """Initializes the database with the required schema."""
cursor = search_db.cursor() cursor = search_db.cursor()
@ -48,20 +82,28 @@ def init_db():
search_db.commit() search_db.commit()
def scan_dir(directory): def scan_dir(directory: str, stats: dict):
"""Recursively scan directories using os.scandir for improved performance.""" """Iteratively scan directories using os.scandir for improved performance."""
try: stack = [directory]
with os.scandir(directory) as it: while stack:
for entry in it: current = stack.pop()
if entry.is_dir(follow_symlinks=False): stats["dirs"] += 1
# Skip transcription directories immediately. try:
if entry.name.lower() == "transkription": with os.scandir(current) as it:
continue for entry in it:
yield from scan_dir(entry.path) try:
elif entry.is_file(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
yield entry # Skip unwanted directories immediately.
except PermissionError: if skip_dir(entry.name):
return stats["skipped_dirs"] += 1
continue
stack.append(entry.path)
elif entry.is_file(follow_symlinks=False):
yield entry
except PermissionError:
log_permission_error(entry.path, stats)
except PermissionError:
log_permission_error(current, stats)
def get_hit_count(relative_path): def get_hit_count(relative_path):
"""Returns the hit count for a given file from the access log database.""" """Returns the hit count for a given file from the access log database."""
@ -71,8 +113,77 @@ def get_hit_count(relative_path):
return row["hit_count"] if row else 0 return row["hit_count"] if row else 0
def get_hit_counts_for_basefolder(basefolder: str) -> dict:
"""Return a map of rel_path -> hit_count for all files under a basefolder."""
cursor = access_log_db.cursor()
pattern = f"{basefolder}/%"
cursor.execute(
"SELECT rel_path, COUNT(*) AS hit_count FROM file_access_log WHERE rel_path LIKE ? GROUP BY rel_path",
(pattern,)
)
return {row["rel_path"]: row["hit_count"] for row in cursor.fetchall()}
def build_transcript_index(transcript_dir: str, stats: dict):
"""Return a dict of basename -> transcript path for a transcript directory."""
try:
with os.scandir(transcript_dir) as it:
index = {}
for entry in it:
if not entry.is_file(follow_symlinks=False):
continue
name = entry.name
if not name.endswith(TRANSCRIPT_EXT):
continue
index[name[:-len(TRANSCRIPT_EXT)]] = entry.path
return index
except FileNotFoundError:
return None
except PermissionError:
log_permission_error(transcript_dir, stats)
return None
def log_structure(root_path, max_depth=None, show_files=False):
"""
Log folder structure up to max_depth levels (root = depth 1).
If max_depth is None, traverse all depths. Files are logged only when show_files is True.
"""
depth_label = "all" if max_depth is None else f"<= {max_depth}"
log(f"Folder structure (depth {depth_label}) for '{root_path}':")
def _walk(path, depth):
if max_depth is not None and depth > max_depth:
return
try:
with os.scandir(path) as it:
entries = sorted(it, key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
for entry in entries:
if entry.is_dir(follow_symlinks=False):
if skip_dir(entry.name):
continue
indent = " " * (depth - 1)
log(f"{indent}- {entry.name}/")
_walk(entry.path, depth + 1)
elif show_files:
indent = " " * (depth - 1)
log(f"{indent}- {entry.name}")
except PermissionError:
indent = " " * (depth - 1)
log(f"{indent}- [permission denied]")
_walk(root_path, depth=1)
def log_file(relative_path: str, filename: str):
"""Debug helper to log each file that is indexed."""
log(f" file: {relative_path} ({filename})")
def updatefileindex(): def updatefileindex():
total_start = monotonic()
cursor = search_db.cursor() cursor = search_db.cursor()
totals = {"folders": 0, "scanned": 0, "deleted": 0}
# Load folder configuration from JSON file. # Load folder configuration from JSON file.
with open(FOLDER_CONFIG, "r", encoding="utf-8") as f: with open(FOLDER_CONFIG, "r", encoding="utf-8") as f:
@ -81,68 +192,120 @@ def updatefileindex():
# Process each configured base folder. # Process each configured base folder.
for config in config_data: for config in config_data:
for folder in config.get("folders", []): for folder in config.get("folders", []):
totals["folders"] += 1
foldername = folder.get("foldername") foldername = folder.get("foldername")
print(f"Processing folder: {foldername}") log(f"Processing folder: {foldername}")
raw_folderpath = folder.get("folderpath") raw_folderpath = folder.get("folderpath")
norm_folderpath = os.path.normpath(raw_folderpath) norm_folderpath = os.path.normpath(raw_folderpath)
# Optional shallow structure log (off by default)
if LOG_STRUCTURE_DEPTH > 0:
log_structure(norm_folderpath, max_depth=LOG_STRUCTURE_DEPTH, show_files=False)
# Precompute the length of the base folder path (plus one for the separator) # Precompute the length of the base folder path (plus one for the separator)
base_len = len(norm_folderpath) + 1 base_prefix = norm_folderpath + os.sep
base_len = len(base_prefix)
# Prefetch hit counts for this basefolder to avoid per-file queries
hitcount_map = get_hit_counts_for_basefolder(foldername)
# Accumulate scanned file data and keys for this base folder. # Accumulate scanned file data and keys for this base folder.
scanned_files = [] # Each entry: (relative_path, basefolder, filename, filetype, transcript, hitcount) scanned_files = [] # Each entry: (relative_path, basefolder, filename, filetype, transcript, hitcount)
current_keys = set() current_keys = set()
for entry in scan_dir(norm_folderpath): scan_stats = {"dirs": 0, "skipped_dirs": 0, "perm_errors": 0}
transcript_cache = {}
transcripts_read = 0
transcript_errors = 0
site = None
if foldername == 'Gottesdienste Speyer':
site = 'Speyer'
elif foldername == 'Gottesdienste Schwegenheim':
site = 'Schwegenheim'
start_time = monotonic()
last_log_time = start_time
next_log_count = PROGRESS_EVERY_FILES
scanned_count = 0
last_log_count = 0
extract_structure = hf.extract_structure_from_string
extract_date = hf.extract_date_from_string
for entry in scan_dir(norm_folderpath, scan_stats):
transcript = None transcript = None
entry_path = os.path.normpath(entry.path) scanned_count += 1
entry_path = entry.path
# Get relative part by slicing if possible. # Get relative part by slicing if possible.
if entry_path.startswith(norm_folderpath): if entry_path.startswith(base_prefix):
rel_part = entry_path[base_len:] rel_part = entry_path[base_len:]
else: else:
rel_part = os.path.relpath(entry_path, norm_folderpath) rel_part = os.path.relpath(entry_path, norm_folderpath)
# Prepend the foldername so it becomes part of the stored relative path. # Prepend the foldername so it becomes part of the stored relative path.
relative_path = os.path.join(foldername, rel_part).replace(os.sep, '/') rel_part = rel_part.replace(os.sep, '/')
filetype = os.path.splitext(entry.name)[1].lower() relative_path = f"{foldername}/{rel_part}"
name_root, name_ext = os.path.splitext(entry.name)
filetype = name_ext.lower()
if filetype not in ['.mp3', '.wav', '.ogg', '.m4a', '.flac']: # Retrieve the hit count for this file from pre-fetched map.
# Skip non-audio files. hit_count = hitcount_map.get(relative_path, 0)
continue
# Retrieve the hit count for this file.
hit_count = get_hit_count(relative_path)
# Determine the site
if foldername == 'Gottesdienste Speyer':
site = 'Speyer'
elif foldername == 'Gottesdienste Schwegenheim':
site = 'Schwegenheim'
else:
site = None
# Check for a corresponding transcript file in a sibling "Transkription" folder. # Check for a corresponding transcript file in a sibling "Transkription" folder.
parent_dir = os.path.dirname(entry_path) parent_dir = os.path.dirname(entry_path)
transcript_dir = os.path.join(parent_dir, "Transkription") transcript_index = transcript_cache.get(parent_dir)
transcript_filename = os.path.splitext(entry.name)[0] + ".md" if transcript_index is None and parent_dir not in transcript_cache:
transcript_path = os.path.join(transcript_dir, transcript_filename) transcript_dir = os.path.join(parent_dir, TRANSCRIPT_DIRNAME)
if os.path.exists(transcript_path): transcript_index = build_transcript_index(transcript_dir, scan_stats)
try: transcript_cache[parent_dir] = transcript_index
with open(transcript_path, 'r', encoding='utf-8') as tf: if transcript_index:
transcript = tf.read() transcript_path = transcript_index.get(name_root)
except Exception: if transcript_path:
transcript = None try:
with open(transcript_path, 'r', encoding='utf-8') as tf:
transcript = tf.read()
transcripts_read += 1
except Exception:
transcript_errors += 1
category, titel, name = hf.extract_structure_from_string(entry.name) category, titel, name = extract_structure(entry.name)
performance_date = hf.extract_date_from_string(relative_path) performance_date = extract_date(relative_path)
scanned_files.append((relative_path, foldername, entry.name, filetype, category, titel, name, performance_date, site, transcript, hit_count)) scanned_files.append((relative_path, foldername, entry.name, filetype, category, titel, name, performance_date, site, transcript, hit_count))
current_keys.add((relative_path, entry.name)) current_keys.add((relative_path, entry.name))
# Light progress output
now = monotonic()
if scanned_count >= next_log_count or (now - last_log_time) >= PROGRESS_EVERY_SECS:
elapsed = max(now - start_time, 0.0001)
window_elapsed = max(now - last_log_time, 0.0001)
window_count = scanned_count - last_log_count
window_rate = window_count / window_elapsed
log(f" progress: {scanned_count} files, {scan_stats['dirs']} dirs, {window_rate:.1f} files/s")
last_log_time = now
last_log_count = scanned_count
next_log_count = scanned_count + PROGRESS_EVERY_FILES
# Progress indicator
dir_count = scan_stats["dirs"]
file_count = scanned_count
elapsed = max(monotonic() - start_time, 0.0001)
avg_rate = file_count / elapsed
log(f"Scan summary for '{foldername}': {dir_count} dirs, {file_count} files, {avg_rate:.1f} files/s avg")
if scan_stats["skipped_dirs"]:
log(f" skipped dirs: {scan_stats['skipped_dirs']}")
if scan_stats["perm_errors"]:
log(f" permission errors: {scan_stats['perm_errors']}")
if transcripts_read or transcript_errors:
log(f" transcripts: {transcripts_read} read, {transcript_errors} errors")
log("updating database...")
scan_duration = format_duration(elapsed)
# Remove database entries for files under this base folder that are no longer on disk. # Remove database entries for files under this base folder that are no longer on disk.
pattern = foldername + os.sep + '%' pattern = foldername + os.sep + '%'
cursor.execute("SELECT id, relative_path, filename FROM files WHERE relative_path LIKE ?", (pattern,)) cursor.execute("SELECT id, relative_path, filename FROM files WHERE relative_path LIKE ?", (pattern,))
db_rows = cursor.fetchall() db_rows = cursor.fetchall()
keys_in_db = set((row["relative_path"], row["filename"]) for row in db_rows) keys_in_db = set((row["relative_path"], row["filename"]) for row in db_rows)
keys_to_delete = keys_in_db - current_keys keys_to_delete = keys_in_db - current_keys
deleted_count = len(keys_to_delete)
totals["deleted"] += deleted_count
for key in keys_to_delete: for key in keys_to_delete:
cursor.execute("DELETE FROM files WHERE relative_path = ? AND filename = ?", key) cursor.execute("DELETE FROM files WHERE relative_path = ? AND filename = ?", key)
@ -154,7 +317,14 @@ def updatefileindex():
# Commit changes after processing this base folder. # Commit changes after processing this base folder.
search_db.commit() search_db.commit()
folder_scanned = scanned_count
totals["scanned"] += folder_scanned
log(f"Indexed {folder_scanned} files (deleted {deleted_count}) in '{foldername}'")
log(f"Scan duration for '{foldername}': {scan_duration}")
total_elapsed = max(monotonic() - total_start, 0.0001)
log(f"Index update finished: folders={totals['folders']}, files indexed={totals['scanned']}, removed={totals['deleted']}")
log(f"Total index duration: {format_duration(total_elapsed)}")
return "File index updated successfully" return "File index updated successfully"
def convert_dates(search_db, def convert_dates(search_db,

View File

@ -12,12 +12,23 @@ search_db.row_factory = sqlite3.Row
with open("app_config.json", 'r') as file: with open("app_config.json", 'r') as file:
app_config = json.load(file) app_config = json.load(file)
FILETYPE_GROUPS = {
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'),
'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff')
}
ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
def searchcommand(): def searchcommand():
query = request.form.get("query", "").strip() query = request.form.get("query", "").strip()
category = request.form.get("category", "").strip() category = request.form.get("category", "").strip()
searchfolder = request.form.get("folder", "").strip() searchfolder = request.form.get("folder", "").strip()
datefrom = request.form.get("datefrom", "").strip() datefrom = request.form.get("datefrom", "").strip()
dateto = request.form.get("dateto", "").strip() dateto = request.form.get("dateto", "").strip()
filetypes = [ft.strip().lower() for ft in request.form.getlist("filetype") if ft.strip()]
if not filetypes:
# Default to audio when nothing selected
filetypes = ['audio']
include_transcript = request.form.get("includeTranscript") in ["true", "on"] include_transcript = request.form.get("includeTranscript") in ["true", "on"]
words = [w for w in query.split() if w] words = [w for w in query.split() if w]
@ -72,6 +83,25 @@ def searchcommand():
if datefrom or dateto: if datefrom or dateto:
conditions.append("performance_date IS NOT NULL") conditions.append("performance_date IS NOT NULL")
# Filetype filters (multiple selection)
selected_groups = [ft for ft in filetypes if ft in FILETYPE_GROUPS]
include_other = 'other' in filetypes
# If not all groups selected, apply filter
if set(filetypes) != {'audio', 'video', 'image', 'other'}:
clauses = []
if selected_groups:
ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]})
placeholders = ",".join("?" for _ in ext_list)
clauses.append(f"filetype IN ({placeholders})")
params.extend(ext_list)
if include_other:
placeholders = ",".join("?" for _ in ALL_GROUP_EXTS)
clauses.append(f"(filetype IS NULL OR filetype = '' OR filetype NOT IN ({placeholders}))")
params.extend(ALL_GROUP_EXTS)
if clauses:
conditions.append("(" + " OR ".join(clauses) + ")")
# Build and execute SQL # Build and execute SQL
sql = "SELECT * FROM files" sql = "SELECT * FROM files"
if conditions: if conditions:

View File

@ -19,6 +19,32 @@ body {
var(--light-background); var(--light-background);
} }
/* Subtle oversized logo watermark on all main sections */
main {
position: relative;
overflow: hidden;
}
main::before {
content: "";
position: absolute;
inset: 100px 0 0 0;
background-image: url("/custom_logo/logoB.png");
background-repeat: no-repeat;
background-position: center top;
background-size: 140%;
opacity: 0.03;
filter: blur(3px) saturate(90%) brightness(1.02);
mix-blend-mode: multiply;
pointer-events: none;
z-index: 5; /* bring logo in front */
}
main > * {
position: relative;
z-index: 1;
}
/* Header styles */ /* Header styles */
.site-header { .site-header {
background: linear-gradient(120deg, var(--dark-background), #13253f); background: linear-gradient(120deg, var(--dark-background), #13253f);
@ -79,6 +105,11 @@ body {
font-size: 17px; font-size: 17px;
} }
/* Spacing for alerts so they don't stick to edges or neighbors */
.alert {
margin: 12px 0;
}
/* Carded content shell */ /* Carded content shell */
main.tab-content, main.tab-content,
section.tab-content { section.tab-content {
@ -109,6 +140,10 @@ search {
opacity: 0.85; opacity: 0.85;
} }
.search-query-wrap .input-group {
position: relative;
}
.input-clear-btn { .input-clear-btn {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@ -1165,3 +1200,10 @@ footer .audio-player-container {
color: var(--brand-ink); color: var(--brand-ink);
border-color: var(--brand-navy); border-color: var(--brand-navy);
} }
/* Highlight a file when opened from search */
.search-highlight {
outline: 2px solid #f6c344;
background-color: rgba(246, 195, 68, 0.25);
border-radius: 4px;
}

View File

@ -1,14 +1,23 @@
.audio-player-container { .audio-player-container {
background: linear-gradient(135deg, #f9fbff, #eef4ff); background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04));
backdrop-filter: blur(10px) saturate(135%);
-webkit-backdrop-filter: blur(10px) saturate(135%);
border-radius: 14px; border-radius: 14px;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.12); box-shadow:
border: 8px solid #0b1220; 0 12px 30px rgba(15, 23, 42, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
border: 2px solid rgba(255, 255, 255, 0.55);
outline: 1.5px solid rgba(12, 18, 32, 0.3); /* slightly stronger edge so front/back separate */
outline-offset: -1px;
background-clip: padding-box; /* keep border separate from glass */
padding: 12px 12px; padding: 12px 12px;
width: min(600px, 100%); width: min(600px, 100%);
display: none; display: none;
color: #1f2937; color: #1f2937;
margin-left: 8px; margin-left: 8px;
margin-right: 8px; margin-right: 8px;
overflow: hidden; /* keep the glass blur clean at the edges */
position: relative;
} }
/* Now Playing Info */ /* Now Playing Info */
@ -44,11 +53,13 @@
-webkit-appearance: none; -webkit-appearance: none;
width: 100%; width: 100%;
height: 0.5em; height: 0.5em;
background-color: #e6edf7; background-color: rgba(255, 255, 255, 0.12);
border-radius: 5px; border-radius: 5px;
background-size: 0% 100%; background-size: 0% 100%;
background-image: linear-gradient(90deg, #0b1220, #0f172a); background-image: linear-gradient(90deg, rgba(11, 18, 32, 0.9), rgba(15, 23, 42, 0.9));
background-repeat: no-repeat; background-repeat: no-repeat;
border: 1px solid rgba(15, 23, 42, 0.2);
background-clip: padding-box; /* keep gradient clean inside the border */
appearance: none; appearance: none;
outline: none; outline: none;
} }
@ -160,3 +171,61 @@
.timeline::-ms-thumb { .timeline::-ms-thumb {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
} }
/* Minimize button */
.minimize-button {
position: absolute;
top: 6px;
right: 6px;
width: 18px;
height: 18px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.18);
background: rgba(255, 255, 255, 0.65);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.16);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
padding: 0;
}
.minimize-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.18);
}
.minimize-button:active {
transform: translateY(0);
}
.minimize-button svg {
width: 16px;
height: 16px;
stroke: currentColor;
stroke-width: 1.6;
fill: none;
}
.minimize-button .icon-expand {
display: none;
}
/* Collapsed state */
.audio-player-container.collapsed {
padding: 8px 48px 8px 12px;
}
.audio-player-container.collapsed .audio-player,
.audio-player-container.collapsed .now-playing-info {
display: none;
}
.audio-player-container.collapsed .minimize-button .icon-collapse {
display: none;
}
.audio-player-container.collapsed .minimize-button .icon-expand {
display: inline-flex;
}

View File

@ -6,7 +6,8 @@ class SimpleAudioPlayer {
infoSelector = '#timeInfo', infoSelector = '#timeInfo',
nowPlayingSelector = '#nowPlayingInfo', nowPlayingSelector = '#nowPlayingInfo',
containerSelector = '#audioPlayerContainer', containerSelector = '#audioPlayerContainer',
footerSelector = 'footer' footerSelector = 'footer',
minimizeBtnSelector = '.minimize-button'
} = {}) { } = {}) {
// Elements // Elements
this.audio = document.querySelector(audioSelector); this.audio = document.querySelector(audioSelector);
@ -16,12 +17,13 @@ class SimpleAudioPlayer {
this.nowInfo = document.querySelector(nowPlayingSelector); this.nowInfo = document.querySelector(nowPlayingSelector);
this.container = document.querySelector(containerSelector); this.container = document.querySelector(containerSelector);
this.footer = document.querySelector(footerSelector); this.footer = document.querySelector(footerSelector);
this.minBtn = document.querySelector(minimizeBtnSelector);
// State // State
this.isSeeking = false; this.isSeeking = false;
this.rafId = null; this.rafId = null;
this.abortCtrl = null; this.abortCtrl = null;
this._posInterval = null; this._hasMetadata = false;
// Pre-compute icons once // Pre-compute icons once
const fill = getComputedStyle(document.documentElement) const fill = getComputedStyle(document.documentElement)
@ -51,6 +53,33 @@ class SimpleAudioPlayer {
this._initMediaSession(); this._initMediaSession();
} }
/**
* Push the current position to MediaSession so lockscreen / BT UIs stay in sync.
* Keeps values strict but avoids over-clamping that can confuse some platforms.
*/
_pushPositionState(force = false) {
if (!navigator.mediaSession?.setPositionState) return;
if (!this._hasMetadata) return;
const duration = this.audio.duration;
const position = this.audio.currentTime;
if (!Number.isFinite(duration) || duration <= 0) return;
if (!Number.isFinite(position) || position < 0) return;
const playbackRate = this.audio.paused
? 0
: (Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1);
try {
if ('playbackState' in navigator.mediaSession) {
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
navigator.mediaSession.setPositionState({ duration, playbackRate, position });
} catch (err) {
if (force) console.warn('setPositionState failed:', err);
}
}
// Minimal SVG helper // Minimal SVG helper
svg(pathD, fill) { svg(pathD, fill) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${fill}"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${fill}">
@ -60,42 +89,59 @@ class SimpleAudioPlayer {
_bindEvents() { _bindEvents() {
this.playBtn.addEventListener('click', () => this.togglePlay()); this.playBtn.addEventListener('click', () => this.togglePlay());
if (this.minBtn) {
this.minBtn.addEventListener('click', () => this.toggleCollapse());
}
this.audio.addEventListener('loadstart', () => {
this._hasMetadata = false;
this.timeline.min = 0;
this.timeline.max = 0;
this.timeline.value = 0;
this.timeline.style.backgroundSize = '0% 100%';
this.timeInfo.textContent = '00:00 / --:--';
});
this.audio.addEventListener('loadedmetadata', () => { this.audio.addEventListener('loadedmetadata', () => {
this._hasMetadata = true;
this.timeline.min = 0; this.timeline.min = 0;
this.timeline.max = this.audio.duration; this.timeline.max = this.audio.duration;
this.timeline.value = 0; this.timeline.value = 0;
this.timeline.style.backgroundSize = '0% 100%'; this.timeline.style.backgroundSize = '0% 100%';
this._pushPositionState(true);
}); });
this.audio.addEventListener('play', () => this._updatePlayState()); this.audio.addEventListener('play', () => {
this.audio.addEventListener('pause', () => this._updatePlayState()); this._updatePlayState();
this.audio.addEventListener('ended', () => this.playBtn.innerHTML = this.icons.play); this._pushPositionState(true);
});
this.audio.addEventListener('playing', () => this._pushPositionState(true));
this.audio.addEventListener('pause', () => {
this._updatePlayState();
this._pushPositionState(true);
});
this.audio.addEventListener('ratechange', () => this._pushPositionState(true));
this.audio.addEventListener('ended', () => {
this.playBtn.innerHTML = this.icons.play;
this._pushPositionState(true);
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
});
// Native timeupdate => update timeline // Native timeupdate => update timeline
this.audio.addEventListener('timeupdate', () => this.updateTimeline()); this.audio.addEventListener('timeupdate', () => this.updateTimeline());
// Fallback interval for throttled mobile // Keep position in sync when page visibility changes
this._interval = setInterval(() => { document.addEventListener('visibilitychange', () => this._pushPositionState(true));
if (!this.audio.paused && !this.isSeeking) {
this.updateTimeline();
}
}, 500);
// Unified seek input // Unified seek input
this.timeline.addEventListener('input', () => { this.timeline.addEventListener('input', () => {
this.isSeeking = true; this.isSeeking = true;
this.audio.currentTime = this.timeline.value; this.audio.currentTime = this.timeline.value;
// immediate Android sync this._pushPositionState(true); // immediate Android sync
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
}
this.updateTimeline(); this.updateTimeline();
this.isSeeking = false; this.isSeeking = false;
}); });
@ -107,9 +153,7 @@ class SimpleAudioPlayer {
_updatePlayState() { _updatePlayState() {
this.playBtn.innerHTML = this.audio.paused ? this.icons.play : this.icons.pause; this.playBtn.innerHTML = this.audio.paused ? this.icons.play : this.icons.pause;
if ('mediaSession' in navigator) { this._pushPositionState(true); // lockscreen refresh on play/pause toggle
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
} }
_formatTime(sec) { _formatTime(sec) {
@ -118,7 +162,7 @@ class SimpleAudioPlayer {
} }
updateTimeline() { updateTimeline() {
if (!this.audio.duration || this.isSeeking) return; if (!this._hasMetadata || !this.audio.duration || this.isSeeking) return;
// 1) Move the thumb // 1) Move the thumb
this.timeline.value = this.audio.currentTime; this.timeline.value = this.audio.currentTime;
@ -129,12 +173,14 @@ class SimpleAudioPlayer {
this.timeInfo.textContent = this.timeInfo.textContent =
`${this._formatTime(this.audio.currentTime)} / ${this._formatTime(this.audio.duration)}`; `${this._formatTime(this.audio.currentTime)} / ${this._formatTime(this.audio.duration)}`;
// 4) Push to Android widget // 4) Push to Android widget
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) { this._pushPositionState();
navigator.mediaSession.setPositionState({ }
duration: this.audio.duration,
playbackRate: this.audio.playbackRate, toggleCollapse() {
position: this.audio.currentTime const collapsed = this.container.classList.toggle('collapsed');
}); if (this.minBtn) {
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
} }
} }
@ -173,7 +219,20 @@ class SimpleAudioPlayer {
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2))); const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`; const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
// Reset any residual state from the previous track before we fetch the new one
this._hasMetadata = false;
this.audio.pause(); this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.timeline.min = 0;
this.timeline.max = 0;
this.timeline.value = 0;
this.timeline.style.backgroundSize = '0% 100%';
this.timeInfo.textContent = '00:00 / --:--';
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'paused';
}
this.container.style.display = 'block'; this.container.style.display = 'block';
this.nowInfo.textContent = 'Loading…'; this.nowInfo.textContent = 'Loading…';
@ -188,6 +247,7 @@ class SimpleAudioPlayer {
if (!head.ok) throw new Error(`Status ${head.status}`); if (!head.ok) throw new Error(`Status ${head.status}`);
this.audio.src = urlWithReq; this.audio.src = urlWithReq;
this.audio.load();
await this.audio.play(); await this.audio.play();
// Full breadcrumb // Full breadcrumb
@ -254,16 +314,6 @@ class SimpleAudioPlayer {
} }
})); }));
// Heartbeat for widget
this._posInterval = setInterval(() => {
if (!this.audio.paused && navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
}
}, 1000);
} }
} }

View File

@ -5,17 +5,26 @@ document.addEventListener('DOMContentLoaded', function() {
const resultsDiv = document.getElementById('results'); const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>'; resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>';
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
if (data.results && data.results.length > 0) { if (data.results && data.results.length > 0) {
data.results.forEach(file => { data.results.forEach(file => {
const card = document.createElement('div'); const card = document.createElement('div');
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.'); const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/'); const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md'; const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
const encodedRelPath = encodeURI(file.relative_path);
card.className = 'card'; card.className = 'card';
const fileAction = isAudio
? `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`
: `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
card.innerHTML = ` card.innerHTML = `
<div class="card-body"> <div class="card-body">
<p><button class="btn btn-light" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button></p> <p>${fileAction}</p>
<p><button onclick="window.open('/path/${file.relative_path}', '_self');" class="btn btn-light btn-sm" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder}</button></p> <p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</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}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></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}"><i class="bi bi-journal-text"></i></a></p>` : ``}
@ -24,6 +33,7 @@ document.addEventListener('DOMContentLoaded', function() {
resultsDiv.appendChild(card); resultsDiv.appendChild(card);
}); });
attachEventListeners(); attachEventListeners();
attachSearchFolderButtons();
} else { } else {
resultsDiv.innerHTML = '<p>No results found.</p>'; resultsDiv.innerHTML = '<p>No results found.</p>';
} }
@ -55,6 +65,22 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// Restore previously selected filetypes (multi-select). Default to audio if none stored.
const previousFiletypes = localStorage.getItem("searchFiletypes");
if (previousFiletypes) {
try {
const list = JSON.parse(previousFiletypes);
document.querySelectorAll('input[name=\"filetype\"]').forEach(cb => {
cb.checked = list.includes(cb.value);
});
} catch (e) {
console.error('Error parsing stored filetypes', e);
document.getElementById('filetype-audio').checked = true;
}
} else {
document.getElementById('filetype-audio').checked = true;
}
// Restore the checkbox state for "Im Transkript suchen" // Restore the checkbox state for "Im Transkript suchen"
const previousIncludeTranscript = localStorage.getItem("searchIncludeTranscript"); const previousIncludeTranscript = localStorage.getItem("searchIncludeTranscript");
if (previousIncludeTranscript !== null) { if (previousIncludeTranscript !== null) {
@ -74,6 +100,15 @@ document.addEventListener('DOMContentLoaded', function() {
const categoryRadio = document.querySelector('input[name="category"]:checked'); const categoryRadio = document.querySelector('input[name="category"]:checked');
const category = categoryRadio ? categoryRadio.value : ''; const category = categoryRadio ? categoryRadio.value : '';
// Get selected filetypes (allow multiple). Default to audio if none selected.
const filetypeCheckboxes = document.querySelectorAll('input[name=\"filetype\"]');
let filetypes = Array.from(filetypeCheckboxes).filter(cb => cb.checked).map(cb => cb.value);
if (filetypes.length === 0) {
// enforce audio as default when user unchecked all
document.getElementById('filetype-audio').checked = true;
filetypes = ['audio'];
}
// Prevent accidental re-selection of already selected radio buttons // Prevent accidental re-selection of already selected radio buttons
const radios = document.querySelectorAll('input[name="category"]'); const radios = document.querySelectorAll('input[name="category"]');
radios.forEach(radio => { radios.forEach(radio => {
@ -98,6 +133,7 @@ document.addEventListener('DOMContentLoaded', function() {
formData.append('datefrom', document.getElementById('datefrom').value); formData.append('datefrom', document.getElementById('datefrom').value);
formData.append('dateto', document.getElementById('dateto').value); formData.append('dateto', document.getElementById('dateto').value);
formData.append('includeTranscript', includeTranscript); formData.append('includeTranscript', includeTranscript);
filetypes.forEach(ft => formData.append('filetype', ft));
const settleSpinner = () => { const settleSpinner = () => {
clearTimeout(spinnerTimer); clearTimeout(spinnerTimer);
@ -121,6 +157,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Save the search word, selected category, and checkbox state in localStorage // Save the search word, selected category, and checkbox state in localStorage
localStorage.setItem("searchQuery", query); localStorage.setItem("searchQuery", query);
localStorage.setItem("searchCategory", category); localStorage.setItem("searchCategory", category);
localStorage.setItem("searchFiletypes", JSON.stringify(filetypes));
localStorage.setItem("searchIncludeTranscript", includeTranscript); localStorage.setItem("searchIncludeTranscript", includeTranscript);
}) })
.catch(error => { .catch(error => {
@ -140,6 +177,7 @@ document.addEventListener('DOMContentLoaded', function() {
localStorage.removeItem("searchResponse"); localStorage.removeItem("searchResponse");
localStorage.removeItem("searchQuery"); localStorage.removeItem("searchQuery");
localStorage.removeItem("searchCategory"); localStorage.removeItem("searchCategory");
localStorage.removeItem("searchFiletypes");
localStorage.removeItem("folder"); localStorage.removeItem("folder");
localStorage.removeItem("datefrom"); localStorage.removeItem("datefrom");
localStorage.removeItem("dateto"); localStorage.removeItem("dateto");
@ -149,6 +187,9 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelector('input[name="category"][value=""]').checked = true; document.querySelector('input[name="category"][value=""]').checked = true;
const otherRadios = document.querySelectorAll('input[name="category"]:not([value=""])'); const otherRadios = document.querySelectorAll('input[name="category"]:not([value=""])');
otherRadios.forEach(radio => radio.checked = false); otherRadios.forEach(radio => radio.checked = false);
document.getElementById('filetype-audio').checked = true;
const otherFiletypeBoxes = document.querySelectorAll('input[name=\"filetype\"]:not([value=\"audio\"])');
otherFiletypeBoxes.forEach(cb => cb.checked = false);
document.getElementById('folder').value = ''; // Reset to "Alle" document.getElementById('folder').value = ''; // Reset to "Alle"
document.getElementById('datefrom').value = ''; // Reset date from document.getElementById('datefrom').value = ''; // Reset date from
document.getElementById('dateto').value = ''; // Reset date to document.getElementById('dateto').value = ''; // Reset date to
@ -164,6 +205,30 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
function attachSearchFolderButtons() {
document.querySelectorAll('.folder-open-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const folder = btn.dataset.folder;
const file = btn.dataset.file;
openFolderAndHighlight(folder, file);
});
});
}
function openFolderAndHighlight(folderPath, filePath) {
const targetFolder = folderPath || '';
// Switch back to main view before loading folder
viewMain();
loadDirectory(targetFolder).then(() => {
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
if (target) {
target.classList.add('search-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
function syncThemeColor() { function syncThemeColor() {
// read the CSS variable from :root (or any selector) // read the CSS variable from :root (or any selector)
const cssVar = getComputedStyle(document.documentElement) const cssVar = getComputedStyle(document.documentElement)

View File

@ -1,4 +1,4 @@
const CACHE_NAME = 'gottesdienste-app'; const CACHE_NAME = 'gottesdienste-app-v1.2';
const ASSETS = [ const ASSETS = [
'/', '/',
'/static/app.css', '/static/app.css',

View File

@ -126,6 +126,32 @@
<hr> <hr>
<!-- Dateityp -->
<div class="mb-3">
<label class="form-label">Dateityp:</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-audio" value="audio" checked>
<label class="form-check-label" for="filetype-audio">Audio</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-video" value="video">
<label class="form-check-label" for="filetype-video">Video</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
<label class="form-check-label" for="filetype-image">Bild</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
<label class="form-check-label" for="filetype-other">Sonstige</label>
</div>
</div>
<small class="text-muted">Mehrfachauswahl möglich!</small>
</div>
<hr>
<!-- Transkript durchsuchen --> <!-- Transkript durchsuchen -->
<div class="form-check mb-3"> <div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript"> <input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript">
@ -161,6 +187,18 @@
<!-- Global Audio Player in Footer --> <!-- Global Audio Player in Footer -->
<footer> <footer>
<div class="audio-player-container" id="audioPlayerContainer"> <div class="audio-player-container" id="audioPlayerContainer">
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
<span class="icon-collapse" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 12h14" />
</svg>
</span>
<span class="icon-expand" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
</button>
<div class="audio-player"> <div class="audio-player">
<audio id="globalAudio" prefetch="auto"> <audio id="globalAudio" prefetch="auto">
Your browser does not support the audio element. Your browser does not support the audio element.

View File

@ -7,6 +7,6 @@
{# page content #} {# page content #}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="alert alert-warning">Du hast keine gültige Freigaben.<br>Bitte Ordner mit einem Freigabelink freischalten.</div> <div class="alert alert-warning">Du hast keine gültige Freigaben.<br>Bitte über einen Freigabelink oder QR-Code freischalten.</div>
</div> </div>
{% endblock %} {% endblock %}