Compare commits

...

3 Commits

Author SHA1 Message Date
Erik Thiele
29caaa2e0e chore: .DS_Store aus der Versionierung nehmen
Die Datei stand bereits in .gitignore, war aber weiterhin getrackt -
Ignore-Regeln greifen bei bereits versionierten Dateien nicht. Lokal
bleibt sie erhalten, nur der Repo-Eintrag entfaellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:45:19 +02:00
Erik Thiele
f540458b74 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>
2026-08-29 19:42:03 +02:00
Erik Thiele
26efed87a5 feat: Add time-based scheduling for playlist items 2026-08-29 16:29:40 +02:00
4 changed files with 1015 additions and 118 deletions

BIN
.DS_Store vendored

Binary file not shown.

308
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,13 +217,163 @@ def playlist_item_name(item):
return "" return ""
def playlist_item_enabled(item): def _window_start_date(now, start_time, end_time):
"""Prüft, ob ein Playlist-Eintrag aktiviert ist. """Ermittelt, an welchem Kalendertag das gerade laufende Tageszeitfenster begonnen hat.
Strings sind immer aktiv, Dicts haben ein optionales "enabled"-Feld (default: True).""" Gibt None zurück, wenn die aktuelle Uhrzeit ausserhalb des Fensters liegt.
if isinstance(item, (str,)): 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):
"""Prüft, ob ein Playlist-Eintrag aktiviert und zeitlich gültig ist.
Berücksichtigt enabled-Status und optionale Zeitsteuerung.
Zeitfenster dürfen über Mitternacht laufen (start > end)."""
now = datetime.now()
# Strings haben keinen enabled-Status und keine Zeitsteuerung
if not isinstance(item, dict):
return True return True
if isinstance(item, dict):
return item.get("enabled", 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
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}
# Unbekannter Modus: wie ohne Zeitsteuerung behandeln
return True return True
@@ -252,8 +402,9 @@ def load_priority_files():
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(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") if isinstance(item, dict) else None
}) })
continue continue
@@ -262,14 +413,20 @@ def load_priority_files():
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
@@ -286,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
@@ -520,11 +678,11 @@ def login():
if trust_token: if trust_token:
now = datetime.now() now = datetime.now()
# Abgelaufene Einträge bereinigen # Abgelaufene Einträge bereinigen
active = [d for d in trusted active = [d for d in user.trusted_devices
if d.get("token") != trust_token or if d.get("token") != trust_token or
datetime.fromisoformat(d["expires"]) > now] datetime.fromisoformat(d["expires"]) > now]
changed = len(active) != len(trusted) changed = len(active) != len(user.trusted_devices)
for d in trusted: for d in user.trusted_devices:
if d.get("token") == trust_token: if d.get("token") == trust_token:
try: try:
expires = datetime.fromisoformat(d["expires"]) expires = datetime.fromisoformat(d["expires"])
@@ -975,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": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False}) 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):
@@ -1005,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": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False}) 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)
@@ -1041,7 +1199,8 @@ def player(site, screen):
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()
) )
@@ -1063,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()
@@ -1192,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)
@@ -1201,8 +1371,9 @@ def admin(site):
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(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": schedule
}) })
continue continue
@@ -1213,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
@@ -1224,7 +1395,8 @@ def admin(site):
"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": schedule
}) })
# 2. Neue Dateien (ohne Playlist-Eintrag) anhängen # 2. Neue Dateien (ohne Playlist-Eintrag) anhängen
@@ -1241,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
@@ -1474,7 +1647,7 @@ def upload(site, screen):
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"]) @app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
@site_access_required @site_access_required
def add_url(site, screen): def add_url(site, screen):
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu.""" """Fügt eine URL (mit Zoom-Faktor und optionaler Zeitsteuerung) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
url = request.form.get("url", "").strip() url = request.form.get("url", "").strip()
zoom = request.form.get("zoom", "1.0").strip() zoom = request.form.get("zoom", "1.0").strip()
@@ -1488,7 +1661,47 @@ def add_url(site, screen):
except (ValueError, TypeError): except (ValueError, TypeError):
zoom_factor = 1.0 zoom_factor = 1.0
# Zeitsteuerung verarbeiten
schedule_enabled = "schedule_enabled" in request.form
schedule = None
if schedule_enabled:
mode = request.form.get("schedule_mode", "once")
# "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:
flash("Start- und Endzeit müssen angegeben werden", "error")
return redirect(f"/admin/{site}")
schedule = {
"mode": mode,
"start": start,
"end": end
}
if mode == "weekly":
days = request.form.getlist("schedule_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:
url_data["schedule"] = schedule
config = load_config() config = load_config()
if screen == "priority": if screen == "priority":
@@ -1584,13 +1797,19 @@ def save_playlist(site, screen):
@app.route("/admin/<site>/update-playlist-item/<screen>", methods=["POST"]) @app.route("/admin/<site>/update-playlist-item/<screen>", methods=["POST"])
@site_access_required @site_access_required
def update_playlist_item(site, screen): def update_playlist_item(site, screen):
"""Aktualisiert URL und Zoom 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")
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:
@@ -1610,15 +1829,34 @@ 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:
passthrough = data.get("passthrough", None) 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
if passthrough is not None: if passthrough is not None:
updated["passthrough_params"] = bool(passthrough) updated["passthrough_params"] = bool(passthrough)
else: else:
# Bestehenden Wert übernehmen falls vorhanden
existing = item.get("passthrough_params") if isinstance(item, dict) else None existing = item.get("passthrough_params") if isinstance(item, dict) else None
if existing is not None: if existing is not None:
updated["passthrough_params"] = existing updated["passthrough_params"] = existing
# Zeitsteuerung beibehalten, entfernen oder aktualisieren
if schedule is not None:
# Schedule wurde explizit gesendet (kann {} für Entfernen sein)
if schedule: # Nicht leer
updated["schedule"] = schedule
# Wenn schedule leer ist {}, wird es nicht hinzugefügt (entfernt)
else:
# Nicht im Request → bestehenden Wert beibehalten
if isinstance(item, dict) and "schedule" in item:
updated["schedule"] = item["schedule"]
playlist[i] = updated playlist[i] = updated
save_config(config) save_config(config)
return jsonify({"success": True}) return jsonify({"success": True})

View File

@@ -240,18 +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 %}" 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 %}
</span> {% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
</span>
<!-- Typ-Badge --> <!-- Typ-Badge -->
{% if file.type == "image" %} {% if file.type == "image" %}
@@ -271,32 +272,30 @@
{% endif %} {% endif %}
</span> </span>
<!-- Löschen --> <!-- Löschen -->
<form action="{{ url_for('delete_file', site=current_site, screen=screen, filename=file.name) }}" <form action="{{ url_for('delete_file', site=current_site, screen=screen, filename=file.name) }}"
method="post"> method="post" style="margin: 0; padding: 0;">
<button type="submit" <button type="submit"
class="btn btn-outline-danger btn-sm" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Datei wirklich löschen?')"> onclick="return confirm('Datei wirklich löschen?')">
Löschen Löschen
</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' }}"
Edit data-schedule='{{ file.schedule | tojson }}'>
</button> Edit
{% else %} </button>
<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>
@@ -482,18 +481,111 @@
<hr> <hr>
<h3>URL hinzufügen</h3> <h3>URL hinzufügen</h3>
<form action="/admin/{{ current_site }}/add-url/{{ screen }}" method="post" class="mb-4"> <form action="/admin/{{ current_site }}/add-url/{{ screen }}" method="post" class="mb-4">
<div class="mb-2"> <div class="mb-2">
<label class="form-label">URL</label> <label class="form-label">URL</label>
<input type="url" name="url" class="form-control" <input type="url" name="url" class="form-control"
placeholder="https://example.com" required> placeholder="https://example.com" required>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<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>
<button class="btn btn-primary" type="submit">URL hinzufügen</button>
</form> <!-- 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-{{ screen }}" name="schedule_enabled">
<label class="form-check-label" for="schedule-enabled-{{ screen }}">Zeitgesteuerte Wiedergabe</label>
</div>
<div class="mb-3" id="schedule-options-{{ screen }}" 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-{{ screen }}">
<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-{{ 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;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="0" id="mon-{{ screen }}">
<label class="form-check-label" for="mon-{{ screen }}">Mo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="1" id="tue-{{ screen }}">
<label class="form-check-label" for="tue-{{ screen }}">Di</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="2" id="wed-{{ screen }}">
<label class="form-check-label" for="wed-{{ screen }}">Mi</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="3" id="thu-{{ screen }}">
<label class="form-check-label" for="thu-{{ screen }}">Do</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="4" id="fri-{{ screen }}">
<label class="form-check-label" for="fri-{{ screen }}">Fr</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="5" id="sat-{{ screen }}">
<label class="form-check-label" for="sat-{{ screen }}">Sa</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="6" id="sun-{{ screen }}">
<label class="form-check-label" for="sun-{{ screen }}">So</label>
</div>
</div>
</div>
</div>
<button class="btn btn-primary" type="submit">URL hinzufügen</button>
</form>
</div> </div>
<!-- ═══ Tab: Info (Client-Informationen) ═══ --> <!-- ═══ Tab: Info (Client-Informationen) ═══ -->
@@ -540,23 +632,105 @@
</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" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */ /* Zeitsteuerung: Wochentage nur im Modus "weekly" zeigen.
document.addEventListener("click", function (e) { Muss auf Top-Level stehen - der Edit-Handler unten läuft ausserhalb
var btn = e.target.closest(".edit-url-btn"); von DOMContentLoaded und würde die Funktion sonst nicht finden. */
if (!btn) return; /* Uhrzeit aus "HH:MM" oder einem vollen ISO-Zeitstempel herausziehen */
e.preventDefault(); function scheduleClock(value) {
editState.site = btn.dataset.site; if (!value) return "";
editState.screen = btn.dataset.screen; var text = String(value).replace(" ", "T");
editState.oldName = btn.dataset.oldname; var idx = text.indexOf("T");
document.getElementById("edit-url-input").value = btn.dataset.oldname; if (idx !== -1) text = text.substring(idx + 1);
document.getElementById("edit-zoom-input").value = btn.dataset.zoom; return text.substring(0, 5);
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true"; }
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show(); 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) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn");
if (!btn) return;
e.preventDefault();
editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen;
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-zoom-input").value = btn.dataset.zoom;
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
const schedule = JSON.parse(btn.dataset.schedule || 'null');
const enabledEl = document.getElementById("edit-schedule-enabled");
const 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 || "";
// Wochentage setzen
const days = schedule.days || [];
document.getElementById("edit-mon").checked = days.includes("0");
document.getElementById("edit-tue").checked = days.includes("1");
document.getElementById("edit-wed").checked = days.includes("2");
document.getElementById("edit-thu").checked = days.includes("3");
document.getElementById("edit-fri").checked = days.includes("4");
document.getElementById("edit-sat").checked = days.includes("5");
document.getElementById("edit-sun").checked = days.includes("6");
// Wochentage-Anzeige entsprechend Modus aktualisieren
updateScheduleDaysVisibility();
} else {
enabledEl.checked = false;
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();
}
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
});
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -610,14 +784,53 @@ document.addEventListener("DOMContentLoaded", function () {
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Zeitsteuerung: Änderungs-Events verarbeiten ─── */
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", function() {
updateScheduleDaysVisibility();
});
// Initiale Anzeige
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;
@@ -625,14 +838,70 @@ 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 (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") {
const days = [];
if (document.getElementById("edit-mon").checked) days.push("0");
if (document.getElementById("edit-tue").checked) days.push("1");
if (document.getElementById("edit-wed").checked) days.push("2");
if (document.getElementById("edit-thu").checked) days.push("3");
if (document.getElementById("edit-fri").checked) days.push("4");
if (document.getElementById("edit-sat").checked) days.push("5");
if (document.getElementById("edit-sun").checked) days.push("6");
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) {
@@ -652,27 +921,117 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
</script> </script>
<!-- Modal: URL bearbeiten --> <!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1"> <div class="modal fade" id="edit-url-modal" tabindex="-1">
<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: gleiche schlichte Zeilen wie der Rest des Dialogs -->
<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">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="0" id="edit-mon">
<label class="form-check-label" for="edit-mon">Mo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="edit-tue">
<label class="form-check-label" for="edit-tue">Di</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="2" id="edit-wed">
<label class="form-check-label" for="edit-wed">Mi</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="3" id="edit-thu">
<label class="form-check-label" for="edit-thu">Do</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="4" id="edit-fri">
<label class="form-check-label" for="edit-fri">Fr</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="5" id="edit-sat">
<label class="form-check-label" for="edit-sat">Sa</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="6" id="edit-sun">
<label class="form-check-label" for="edit-sun">So</label>
</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>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

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">