fix: Zeitsteuerung reparieren und vervollstaendigen

Der vorige Commit 26efed8 war nicht lauffaehig - fehlgeschlagene
Patch-Anwendungen hatten Zeilen aus ihren Bloecken geschoben.

Reparaturen:
- admin(): zerschossene Playlist-Schleife neu aufgebaut
- player(): return render_template war in den else-Zweig gerutscht,
  wodurch die View bei aktiver Priority-Playlist None lieferte (HTTP 500)
- playlist_item_enabled() existierte nicht mehr (umbenannt zu
  is_item_active), zwei Aufrufe im Player blieben stehen -> NameError
- update_playlist_item(): Absturz bei new_url=null, und der
  enabled-Status ging beim Bearbeiten verloren
- Dateityp-Erkennung mit else-Fallback, ftype konnte ungebunden bleiben

Zeitsteuerung:
- Zeitfenster ueber Mitternacht (22:00-02:00) funktionieren; bei weekly
  gehoert ein solches Fenster zum Wochentag seines Starts
- Optionaler Gueltigkeitszeitraum (valid_from/valid_until) fuer daily
  und weekly, beide Grenzen einzeln optional und inklusiv
- daily/weekly speichern nur noch die Uhrzeit ("08:00") statt eines
  Zeitstempels mit bedeutungslosem Datumsanteil; _schedule_time() liest
  weiterhin auch Altbestaende mit vollem ISO-Zeitstempel
- playlist_hash() bezieht den ausgewerteten Aktiv-Zustand ein, sonst
  bemerkt der Player ein ablaufendes Zeitfenster erst beim naechsten
  Reload aus anderem Grund
- Zeitsteuerung auch fuer Datei-Eintraege, nicht nur URLs
- Neuer Filter schedule_text fuer den Tooltip am "Zeit"-Badge

Oberflaeche:
- required lag auf ausgeblendeten Feldern und blockierte das Absenden
  des Formulars "URL hinzufuegen" ohne sichtbare Meldung
- updateScheduleDaysVisibility() war innerhalb von DOMContentLoaded
  definiert, der Edit-Handler darueber -> ReferenceError bei jedem Klick
- data-schedule mit tojson zerlegte das Attribut, da tojson " nicht
  escaped
- Zeitsteuerungs-Block als schlichte Formularzeilen statt als card, die
  global den roten Rand und grauen Header erhaelt
- Zeitsteuerung auch in der Priority-Playlist

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-08-29 19:42:03 +02:00
parent 26efed87a5
commit f540458b74
3 changed files with 788 additions and 199 deletions

351
app.py
View File

@@ -22,7 +22,7 @@ import pyotp
import qrcode import qrcode
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
from pathlib import Path from pathlib import Path
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -217,52 +217,164 @@ def playlist_item_name(item):
return "" return ""
def _window_start_date(now, start_time, end_time):
"""Ermittelt, an welchem Kalendertag das gerade laufende Tageszeitfenster begonnen hat.
Gibt None zurück, wenn die aktuelle Uhrzeit ausserhalb des Fensters liegt.
Fenster über Mitternacht (start > end) gehören zum Tag ihres Starts:
um 01:00 läuft noch das Fenster, das gestern um 22:00 begonnen hat."""
current_time = now.time()
if start_time <= end_time:
if start_time <= current_time <= end_time:
return now.date()
return None
# Fenster über Mitternacht, z. B. 22:00–02:00
if current_time >= start_time:
return now.date()
if current_time <= end_time:
return (now - timedelta(days=1)).date()
return None
def _schedule_time(value):
"""Liest die Uhrzeit aus einem Schedule-Feld.
Akzeptiert "HH:MM" (aktuelles Format für daily/weekly) und vollständige
ISO-Zeitstempel aus Altbeständen, bei denen der Datumsanteil ohnehin
ignoriert wurde. Gibt None zurück, wenn nichts Lesbares drinsteht."""
if not value:
return None
text = str(value).strip().replace(" ", "T")
if "T" in text:
try:
return datetime.fromisoformat(text).time()
except (ValueError, TypeError):
return None
for fmt in ("%H:%M", "%H:%M:%S"):
try:
return datetime.strptime(text, fmt).time()
except ValueError:
continue
return None
def _in_validity_range(schedule, day):
"""Prüft den optionalen Gültigkeitszeitraum (valid_from / valid_until) eines
Schedules gegen den Tag, an dem das Zeitfenster begonnen hat.
Beide Grenzen sind optional und inklusiv; unlesbare Werte werden ignoriert."""
for key, is_lower_bound in (("valid_from", True), ("valid_until", False)):
value = schedule.get(key)
if not value:
continue
try:
bound = date.fromisoformat(str(value)[:10])
except (ValueError, TypeError):
continue
if is_lower_bound and day < bound:
return False
if not is_lower_bound and day > bound:
return False
return True
@app.template_filter("schedule_text")
def schedule_text(schedule):
"""Kurzbeschreibung einer Zeitsteuerung für Tooltips in der Playlist."""
if not schedule:
return ""
start = str(schedule.get("start", ""))
end = str(schedule.get("end", ""))
mode = schedule.get("mode", "once")
def clock(value):
parsed = _schedule_time(value)
return parsed.strftime("%H:%M") if parsed else value
if mode == "once":
text = f"Einmalig: {start.replace('T', ' ')} bis {end.replace('T', ' ')}"
elif mode == "daily":
text = f"Täglich {clock(start)}–{clock(end)}"
elif mode == "weekly":
names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
days = []
for d in schedule.get("days", []):
try:
days.append(names[int(d)])
except (ValueError, TypeError, IndexError):
continue
day_text = ", ".join(days) if days else "kein Tag ausgewählt"
text = f"{day_text}: {clock(start)}–{clock(end)}"
else:
return ""
valid_from = schedule.get("valid_from")
valid_until = schedule.get("valid_until")
if valid_from and valid_until:
text += f" (vom {valid_from} bis {valid_until})"
elif valid_from:
text += f" (ab {valid_from})"
elif valid_until:
text += f" (bis {valid_until})"
return text
def is_item_active(item): def is_item_active(item):
"""Prüft, ob ein Playlist-Eintrag aktiviert und zeitlich gültig ist. """Prüft, ob ein Playlist-Eintrag aktiviert und zeitlich gültig ist.
Berücksichtigt enabled-Status und optionale Zeitsteuerung.""" Berücksichtigt enabled-Status und optionale Zeitsteuerung.
Zeitfenster dürfen über Mitternacht laufen (start > end)."""
now = datetime.now() now = datetime.now()
# Standardmäßig aktiv, wenn kein enabled-Status gesetzt ist # Strings haben keinen enabled-Status und keine Zeitsteuerung
if isinstance(item, dict): if not isinstance(item, dict):
if not item.get("enabled", True): return True
if not item.get("enabled", True):
return False
schedule = item.get("schedule")
if not schedule:
return True
start_str = schedule.get("start")
end_str = schedule.get("end")
if not start_str or not end_str:
return True
mode = schedule.get("mode", "once")
# "Einmalig" braucht vollständige Zeitstempel, daily/weekly nur die Uhrzeit.
# Unlesbare Angaben blenden den Eintrag nicht aus - ein Konfigfehler soll
# nicht dazu führen, dass stillschweigend nichts mehr läuft.
if mode == "once":
try:
return datetime.fromisoformat(start_str) <= now <= datetime.fromisoformat(end_str)
except (ValueError, TypeError):
return True
if mode in ("daily", "weekly"):
start_time = _schedule_time(start_str)
end_time = _schedule_time(end_str)
if start_time is None or end_time is None:
return True
day = _window_start_date(now, start_time, end_time)
if day is None:
return False return False
if not _in_validity_range(schedule, day):
return False
if mode == "daily":
# Bei "täglich" zählt jeder Wochentag - days wird nicht ausgewertet
return True
days = schedule.get("days", [])
return str(day.weekday()) in {str(d) for d in days}
# Zeitsteuerung prüfen # Unbekannter Modus: wie ohne Zeitsteuerung behandeln
schedule = item.get("schedule") return True
if schedule:
start_str = schedule.get("start")
end_str = schedule.get("end")
if not start_str or not end_str:
return item.get("enabled", True)
try:
start = datetime.fromisoformat(start_str)
end = datetime.fromisoformat(end_str)
mode = schedule.get("mode", "once")
if mode == "once":
return start <= now <= end
elif mode == "daily":
current_time = now.time()
start_time = start.time()
end_time = end.time()
return start_time <= current_time <= end_time
elif mode == "weekly":
days = schedule.get("days", [])
current_weekday = now.weekday() # 0=Montag, 6=Sonntag
current_time = now.time()
start_time = start.time()
end_time = end.time()
return (str(current_weekday) in days or
f"{current_weekday}".lower()[:3] in [d.lower() for d in days]) and \
start_time <= current_time <= end_time
except (ValueError, TypeError):
pass
# Für Strings oder keine Zeitsteuerung: nur enabled prüfen
return playlist_item_enabled(item)
# ------------------------------------------------- # -------------------------------------------------
@@ -282,33 +394,39 @@ def load_priority_files():
# 1. Einträge aus der config.json-Playlist # 1. Einträge aus der config.json-Playlist
for item in prio_playlist: for item in prio_playlist:
item_name = playlist_item_name(item) item_name = playlist_item_name(item)
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
priority_files.append({ priority_files.append({
"name": url_data["url"], "name": url_data["url"],
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": is_item_active(item), "enabled": is_item_active(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
"schedule": item.get("schedule") "schedule": item.get("schedule") if isinstance(item, dict) else None
}) })
continue continue
file_path = os.path.join(prio_dir, item_name) file_path = os.path.join(prio_dir, item_name)
if not os.path.exists(file_path): if not os.path.exists(file_path):
continue continue
ext = os.path.splitext(item_name)[1].lower() ext = os.path.splitext(item_name)[1].lower()
ftype = "video" if ext == ".mp4" else "image" if ext == ".mp4":
ftype = "video"
elif ext == ".html":
ftype = "html"
else:
ftype = "image"
size = os.path.getsize(file_path) // 1024 size = os.path.getsize(file_path) // 1024
priority_files.append({ priority_files.append({
"name": item_name, "name": item_name,
"type": ftype, "type": ftype,
"size": size, "size": size,
"enabled": playlist_item_enabled(item) "enabled": is_item_active(item),
"schedule": item.get("schedule") if isinstance(item, dict) else None
}) })
# 2. Neue Dateien aus dem Ordner, die noch nicht in der Playlist sind # 2. Neue Dateien aus dem Ordner, die noch nicht in der Playlist sind
@@ -325,7 +443,8 @@ def load_priority_files():
priority_files.append({ priority_files.append({
"name": f, "name": f,
"type": ftype, "type": ftype,
"size": size "size": size,
"schedule": None
}) })
return priority_files return priority_files
@@ -1014,13 +1133,13 @@ def player(site, screen):
# 1. Playlist-Reihenfolge aus config.json # 1. Playlist-Reihenfolge aus config.json
for item in playlist: for item in playlist:
if not playlist_item_enabled(item): if not is_item_active(item):
continue continue
item_name = playlist_item_name(item) item_name = playlist_item_name(item)
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule")}) normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
continue continue
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name): if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
@@ -1044,13 +1163,13 @@ def player(site, screen):
if priority_cfg.get("enabled", False): if priority_cfg.get("enabled", False):
prio_files = [] prio_files = []
for item in priority_cfg.get("playlist", []): for item in priority_cfg.get("playlist", []):
if not playlist_item_enabled(item): if not is_item_active(item):
continue continue
item_name = playlist_item_name(item) item_name = playlist_item_name(item)
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule")}) prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
continue continue
file_path = os.path.join(MEDIA_DIR, "priority", item_name) file_path = os.path.join(MEDIA_DIR, "priority", item_name)
@@ -1059,30 +1178,30 @@ def player(site, screen):
else: else:
prio_files = [] prio_files = []
return render_template( return render_template(
"player.html", "player.html",
site=site, site=site,
screen=screen, screen=screen,
normal_files=[item for item in normal_files if item.get("enabled", True)], normal_files=[item for item in normal_files if item.get("enabled", True)],
prio_files=[item for item in prio_files if item.get("enabled", True)], prio_files=[item for item in prio_files if item.get("enabled", True)],
interval=screen_cfg.get("interval", 10), interval=screen_cfg.get("interval", 10),
newsticker_text=screen_cfg.get("newsticker_text", ""), newsticker_text=screen_cfg.get("newsticker_text", ""),
newsticker_enabled=screen_cfg.get("newsticker_enabled", False), newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
custom_url=screen_cfg.get("custom_url", ""), custom_url=screen_cfg.get("custom_url", ""),
custom_url_label=screen_cfg.get("custom_url_label", ""), custom_url_label=screen_cfg.get("custom_url_label", ""),
custom_url_enabled=screen_cfg.get("custom_url_enabled", False), custom_url_enabled=screen_cfg.get("custom_url_enabled", False),
custom_url_target=screen_cfg.get("custom_url_target", "overlay"), custom_url_target=screen_cfg.get("custom_url_target", "overlay"),
custom_url_position=screen_cfg.get("custom_url_position", "top-left"), custom_url_position=screen_cfg.get("custom_url_position", "top-left"),
stay_on_first=screen_cfg.get("stay_on_first", False), stay_on_first=screen_cfg.get("stay_on_first", False),
voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False), voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False),
voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False, voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False,
voice_agent_url=config.get("voice_agent_url", ""), voice_agent_url=config.get("voice_agent_url", ""),
voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"), voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"), voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"),
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"), voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"),
player_params=dict(request.args), player_params=dict(request.args),
now=datetime.now().isoformat() now=datetime.now().isoformat()
) )
# ------------------------------------------------- # -------------------------------------------------
@@ -1103,11 +1222,21 @@ def playlist_hash(site, screen):
record_heartbeat(site, screen, ip=ip, ua=ua, resolution=resolution) record_heartbeat(site, screen, ip=ip, ua=ua, resolution=resolution)
config = load_config() config = load_config()
screen_playlist = (
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
)
prio_cfg = config.get("priority", {})
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
relevant = { relevant = {
"sites": { "sites": {
site: config.get("sites", {}).get(site, {}) site: config.get("sites", {}).get(site, {})
}, },
"priority": config.get("priority", {}) "priority": prio_cfg,
# Aktueller Zeitsteuerungs-Zustand: sonst würde der Player ein
# ablaufendes Zeitfenster erst beim nächsten Reload bemerken.
"active": [is_item_active(item) for item in screen_playlist]
+ [is_item_active(item) for item in prio_playlist]
} }
blob = json.dumps(relevant, sort_keys=True).encode() blob = json.dumps(relevant, sort_keys=True).encode()
@@ -1232,6 +1361,7 @@ def admin(site):
# 1. Playlist-Einträge in Config-Reihenfolge # 1. Playlist-Einträge in Config-Reihenfolge
for item in playlist: for item in playlist:
item_name = playlist_item_name(item) item_name = playlist_item_name(item)
schedule = item.get("schedule") if isinstance(item, dict) else None
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
@@ -1243,7 +1373,7 @@ def admin(site):
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": is_item_active(item), "enabled": is_item_active(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
"schedule": item.get("schedule") "schedule": schedule
}) })
continue continue
@@ -1254,10 +1384,10 @@ def admin(site):
ext = os.path.splitext(item_name)[1].lower() ext = os.path.splitext(item_name)[1].lower()
if ext == ".mp4": if ext == ".mp4":
ftype = "video" ftype = "video"
if ext in (".jpg", ".jpeg", ".png"): elif ext == ".html":
ftype = "image"
if ext == ".html":
ftype = "html" ftype = "html"
else:
ftype = "image"
size = os.path.getsize(file_path) // 1024 size = os.path.getsize(file_path) // 1024
@@ -1266,7 +1396,7 @@ def admin(site):
"type": ftype, "type": ftype,
"size": size, "size": size,
"enabled": is_item_active(item), "enabled": is_item_active(item),
"schedule": item.get("schedule") "schedule": schedule
}) })
# 2. Neue Dateien (ohne Playlist-Eintrag) anhängen # 2. Neue Dateien (ohne Playlist-Eintrag) anhängen
@@ -1283,7 +1413,8 @@ def admin(site):
files.append({ files.append({
"name": f, "name": f,
"type": ftype, "type": ftype,
"size": size "size": size,
"schedule": None
}) })
screens[screen_name] = screen_cfg screens[screen_name] = screen_cfg
@@ -1536,8 +1667,14 @@ def add_url(site, screen):
if schedule_enabled: if schedule_enabled:
mode = request.form.get("schedule_mode", "once") mode = request.form.get("schedule_mode", "once")
start = request.form.get("schedule_start")
end = request.form.get("schedule_end") # "Einmalig" braucht Datum + Uhrzeit, daily/weekly nur die Uhrzeit
if mode in ("daily", "weekly"):
start = request.form.get("schedule_start_time")
end = request.form.get("schedule_end_time")
else:
start = request.form.get("schedule_start")
end = request.form.get("schedule_end")
if not start or not end: if not start or not end:
flash("Start- und Endzeit müssen angegeben werden", "error") flash("Start- und Endzeit müssen angegeben werden", "error")
@@ -1553,6 +1690,15 @@ def add_url(site, screen):
days = request.form.getlist("schedule_days") days = request.form.getlist("schedule_days")
schedule["days"] = days schedule["days"] = days
# Optionaler Gültigkeitszeitraum (nur bei daily/weekly sinnvoll)
if mode in ("daily", "weekly"):
valid_from = (request.form.get("schedule_valid_from") or "").strip()
valid_until = (request.form.get("schedule_valid_until") or "").strip()
if valid_from:
schedule["valid_from"] = valid_from
if valid_until:
schedule["valid_until"] = valid_until
url_data = {"url": url, "zoom": zoom_factor} url_data = {"url": url, "zoom": zoom_factor}
if schedule: if schedule:
url_data["schedule"] = schedule url_data["schedule"] = schedule
@@ -1654,12 +1800,16 @@ def update_playlist_item(site, screen):
"""Aktualisiert URL, Zoom und Zeitsteuerung eines einzelnen Playlist-Items.""" """Aktualisiert URL, Zoom und Zeitsteuerung eines einzelnen Playlist-Items."""
data = request.get_json() data = request.get_json()
old_name = data.get("old_name") old_name = data.get("old_name")
new_url = data.get("new_url", "").strip() new_url = (data.get("new_url") or "").strip()
zoom = data.get("zoom", 1.0) zoom = data.get("zoom", 1.0)
passthrough = data.get("passthrough") passthrough = data.get("passthrough")
schedule = data.get("schedule") # Kann None, {} oder schedule-Objekt sein schedule = data.get("schedule") # Kann None, {} oder schedule-Objekt sein
if not is_url(new_url): # new_url ist nur bei URL-Einträgen gesetzt; bei Dateien wird ausschliesslich
# die Zeitsteuerung bearbeitet und der Dateiname bleibt unverändert.
is_url_item = bool(new_url)
if is_url_item and not is_url(new_url):
return jsonify({"error": "Ungültige URL"}), 400 return jsonify({"error": "Ungültige URL"}), 400
try: try:
@@ -1679,7 +1829,14 @@ def update_playlist_item(site, screen):
for i, item in enumerate(playlist): for i, item in enumerate(playlist):
name = playlist_item_name(item) name = playlist_item_name(item)
if name == old_name: if name == old_name:
updated = {"url": new_url, "zoom": zoom} if is_url_item:
updated = {"url": new_url, "zoom": zoom}
else:
updated = {"name": old_name}
# enabled-Status des bestehenden Eintrags nicht verlieren
if isinstance(item, dict) and "enabled" in item:
updated["enabled"] = item["enabled"]
# Passthrough beibehalten oder aktualisieren # Passthrough beibehalten oder aktualisieren
if passthrough is not None: if passthrough is not None:

View File

@@ -240,21 +240,19 @@
{% if file.enabled %}checked{% endif %} {% if file.enabled %}checked{% endif %}
title="Element aktivieren"> title="Element aktivieren">
<!-- Dateiname (mit Tooltip-Vorschau) --> <!-- Dateiname (mit Tooltip-Vorschau) -->
<span class="playlist-name" <span class="playlist-name"
{% if file.type == "image" %} {% if file.type == "image" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
title="<img src='/media/{{ current_site }}/{{ screen }}/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>" title="<img src='/media/{{ current_site }}/{{ screen }}/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
{% elif file.type == "url" %} {% else %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"{% if file.schedule %} title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
data-bs-custom-class="schedule-tooltip" {% endif %}>
data-bs-title="Zeitsteuerung: {% if file.schedule.mode == 'once' %}Einmalig: {{ file.schedule.start }} bis {{ file.schedule.end }}{% elif file.schedule.mode == 'daily' %}Täglich: {{ file.schedule.start.split('T')[1][:5] }} bis {{ file.schedule.end.split('T')[1][:5] }}{% elif file.schedule.mode == 'weekly' %}Wöchentlich: {{ file.schedule.start.split('T')[1][:5] }} bis {{ file.schedule.end.split('T')[1][:5] }} an {% for day in file.schedule.days %}{{ ['Mo','Di','Mi','Do','Fr','Sa','So'][int(day)] }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}"
{% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge bg-info text-white ms-1">Zeit</span>{% endif %} {% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
</span> </span>
<!-- Typ-Badge --> <!-- Typ-Badge -->
{% if file.type == "image" %} {% if file.type == "image" %}
@@ -284,23 +282,20 @@
</button> </button>
</form> </form>
<!-- Bearbeiten (nur URLs) --> <!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %} <button type="button"
<button type="button" class="btn btn-outline-secondary btn-sm edit-url-btn"
class="btn btn-outline-secondary btn-sm edit-url-btn" title="Bearbeiten"
title="URL bearbeiten" data-name="{{ file.name | e }}"
data-name="{{ file.name | e }}" data-type="{{ file.type }}"
data-zoom="{{ file.zoom | default(1.0) }}" data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}" data-site="{{ current_site }}"
data-screen="{{ screen }}" data-screen="{{ screen }}"
data-oldname="{{ file.name | e }}" data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}" data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"
data-schedule="{{ file.schedule | tojson }}"> data-schedule='{{ file.schedule | tojson }}'>
Edit Edit
</button> </button>
{% else %}
<span></span>
{% endif %}
<!-- Drag-Handle (SortableJS) --> <!-- Drag-Handle (SortableJS) -->
<span class="drag-handle" title="Reihenfolge ändern">⇅</span> <span class="drag-handle" title="Reihenfolge ändern">⇅</span>
@@ -498,16 +493,15 @@
</div> </div>
<!-- Zeitsteuerung --> <!-- Zeitsteuerung -->
<div class="card mb-3"> <hr class="mt-4 mb-3">
<div class="card-header"> <h3>Zeitsteuerung</h3>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="schedule-enabled-{{ screen }}" name="schedule_enabled"> <div class="mb-2 form-check">
<label class="form-check-label" for="schedule-enabled-{{ screen }}"> <input class="form-check-input" type="checkbox" id="schedule-enabled-{{ screen }}" name="schedule_enabled">
Zeitgesteuerte Wiedergabe <label class="form-check-label" for="schedule-enabled-{{ screen }}">Zeitgesteuerte Wiedergabe</label>
</label> </div>
</div>
</div> <div class="mb-3" id="schedule-options-{{ screen }}" style="display: none;">
<div class="card-body" id="schedule-options-{{ screen }}" style="display: none;">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Wiederholung</label> <label class="form-label">Wiederholung</label>
@@ -518,17 +512,43 @@
</select> </select>
</div> </div>
<div class="row mb-3"> <!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="schedule-datetime-{{ screen }}">
<div class="col"> <div class="col">
<label class="form-label">Start</label> <label class="form-label">Start</label>
<input type="datetime-local" class="form-control" name="schedule_start" required> <input type="datetime-local" class="form-control" name="schedule_start">
</div> </div>
<div class="col"> <div class="col">
<label class="form-label">Ende</label> <label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" name="schedule_end" required> <input type="datetime-local" class="form-control" name="schedule_end">
</div> </div>
</div> </div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="schedule-time-{{ screen }}" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" name="schedule_start_time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" name="schedule_end_time">
</div>
</div>
<div class="mb-3" id="schedule-validity-{{ screen }}" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" name="schedule_valid_from">
</div>
<div class="col">
<input type="date" class="form-control" name="schedule_valid_until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="schedule-days-{{ screen }}" style="display: none;"> <div class="mb-3" id="schedule-days-{{ screen }}" style="display: none;">
<label class="form-label">Wochentage</label> <label class="form-label">Wochentage</label>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -562,7 +582,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<button class="btn btn-primary" type="submit">URL hinzufügen</button> <button class="btn btn-primary" type="submit">URL hinzufügen</button>
@@ -613,8 +632,39 @@
</div> <!-- /page --> </div> <!-- /page -->
<script> <script>
/* ─── Playlist-Item bearbeiten (URL + Zoom) ─── */ /* ─── Playlist-Item bearbeiten (URL + Zoom + Zeitsteuerung) ─── */
var editState = { site: "", screen: "", oldName: "" }; var editState = { site: "", screen: "", oldName: "", type: "url" };
/* Zeitsteuerung: Wochentage nur im Modus "weekly" zeigen.
Muss auf Top-Level stehen - der Edit-Handler unten läuft ausserhalb
von DOMContentLoaded und würde die Funktion sonst nicht finden. */
/* Uhrzeit aus "HH:MM" oder einem vollen ISO-Zeitstempel herausziehen */
function scheduleClock(value) {
if (!value) return "";
var text = String(value).replace(" ", "T");
var idx = text.indexOf("T");
if (idx !== -1) text = text.substring(idx + 1);
return text.substring(0, 5);
}
function updateScheduleDaysVisibility() {
var modeEl = document.getElementById("edit-schedule-mode");
if (!modeEl) return;
var wiederkehrend = modeEl.value === "daily" || modeEl.value === "weekly";
var daysEl = document.getElementById("edit-schedule-days");
var validityEl = document.getElementById("edit-schedule-validity");
var datetimeEl = document.getElementById("edit-schedule-datetime");
var timeEl = document.getElementById("edit-schedule-time");
// Wochentage nur bei "wöchentlich" - bei "täglich" gilt ohnehin jeder Tag
if (daysEl) daysEl.style.display = modeEl.value === "weekly" ? "" : "none";
// Gültigkeitszeitraum ergänzt die Tageszeit; bei "einmalig" steckt das
// Datum bereits in Start und Ende.
if (validityEl) validityEl.style.display = wiederkehrend ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */ /* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
@@ -624,10 +674,21 @@ var editState = { site: "", screen: "", oldName: "" };
editState.site = btn.dataset.site; editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen; editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname; editState.oldName = btn.dataset.oldname;
editState.type = btn.dataset.type || "url";
var isUrl = editState.type === "url";
document.getElementById("edit-url-input").value = btn.dataset.oldname; document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom; document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true"; document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
/* Bei Dateien gibt es keine URL, keinen Zoom und kein Passthrough -
dann bleibt nur die Zeitsteuerung übrig. */
document.getElementById("edit-url-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-zoom-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-passthrough-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-url-modal-title").textContent =
isUrl ? "URL bearbeiten" : "Zeitsteuerung: " + btn.dataset.oldname;
// Zeitsteuerung aus Daten setzen // Zeitsteuerung aus Daten setzen
const schedule = JSON.parse(btn.dataset.schedule || 'null'); const schedule = JSON.parse(btn.dataset.schedule || 'null');
const enabledEl = document.getElementById("edit-schedule-enabled"); const enabledEl = document.getElementById("edit-schedule-enabled");
@@ -637,8 +698,13 @@ var editState = { site: "", screen: "", oldName: "" };
enabledEl.checked = true; enabledEl.checked = true;
optionsEl.style.display = ""; optionsEl.style.display = "";
document.getElementById("edit-schedule-mode").value = schedule.mode || "once"; document.getElementById("edit-schedule-mode").value = schedule.mode || "once";
document.getElementById("edit-schedule-start").value = schedule.start || ""; var wiederkehrend = (schedule.mode === "daily" || schedule.mode === "weekly");
document.getElementById("edit-schedule-end").value = schedule.end || ""; document.getElementById("edit-schedule-start").value = wiederkehrend ? "" : (schedule.start || "");
document.getElementById("edit-schedule-end").value = wiederkehrend ? "" : (schedule.end || "");
document.getElementById("edit-schedule-start-time").value = scheduleClock(schedule.start);
document.getElementById("edit-schedule-end-time").value = scheduleClock(schedule.end);
document.getElementById("edit-schedule-valid-from").value = schedule.valid_from || "";
document.getElementById("edit-schedule-valid-until").value = schedule.valid_until || "";
// Wochentage setzen // Wochentage setzen
const days = schedule.days || []; const days = schedule.days || [];
@@ -655,6 +721,10 @@ var editState = { site: "", screen: "", oldName: "" };
} else { } else {
enabledEl.checked = false; enabledEl.checked = false;
optionsEl.style.display = "none"; optionsEl.style.display = "none";
document.getElementById("edit-schedule-valid-from").value = "";
document.getElementById("edit-schedule-valid-until").value = "";
document.getElementById("edit-schedule-start-time").value = "";
document.getElementById("edit-schedule-end-time").value = "";
updateScheduleDaysVisibility(); updateScheduleDaysVisibility();
} }
@@ -715,13 +785,6 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Zeitsteuerung: Anzeige der Wochentage entsprechend Modus ─── */
function updateScheduleDaysVisibility() {
const mode = document.getElementById("edit-schedule-mode").value;
const daysEl = document.getElementById("edit-schedule-days");
daysEl.style.display = mode === "weekly" ? "" : "none";
}
/* ─── Zeitsteuerung: Änderungs-Events verarbeiten ─── */ /* ─── Zeitsteuerung: Änderungs-Events verarbeiten ─── */
document.getElementById("edit-schedule-enabled").addEventListener("change", function() { document.getElementById("edit-schedule-enabled").addEventListener("change", function() {
document.getElementById("edit-schedule-options").style.display = this.checked ? "" : "none"; document.getElementById("edit-schedule-options").style.display = this.checked ? "" : "none";
@@ -734,13 +797,40 @@ document.addEventListener("DOMContentLoaded", function () {
// Initiale Anzeige // Initiale Anzeige
updateScheduleDaysVisibility(); updateScheduleDaysVisibility();
/* ─── "URL hinzufügen"-Formular: Zeitsteuerungs-Felder ein-/ausblenden ───
Läuft pro Screen, die IDs sind mit dem Screen-Namen suffigiert. */
document.querySelectorAll('[id^="schedule-enabled-"]').forEach(function (cb) {
var suffix = cb.id.substring("schedule-enabled-".length);
var optionsEl = document.getElementById("schedule-options-" + suffix);
var daysEl = document.getElementById("schedule-days-" + suffix);
var validityEl = document.getElementById("schedule-validity-" + suffix);
var datetimeEl = document.getElementById("schedule-datetime-" + suffix);
var timeEl = document.getElementById("schedule-time-" + suffix);
var modeEl = cb.closest("form") ? cb.closest("form").querySelector('[name="schedule_mode"]') : null;
function sync() {
var mode = modeEl ? modeEl.value : "once";
var wiederkehrend = (mode === "daily" || mode === "weekly");
if (optionsEl) optionsEl.style.display = cb.checked ? "" : "none";
if (daysEl) daysEl.style.display = (cb.checked && mode === "weekly") ? "" : "none";
if (validityEl) validityEl.style.display = (cb.checked && wiederkehrend) ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
cb.addEventListener("change", sync);
if (modeEl) modeEl.addEventListener("change", sync);
sync();
});
/* ─── Edit-URL-Modal: Speichern-Button ─── */ /* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () { document.getElementById("edit-url-save").addEventListener("click", function () {
const isUrl = editState.type === "url";
const newUrl = document.getElementById("edit-url-input").value.trim(); const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0; const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error"); const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) { if (isUrl && !newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)"; errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block"; errorEl.style.display = "block";
return; return;
@@ -754,11 +844,20 @@ document.addEventListener("DOMContentLoaded", function () {
if (scheduleEnabled) { if (scheduleEnabled) {
const mode = document.getElementById("edit-schedule-mode").value; const mode = document.getElementById("edit-schedule-mode").value;
const start = document.getElementById("edit-schedule-start").value; const wiederkehrend = (mode === "daily" || mode === "weekly");
const end = document.getElementById("edit-schedule-end").value;
// Einmalig arbeitet mit Datum + Uhrzeit, täglich/wöchentlich nur mit Uhrzeit
const start = wiederkehrend
? document.getElementById("edit-schedule-start-time").value
: document.getElementById("edit-schedule-start").value;
const end = wiederkehrend
? document.getElementById("edit-schedule-end-time").value
: document.getElementById("edit-schedule-end").value;
if (!start || !end) { if (!start || !end) {
errorEl.textContent = "Start- und Endzeit müssen angegeben werden"; errorEl.textContent = wiederkehrend
? "Von- und Bis-Uhrzeit müssen angegeben werden"
: "Start und Ende müssen angegeben werden";
errorEl.style.display = "block"; errorEl.style.display = "block";
return; return;
} }
@@ -769,6 +868,18 @@ document.addEventListener("DOMContentLoaded", function () {
end: end end: end
}; };
if (mode === "daily" || mode === "weekly") {
const validFrom = document.getElementById("edit-schedule-valid-from").value;
const validUntil = document.getElementById("edit-schedule-valid-until").value;
if (validFrom && validUntil && validFrom > validUntil) {
errorEl.textContent = "\"Gültig von\" liegt nach \"Gültig bis\"";
errorEl.style.display = "block";
return;
}
if (validFrom) schedule.valid_from = validFrom;
if (validUntil) schedule.valid_until = validUntil;
}
if (mode === "weekly") { if (mode === "weekly") {
const days = []; const days = [];
if (document.getElementById("edit-mon").checked) days.push("0"); if (document.getElementById("edit-mon").checked) days.push("0");
@@ -787,10 +898,10 @@ document.addEventListener("DOMContentLoaded", function () {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
old_name: editState.oldName, old_name: editState.oldName,
new_url: newUrl, new_url: isUrl ? newUrl : null,
zoom: zoom, zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked, passthrough: isUrl ? document.getElementById("edit-passthrough-input").checked : null,
schedule: schedule schedule: schedule || {}
}) })
}).then(function (r) { }).then(function (r) {
if (r.ok) { if (r.ok) {
@@ -815,34 +926,30 @@ document.addEventListener("DOMContentLoaded", function () {
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5> <h5 class="modal-title" id="edit-url-modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3" id="edit-url-row">
<label class="form-label">URL</label> <label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input"> <input type="url" class="form-control" id="edit-url-input">
</div> </div>
<div class="mb-3"> <div class="mb-3" id="edit-zoom-row">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0"> <input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check" id="edit-passthrough-row">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input"> <input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label> <label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div> </div>
<!-- Zeitsteuerung --> <!-- Zeitsteuerung: gleiche schlichte Zeilen wie der Rest des Dialogs -->
<div class="card"> <div class="mb-3 form-check">
<div class="card-header"> <input class="form-check-input" type="checkbox" id="edit-schedule-enabled">
<div class="form-check"> <label class="form-check-label" for="edit-schedule-enabled">Zeitgesteuerte Wiedergabe</label>
<input class="form-check-input" type="checkbox" id="edit-schedule-enabled"> </div>
<label class="form-check-label" for="edit-schedule-enabled">
Zeitgesteuerte Wiedergabe <div id="edit-schedule-options" style="display: none;">
</label>
</div>
</div>
<div class="card-body" id="edit-schedule-options" style="display: none;">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Wiederholung</label> <label class="form-label">Wiederholung</label>
@@ -853,7 +960,8 @@ document.addEventListener("DOMContentLoaded", function () {
</select> </select>
</div> </div>
<div class="row mb-3"> <!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="edit-schedule-datetime">
<div class="col"> <div class="col">
<label class="form-label">Start</label> <label class="form-label">Start</label>
<input type="datetime-local" class="form-control" id="edit-schedule-start"> <input type="datetime-local" class="form-control" id="edit-schedule-start">
@@ -864,6 +972,31 @@ document.addEventListener("DOMContentLoaded", function () {
</div> </div>
</div> </div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="edit-schedule-time" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" id="edit-schedule-start-time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" id="edit-schedule-end-time">
</div>
</div>
<div class="mb-3" id="edit-schedule-validity" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-from">
</div>
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="edit-schedule-days" style="display: none;"> <div class="mb-3" id="edit-schedule-days" style="display: none;">
<label class="form-label">Wochentage</label> <label class="form-label">Wochentage</label>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -897,7 +1030,6 @@ document.addEventListener("DOMContentLoaded", function () {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div> <div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>

View File

@@ -153,6 +153,7 @@
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
</span> </span>
{% if file.type == "image" %} {% if file.type == "image" %}
@@ -183,22 +184,20 @@
</button> </button>
</form> </form>
<!-- Bearbeiten (nur URLs) --> <!-- Bearbeiten -->
{% if file.type == "url" %}
<button type="button" <button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn" class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten" title="Bearbeiten"
data-name="{{ file.name | e }}" data-name="{{ file.name | e }}"
data-type="{{ file.type }}"
data-zoom="{{ file.zoom | default(1.0) }}" data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}" data-site="{{ current_site }}"
data-screen="priority" data-screen="priority"
data-oldname="{{ file.name | e }}" data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"> data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"
data-schedule='{{ file.schedule | tojson }}'>
Edit Edit
</button> </button>
{% else %}
<span></span>
{% endif %}
<!-- Drag --> <!-- Drag -->
<span class="drag-handle" title="Reihenfolge ändern">⇅</span> <span class="drag-handle" title="Reihenfolge ändern">⇅</span>
@@ -235,6 +234,77 @@
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1"> <input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1">
</div> </div>
<!-- Zeitsteuerung -->
<hr class="mt-4 mb-3">
<h3>Zeitsteuerung</h3>
<div class="mb-2 form-check">
<input class="form-check-input" type="checkbox" id="schedule-enabled-priority" name="schedule_enabled">
<label class="form-check-label" for="schedule-enabled-priority">Zeitgesteuerte Wiedergabe</label>
</div>
<div class="mb-3" id="schedule-options-priority" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" name="schedule_mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="schedule-datetime-priority">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" name="schedule_start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" name="schedule_end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="schedule-time-priority" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" name="schedule_start_time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" name="schedule_end_time">
</div>
</div>
<div class="mb-3" id="schedule-validity-priority" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" name="schedule_valid_from">
</div>
<div class="col">
<input type="date" class="form-control" name="schedule_valid_until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="schedule-days-priority" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
{% for nr, tag in [(0, 'Mo'), (1, 'Di'), (2, 'Mi'), (3, 'Do'), (4, 'Fr'), (5, 'Sa'), (6, 'So')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="{{ nr }}" id="prio-day-{{ nr }}">
<label class="form-check-label" for="prio-day-{{ nr }}">{{ tag }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<button class="btn btn-primary" type="submit">URL hinzufügen</button> <button class="btn btn-primary" type="submit">URL hinzufügen</button>
</form> </form>
</div> </div>
@@ -254,7 +324,38 @@
<script> <script>
/* ─── Global: editState ─── */ /* ─── Global: editState ─── */
var editState = { site: "", screen: "", oldName: "" }; var editState = { site: "", screen: "", oldName: "", type: "url" };
/* Uhrzeit aus "HH:MM" oder einem vollen ISO-Zeitstempel herausziehen */
function scheduleClock(value) {
if (!value) return "";
var text = String(value).replace(" ", "T");
var idx = text.indexOf("T");
if (idx !== -1) text = text.substring(idx + 1);
return text.substring(0, 5);
}
/* Sichtbarkeit der Zeitsteuerungs-Felder im Modal an den Modus koppeln.
Muss auf Top-Level stehen - der Edit-Handler unten läuft ausserhalb
von DOMContentLoaded. */
function updateScheduleDaysVisibility() {
var modeEl = document.getElementById("edit-schedule-mode");
if (!modeEl) return;
var wiederkehrend = modeEl.value === "daily" || modeEl.value === "weekly";
var daysEl = document.getElementById("edit-schedule-days");
var validityEl = document.getElementById("edit-schedule-validity");
var datetimeEl = document.getElementById("edit-schedule-datetime");
var timeEl = document.getElementById("edit-schedule-time");
// Wochentage nur bei "wöchentlich" - bei "täglich" gilt ohnehin jeder Tag
if (daysEl) daysEl.style.display = modeEl.value === "weekly" ? "" : "none";
// Gültigkeitszeitraum ergänzt die Tageszeit; bei "einmalig" steckt das
// Datum bereits in Start und Ende.
if (validityEl) validityEl.style.display = wiederkehrend ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */ /* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
@@ -264,9 +365,57 @@ document.addEventListener("click", function (e) {
editState.site = btn.dataset.site; editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen; editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname; editState.oldName = btn.dataset.oldname;
editState.type = btn.dataset.type || "url";
var isUrl = editState.type === "url";
document.getElementById("edit-url-input").value = btn.dataset.oldname; document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom; document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true"; document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
/* Bei Dateien gibt es keine URL, keinen Zoom und kein Passthrough -
dann bleibt nur die Zeitsteuerung übrig. */
document.getElementById("edit-url-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-zoom-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-passthrough-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-url-modal-title").textContent =
isUrl ? "URL bearbeiten" : "Zeitsteuerung: " + btn.dataset.oldname;
var schedule = JSON.parse(btn.dataset.schedule || "null");
var enabledEl = document.getElementById("edit-schedule-enabled");
var optionsEl = document.getElementById("edit-schedule-options");
if (schedule) {
enabledEl.checked = true;
optionsEl.style.display = "";
document.getElementById("edit-schedule-mode").value = schedule.mode || "once";
var wiederkehrend = (schedule.mode === "daily" || schedule.mode === "weekly");
document.getElementById("edit-schedule-start").value = wiederkehrend ? "" : (schedule.start || "");
document.getElementById("edit-schedule-end").value = wiederkehrend ? "" : (schedule.end || "");
document.getElementById("edit-schedule-start-time").value = scheduleClock(schedule.start);
document.getElementById("edit-schedule-end-time").value = scheduleClock(schedule.end);
document.getElementById("edit-schedule-valid-from").value = schedule.valid_from || "";
document.getElementById("edit-schedule-valid-until").value = schedule.valid_until || "";
var days = schedule.days || [];
for (var i = 0; i < 7; i++) {
document.getElementById("edit-prio-day-" + i).checked = days.indexOf(String(i)) !== -1;
}
} else {
enabledEl.checked = false;
optionsEl.style.display = "none";
document.getElementById("edit-schedule-start").value = "";
document.getElementById("edit-schedule-end").value = "";
document.getElementById("edit-schedule-start-time").value = "";
document.getElementById("edit-schedule-end-time").value = "";
document.getElementById("edit-schedule-valid-from").value = "";
document.getElementById("edit-schedule-valid-until").value = "";
for (var j = 0; j < 7; j++) {
document.getElementById("edit-prio-day-" + j).checked = false;
}
}
updateScheduleDaysVisibility();
document.getElementById("edit-url-error").style.display = "none"; document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show(); new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
}); });
@@ -319,13 +468,48 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Zeitsteuerung: Änderungs-Events im Modal ─── */
document.getElementById("edit-schedule-enabled").addEventListener("change", function () {
document.getElementById("edit-schedule-options").style.display = this.checked ? "" : "none";
});
document.getElementById("edit-schedule-mode").addEventListener("change", updateScheduleDaysVisibility);
updateScheduleDaysVisibility();
/* ─── "URL hinzufügen"-Formular: Zeitsteuerungs-Felder ein-/ausblenden ─── */
(function () {
var cb = document.getElementById("schedule-enabled-priority");
if (!cb) return;
var optionsEl = document.getElementById("schedule-options-priority");
var daysEl = document.getElementById("schedule-days-priority");
var validityEl = document.getElementById("schedule-validity-priority");
var datetimeEl = document.getElementById("schedule-datetime-priority");
var timeEl = document.getElementById("schedule-time-priority");
var modeEl = cb.closest("form").querySelector('[name="schedule_mode"]');
function sync() {
var mode = modeEl ? modeEl.value : "once";
var wiederkehrend = (mode === "daily" || mode === "weekly");
if (optionsEl) optionsEl.style.display = cb.checked ? "" : "none";
if (daysEl) daysEl.style.display = (cb.checked && mode === "weekly") ? "" : "none";
if (validityEl) validityEl.style.display = (cb.checked && wiederkehrend) ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
cb.addEventListener("change", sync);
if (modeEl) modeEl.addEventListener("change", sync);
sync();
})();
/* ─── Edit-URL-Modal: Speichern-Button ─── */ /* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () { document.getElementById("edit-url-save").addEventListener("click", function () {
const isUrl = editState.type === "url";
const newUrl = document.getElementById("edit-url-input").value.trim(); const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0; const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error"); const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) { if (isUrl && !newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)"; errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block"; errorEl.style.display = "block";
return; return;
@@ -333,14 +517,62 @@ document.addEventListener("DOMContentLoaded", function () {
errorEl.style.display = "none"; errorEl.style.display = "none";
// Zeitsteuerung sammeln
const scheduleEnabled = document.getElementById("edit-schedule-enabled").checked;
let schedule = null;
if (scheduleEnabled) {
const mode = document.getElementById("edit-schedule-mode").value;
const wiederkehrend = (mode === "daily" || mode === "weekly");
// Einmalig arbeitet mit Datum + Uhrzeit, täglich/wöchentlich nur mit Uhrzeit
const start = wiederkehrend
? document.getElementById("edit-schedule-start-time").value
: document.getElementById("edit-schedule-start").value;
const end = wiederkehrend
? document.getElementById("edit-schedule-end-time").value
: document.getElementById("edit-schedule-end").value;
if (!start || !end) {
errorEl.textContent = wiederkehrend
? "Von- und Bis-Uhrzeit müssen angegeben werden"
: "Start und Ende müssen angegeben werden";
errorEl.style.display = "block";
return;
}
schedule = { mode: mode, start: start, end: end };
if (wiederkehrend) {
const validFrom = document.getElementById("edit-schedule-valid-from").value;
const validUntil = document.getElementById("edit-schedule-valid-until").value;
if (validFrom && validUntil && validFrom > validUntil) {
errorEl.textContent = "\"Gültig von\" liegt nach \"Gültig bis\"";
errorEl.style.display = "block";
return;
}
if (validFrom) schedule.valid_from = validFrom;
if (validUntil) schedule.valid_until = validUntil;
}
if (mode === "weekly") {
const days = [];
for (var i = 0; i < 7; i++) {
if (document.getElementById("edit-prio-day-" + i).checked) days.push(String(i));
}
schedule.days = days;
}
}
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, { fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
old_name: editState.oldName, old_name: editState.oldName,
new_url: newUrl, new_url: isUrl ? newUrl : null,
zoom: zoom, zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked passthrough: isUrl ? document.getElementById("edit-passthrough-input").checked : null,
schedule: schedule || {}
}) })
}).then(function (r) { }).then(function (r) {
if (r.ok) { if (r.ok) {
@@ -365,22 +597,90 @@ document.addEventListener("DOMContentLoaded", function () {
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5> <h5 class="modal-title" id="edit-url-modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3" id="edit-url-row">
<label class="form-label">URL</label> <label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input"> <input type="url" class="form-control" id="edit-url-input">
</div> </div>
<div class="mb-3"> <div class="mb-3" id="edit-zoom-row">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0"> <input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check" id="edit-passthrough-row">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input"> <input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label> <label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div> </div>
<!-- Zeitsteuerung -->
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="edit-schedule-enabled">
<label class="form-check-label" for="edit-schedule-enabled">Zeitgesteuerte Wiedergabe</label>
</div>
<div id="edit-schedule-options" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" id="edit-schedule-mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="edit-schedule-datetime">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" id="edit-schedule-start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" id="edit-schedule-end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="edit-schedule-time" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" id="edit-schedule-start-time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" id="edit-schedule-end-time">
</div>
</div>
<div class="mb-3" id="edit-schedule-validity" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-from">
</div>
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="edit-schedule-days" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
{% for nr, tag in [(0, 'Mo'), (1, 'Di'), (2, 'Mi'), (3, 'Do'), (4, 'Fr'), (5, 'Sa'), (6, 'So')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ nr }}" id="edit-prio-day-{{ nr }}">
<label class="form-check-label" for="edit-prio-day-{{ nr }}">{{ tag }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div> <div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">