diff --git a/.DS_Store b/.DS_Store index 12188b9..dc11242 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/AGENTS.md b/AGENTS.md index 7a3c5a6..50e7386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,26 +8,58 @@ python app.py docker compose up -d ``` -`python app.py` serves on `http://localhost:5005`. `app.py` runs Flask with `debug=True`, `host="0.0.0.0"`, and `port=5005`. +`python app.py` (via `.venv/bin/python app.py`) serves on `http://localhost:5005`. `app.py` runs Flask with `debug=True`, `host="0.0.0.0"`, and `port=5005`. ## Source Of Truth - Single Flask app in `app.py`; there is no database. -- Persistent state is `config.json` plus files under `media//`. +- Persistent state is `config.json` plus files under `media///`. - `README.md` is stale on the port number; trust `app.py`. - Existing repo instructions in this file are the main local guidance; there is no `opencode.json` or workflow config in this repo. +## Multi-Standort-URL-Struktur (seit v4.2.0) + +Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens. + +| Route | Beschreibung | +|-------|-------------| +| `GET /player//` | Player für Screen an einem Standort | +| `GET /admin` | Redirect zum ersten Standort | +| `GET /admin/` | Admin-Dashboard für einen Standort | +| `GET /admin//priority` | Priority-Playlist als separate Seite | +| `GET /media///` | Medien-Datei ausliefern | +| `GET /media/priority/` | Priority-Medien (global) | +| `GET /playlist///hash` | Playlist-Checksumme für Auto-Reload | +| `GET /willkommen?site=` | Willkommensseite für Standort generieren | +| `POST /api/customer` | API-Endpunkt (JSON mit "site"-Feld) | +| `GET /admin//add-screen?name=` | Neuen Screen anlegen | +| `POST /admin//delete-screen/` | Screen + Medien löschen | + ## Behavior To Preserve -- `GET /player/` renders the playlist and auto-reloads from `/playlist//hash`. +- `GET /player//` renders the playlist and auto-reloads from `/playlist///hash`. - `GET /admin` requires login; `config.json.admin` holds the credentials. - URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. - `.html` items in `media/` are rendered inline as content, not in an iframe. - `config.priority.enabled` makes the priority playlist show on every screen. -- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist. +- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist for the specified site. +- New standorte can be added by creating `media///` directories and optionally adding config to `config.json["sites"][]`. +- Priority playlist (`config.priority`) is global and affects all sites/screens. +- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row. +- `generate_welcome_html(customer_names, logo_urls)` takes lists for up to 3 customers; logos have equal width (280px) with `max-height: 180px`. +- `customer_names` are preserved in form fields after POST (via `value`-Attribute). +- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`. +- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem). +- `add_customer_to_lobby_playlist` entfernt `welcome.html` sowohl als String als auch als Dict aus der Playlist vor dem Einfügen. +- `add_screen` legt ein Verzeichnis unter `media///` an und einen Config-Eintrag. +- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv. +- Custom-URL-Button: pro Screen konfigurierbar (`custom_url` + `custom_url_label` + `custom_url_target`) im Admin-Formular; wird im Player als Button oben links angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (`overlay`) oder per Direkt-Weiterleitung (`redirect`); Player pausiert während das Overlay geöffnet ist. ## Repo Quirks - `media/` and media file extensions are gitignored. - The app has no configured tests, lint, typecheck, formatter, or CI. - Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually. +- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`. +- `welcome.html` wird in `media//lobby/` gespeichert. +- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN. diff --git a/README.md b/README.md index ee7dd01..d76f947 100755 --- a/README.md +++ b/README.md @@ -1,85 +1,95 @@ -# 📺 CANCOM Simple Signage +# CANCOM Simple Signage -**CANCOM Simple Signage** ist ein leichtgewichtiges, browserbasiertes Digital-Signage-System auf Basis von **Python + Flask**. - -Ziel ist eine **einfache, robuste und wartbare Lösung** für interne Info-Screens, ohne die Komplexität klassischer Digital-Signage-Plattformen. +Browserbasiertes Digital-Signage-System für interne Info-Screens. --- -## ✨ Features +## Features -- ✅ Browser-basierter Player (kein nativer Client erforderlich) -- ✅ Bilder & Videos (MP4, H.264) -- ✅ Pro Screen eine eigene Playlist -- ✅ Drag-&-Drop-Reihenfolge im Admin-Portal -- ✅ Automatischer Player-Reload bei Änderungen -- ✅ Upload & Löschen von Medien -- ✅ Screen-Status (aktiv / leer) -- ✅ CI-konformes Admin-UI (CANCOM) -- ✅ Läuft lokal, in VMs oder auf Proxmox +- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox) +- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge +- **Multi-Standort**: Standorte (sites) gruppieren Screens +- **Priority-Playlist**: globale Inhalte wirken auf alle Player +- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch +- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge +- Auto-Reload bei Playlist-Änderungen +- Newsticker pro Screen +- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button oben links im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung) +- Dark Mode (localStorage-persistiert) +- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot) --- -## 🧱 Architektur +## Architektur ``` Browser (Player) │ ▼ Flask App (Server) -├── Admin UI -├── Player UI +├── Admin UI /admin/ +├── Player UI /player// +├── Priority-Seite /admin//priority ├── config.json -└── media/ - └── / - ├── bild.jpg - └── video.mp4 +├── media/ +│ ├── / +│ │ ├── / +│ │ │ ├── bild.jpg +│ │ │ ├── video.mp4 +│ │ │ └── welcome.html +│ └── priority/ +└── generate_welcome_page.py ``` - **Server:** Python 3 + Flask -- **Player:** Jeder moderne Browser (Chrome, Edge, Firefox, Kiosk-Modus) +- **Player:** Jeder moderne Browser (Chrome Kiosk, Edge, Firefox) - **State:** `config.json` + Dateisystem -- **Frontend:** Tabler + SortableJS (CDN) +- **Frontend:** Tabler Core + Tabler Icons + SortableJS (CDN) --- -## 📂 Projektstruktur +## Projektstruktur ``` signage/ -├── app.py -├── config.json +├── app.py # Flask-App (alle Routen) +├── generate_welcome_page.py # Logo-Suche + Willkommensseite-Generierung +├── config.json # Persistente Konfiguration ├── media/ -│ ├── lobby/ -│ └── meetingroom/ +│ ├── / +│ │ ├── lobby/ +│ │ ├── casino/ +│ │ └── videosysteme/ +│ └── priority/ ├── templates/ -│ ├── admin.html -│ ├── player.html -│ └── login.html +│ ├── admin.html # Admin-Dashboard (Übersicht) +│ ├── priority.html # Priority-Playlist (eigene Seite) +│ ├── customer.html # Willkommensseite-Formular +│ ├── player.html # Player-Ansicht +│ ├── login.html +│ ├── _header.html # Gemeinsamer Header +│ ├── _footer.html # Gemeinsamer Footer +│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode) ├── static/ -└── README.md +│ ├── cancom.svg +│ └── wallpaper.png +└── AGENTS.md ``` --- -## 🚀 Installation +## Installation ### Voraussetzungen - Python ≥ 3.9 - pip -- ffmpeg - -```bash -sudo apt install python3 python3-pip ffmpeg -``` +- ffmpeg (optional, für Videokonvertierung) ### Setup ```bash -git clone -cd signage -pip install flask flask-login werkzeug +pip install flask flask-login werkzeug openai ``` ### Starten @@ -88,72 +98,134 @@ pip install flask flask-login werkzeug python app.py ``` -Server läuft auf: +Server läuft auf `http://localhost:5005`. -``` -http://localhost:5005 +### Docker + +```bash +docker compose up -d ``` --- -## 🛠️ Betrieb +## Routen + +| Route | Beschreibung | +|-------|-------------| +| `GET /` | 404 – kein Root-Endpunkt | +| `GET /login` | Admin-Login | +| `GET /logout` | Ausloggen | +| `GET /admin` | Redirect zum ersten konfigurierten Standort | +| `GET /admin/` | Admin-Dashboard für einen Standort | +| `GET /admin//priority` | Priority-Playlist (separate Seite) | +| `GET /admin//update/` | Screen-Einstellungen speichern (POST) | +| `GET /admin//upload/` | Medien hochladen (POST) | +| `GET /admin//add-url/` | URL zur Playlist hinzufügen (POST) | +| `GET /admin//delete//` | Datei löschen (POST) | +| `GET /admin//playlist/` | Playlist-Reihenfolge speichern (POST JSON) | +| `GET /player//` | Player-Ansicht | +| `GET /playlist///hash` | Playlist-Checksumme (für Auto-Reload) | +| `GET /willkommen?site=` | Willkommensseite-Formular (GET + POST) | +| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) | +| `GET /media///` | Medien-Datei ausliefern | +| `GET /media/priority/` | Priority-Medien (global) | ### Admin-Portal ``` -/admin +http://localhost:5005/admin/ ``` - Screens konfigurieren - Medien hochladen / löschen - Playlist per Drag & Drop sortieren +- Priority-Playlist verwalten +- Willkommensseite generieren (bis zu 3 Kundenlogos) +- Custom-URL-Aktionsbutton pro Screen konfigurieren (iframe-Overlay oder Weiterleitung) -### Player-URLs +### Player-URL ``` -/player/ +http://localhost:5005/player// ``` Beispiel: +``` +http://localhost:5005/player/stuttgart/lobby +``` + +### Willkommensseite ``` -http://server:5000/player/lobby +http://localhost:5005/willkommen?site=stuttgart +``` + +Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. + +--- + +## Konfiguration (`config.json`) + +```json +{ + "admin": { "username": "...", "password": "..." }, + "sites": { + "stuttgart": { + "screens": { + "lobby": { "playlist": [...], "interval": 10 }, + "casino": { "playlist": [...], "interval": 15 } + } + } + }, + "priority": { + "enabled": true, + "playlist": [...] + } +} ``` --- -## 🔄 Automatischer Reload +## Helper-Funktionen (`app.py`) -Der Player aktualisiert sich selbst, wenn: - -- Medien hinzugefügt oder gelöscht werden -- Playlist-Reihenfolge geändert wird -- Screen-Einstellungen angepasst werden +- `load_config()` / `save_config()` – JSON lesen/schreiben +- `get_site_list(cfg)` – alle Standorte sortiert +- `get_screen_config(cfg, site, screen)` – Screen-Konfiguration mit Defaults +- `is_url(item)` – prüft ob Item eine URL ist +- `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}` +- `playlist_item_name(item)` – Name aus String oder Dict extrahieren +- `playlist_item_enabled(item)` – Enabled-Status prüfen +- `load_priority_files()` – Priority-Playlist + Dateien laden +- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite --- -## ⚠️ Git & Medien +## Dark Mode -> **Dieses Repository ist für Code gedacht, nicht für Medien.** - -Empfohlene `.gitignore`: - -```gitignore -media/ -*.mp4 -*.mov -*.jpg -*.jpeg -*.png -``` +Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Header. CSS-Variablen `--ccm-*` in `_styles.html`. --- -## 🎞️ Video-Empfehlungen +## Sicherheit -- Format: MP4 -- Codec: H.264 -- Auflösung: max. 1920x1080 +- Admin-Bereich per Flask-Login geschützt +- Player-Seiten Read-Only +- Externe Nutzung via Reverse Proxy + TLS empfohlen + +--- + +## Git & Medien + +> Das Repository ist für Code gedacht, nicht für Medien. + +`media/` und Medien-Dateiendungen sind in `.gitignore` ausgeschlossen. + +--- + +## Video-Empfehlungen + +- Format: MP4 (H.264) +- Auflösung: max. 1920×1080 ```bash ffmpeg -i input.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart output.mp4 @@ -161,25 +233,6 @@ ffmpeg -i input.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart output.mp --- -## 🔐 Sicherheit +## Maintainer -- Admin-Bereich per Login geschützt -- Player-Seiten Read-Only -- Externe Nutzung via Reverse Proxy + TLS empfohlen - ---- - -## 🧭 Roadmap - -- Zeitsteuerung pro Playlist-Eintrag -- Vorschau im Admin-Portal -- Mehrere Admin-User -- SQLite statt config.json -- Docker-Compose Setup - ---- - -## 👤 Maintainer - -**CANCOM Simple Signage** -Interne Lösung – nicht für externe Weitergabe bestimmt. +**CANCOM Simple Signage** – Interne Lösung, nicht für externe Weitergabe bestimmt. diff --git a/app.py b/app.py index 5aec7ec..091250d 100755 --- a/app.py +++ b/app.py @@ -26,7 +26,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MEDIA_DIR = os.path.join(BASE_DIR, "media") CONFIG_FILE = os.path.join(BASE_DIR, "config.json") -APP_VERSION = "4.1.1" +APP_VERSION = "5.1.0" UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} app = Flask(__name__) @@ -40,7 +40,7 @@ login_manager.login_view = "login" # ------------------------------------------------- def load_config(): if not os.path.exists(CONFIG_FILE): - return {"admin": {}, "screens": {}} + return {"admin": {}, "sites": {}} with open(CONFIG_FILE) as f: return json.load(f) @@ -48,38 +48,117 @@ def save_config(cfg): with open(CONFIG_FILE, "w") as f: json.dump(cfg, f, indent=2) f.flush() - os.fsync(f.fileno()) - print("✅ config.json gespeichert") -def is_url(value): - """Check if value is a URL (string or dict with 'url' key)""" - if isinstance(value, str): - return value.lower().startswith(("http://", "https://")) - if isinstance(value, dict) and "url" in value: - url = value.get("url", "") - return isinstance(url, str) and url.lower().startswith(("http://", "https://")) - return False +def get_site_list(cfg): + return sorted(cfg.get("sites", {}).keys()) -def normalize_url(value): - """Convert URL string to dict with zoom factor, or return existing dict""" - if isinstance(value, dict) and "url" in value: - return value - if isinstance(value, str) and value.lower().startswith(("http://", "https://")): - return {"url": value, "zoom": 1.0} + +def get_screen_config(cfg, site, screen): + return ( + cfg + .setdefault("sites", {}) + .setdefault(site, {}) + .setdefault("screens", {}) + .setdefault(screen, {}) + ) + + +def is_url(item): + if isinstance(item, str): + return item.startswith("http://") or item.startswith("https://") + return isinstance(item, dict) and "url" in item + + +def normalize_url(item): + if isinstance(item, str): + return {"url": item, "zoom": 1.0} + if isinstance(item, dict): + return {"url": item["url"], "zoom": item.get("zoom", 1.0)} return None -def playlist_item_enabled(item): - return not (isinstance(item, dict) and item.get("enabled") is False) - - def playlist_item_name(item): + if isinstance(item, str): + return item if isinstance(item, dict): - return item.get("url") or item.get("name") - return item + return item.get("url") or item.get("name", "") + return "" +def playlist_item_enabled(item): + if isinstance(item, (str,)): + return True + if isinstance(item, dict): + return item.get("enabled", True) + return True + + +# ------------------------------------------------- +# Priority-Helper +# ------------------------------------------------- +def load_priority_files(): + cfg = load_config() + prio_dir = os.path.join(MEDIA_DIR, "priority") + os.makedirs(prio_dir, exist_ok=True) + + priority_files = [] + prio_playlist = cfg.get("priority", {}).get("playlist", []) + + for item in prio_playlist: + item_name = playlist_item_name(item) + if is_url(item): + url_data = normalize_url(item) + if url_data: + priority_files.append({ + "name": url_data["url"], + "type": "url", + "size": "URL", + "zoom": url_data.get("zoom", 1.0), + "enabled": playlist_item_enabled(item) + }) + continue + + file_path = os.path.join(prio_dir, item_name) + if not os.path.exists(file_path): + continue + + ext = os.path.splitext(item_name)[1].lower() + ftype = "video" if ext == ".mp4" else "image" + size = os.path.getsize(file_path) // 1024 + + priority_files.append({ + "name": item_name, + "type": ftype, + "size": size, + "enabled": playlist_item_enabled(item) + }) + + playlist_names = {playlist_item_name(item) for item in prio_playlist} + for f in sorted(os.listdir(prio_dir)): + if f in playlist_names or f.startswith("._"): + continue + + file_path = os.path.join(prio_dir, f) + ext = os.path.splitext(f)[1].lower() + ftype = "video" if ext == ".mp4" else "image" + size = os.path.getsize(file_path) // 1024 + + priority_files.append({ + "name": f, + "type": ftype, + "size": size + }) + + return priority_files + + +def prio_redirect(site): + return f"/admin/{site}/priority" + + +# ------------------------------------------------- +# User & Login # ------------------------------------------------- # Auth / Benutzer # ------------------------------------------------- @@ -117,34 +196,62 @@ def logout(): # ------------------------------------------------- # Customer / Willkommensseite # ------------------------------------------------- +@app.route("/willkommen", methods=["GET", "POST"]) @app.route("/customer", methods=["GET", "POST"]) # @login_required def add_customer(): """Add new customer with logo search and welcome page""" error = None success = None - logo_url = None - + logo_urls = [None, None, None] + customer_names = ["", "", ""] + site = request.args.get("site") or request.form.get("site") or "" + + # Default to first site if not specified + if not site: + config = load_config() + sites = get_site_list(config) + site = sites[0] if sites else "" + if request.method == "POST": - customer_name = request.form.get("customer_name", "").strip() - print(f"🔍 APP.PY - Suche nach Kunde: '{customer_name}'") - if not customer_name: - error = "Kundenname erforderlich" + customer_names = [ + request.form.get("customer_name_1", "").strip(), + request.form.get("customer_name_2", "").strip(), + request.form.get("customer_name_3", "").strip(), + ] + filled_names = [n for n in customer_names if n] + + print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})") + + if not filled_names: + error = "Mindestens ein Kundenname erforderlich" + elif not site: + error = "Kein Standort ausgewählt" else: try: - # Search for logo - logo_url = generate_welcome_page.search_customer_logo(customer_name) - - if not logo_url: - error = "Logo konnte nicht gefunden werden" + found_logos = [] + for name in filled_names: + logo = generate_welcome_page.search_customer_logo(name) + found_logos.append(logo) + + valid = [(n, l) for n, l in zip(filled_names, found_logos) if l] + + if not valid: + error = "Kein Logo konnte gefunden werden" else: - # Generate and save welcome page - html_filename = generate_welcome_page.save_welcome_page(customer_name, logo_url) - + valid_names = [n for n, l in valid] + valid_urls = [l for n, l in valid] + for i, name in enumerate(customer_names): + for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)): + if vn == name: + logo_urls[i] = vu + break + + html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, site=site) + if html_filename: - # Add to lobby playlist - if generate_welcome_page.add_customer_to_lobby_playlist(html_filename): - success = f"✅ Kunde '{customer_name}' erfolgreich hinzugefügt!" + if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site): + success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!" else: error = "Fehler beim Hinzufügen zur Playliste" else: @@ -153,8 +260,22 @@ def add_customer(): error = f"Konfigurationsfehler: {str(e)}" except Exception as e: error = f"Fehler: {str(e)}" - - return render_template("customer.html", error=error, success=success, logo_url=logo_url) + + config = load_config() + site_list = get_site_list(config) + return render_template( + "customer.html", + error=error, + success=success, + logo_urls=logo_urls, + customer_names=customer_names, + site=site, + site_list=site_list, + current_site=site, + version=APP_VERSION, + year=datetime.now().year, + hostname=os.uname().nodename + ) @app.route("/api/customer", methods=["POST"]) # @login_required @@ -163,33 +284,41 @@ def api_add_customer(): try: data = request.get_json() customer_name = data.get("customer_name", "").strip() - + site = data.get("site", "").strip() + if not customer_name: return jsonify({"error": "Customer name required"}), 400 - - # Search for logo + + # Default to first site + if not site: + config = load_config() + sites = get_site_list(config) + site = sites[0] if sites else "" + + if not site: + return jsonify({"error": "No site configured"}), 400 + logo_url = generate_welcome_page.search_customer_logo(customer_name) - + if not logo_url: return jsonify({"error": "Logo not found"}), 400 - - # Generate and save welcome page - html_filename = generate_welcome_page.save_welcome_page(customer_name, logo_url) - + + html_filename = generate_welcome_page.save_welcome_page(customer_name, logo_url, site=site) + if not html_filename: return jsonify({"error": "Failed to create welcome page"}), 500 - - # Add to lobby playlist - if generate_welcome_page.add_customer_to_lobby_playlist(html_filename): + + if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site): return jsonify({ "success": True, "message": f"Customer '{customer_name}' added successfully", "welcome_page": html_filename, - "logo_url": logo_url + "logo_url": logo_url, + "site": site }), 201 else: return jsonify({"error": "Failed to add to playlist"}), 500 - + except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: @@ -198,25 +327,36 @@ def api_add_customer(): # ------------------------------------------------- # Medien ausliefern # ------------------------------------------------- -@app.route("/media//") -def media(screen, filename): - path = os.path.join(MEDIA_DIR, screen) +@app.route("/media/priority/") +def media_priority(filename): + path = os.path.join(MEDIA_DIR, "priority") + return send_from_directory(path, filename) + +@app.route("/media///") +def media(site, screen, filename): + path = os.path.join(MEDIA_DIR, site, screen) return send_from_directory(path, filename) # ------------------------------------------------- # Player # ------------------------------------------------- -@app.route("/player/") -def player(screen): +@app.route("/player//") +def player(site, screen): config = load_config() - screens_cfg = config.setdefault("screens", {}) + sites_cfg = config.setdefault("sites", {}) + site_cfg = sites_cfg.get(site) + + if not site_cfg: + abort(404) + + screens_cfg = site_cfg.setdefault("screens", {}) screen_cfg = screens_cfg.get(screen) if not screen_cfg: abort(404) - folder = os.path.join(MEDIA_DIR, screen) + folder = os.path.join(MEDIA_DIR, site, screen) playlist = screen_cfg.get("playlist", []) show_images = screen_cfg.get("show_images", True) @@ -267,7 +407,6 @@ def player(screen): # ------------------------------ # Priority-Playlist (global) - # WICHTIG: IMMER definieren! # ------------------------------ priority_cfg = config.get("priority", {}) @@ -291,26 +430,30 @@ def player(screen): return render_template( "player.html", + site=site, screen=screen, 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)], interval=screen_cfg.get("interval", 10), 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_label=screen_cfg.get("custom_url_label", ""), + custom_url_enabled=screen_cfg.get("custom_url_enabled", False), + custom_url_target=screen_cfg.get("custom_url_target", "overlay") ) # ------------------------------------------------- # Hash für automatisches Player-Reload # ------------------------------------------------- -@app.route("/playlist//hash") -def playlist_hash(screen): +@app.route("/playlist///hash") +def playlist_hash(site, screen): config = load_config() - # ✅ Globaler Hash über ALLE relevanten Daten relevant = { - "screens": { - screen: config["screens"].get(screen, {}) + "sites": { + site: config.get("sites", {}).get(site, {}) }, "priority": config.get("priority", {}) } @@ -325,33 +468,66 @@ def playlist_hash(screen): @app.route("/admin") @login_required -def admin(): +def admin_redirect(): + """Redirect to first available site""" + cfg = load_config() + sites = get_site_list(cfg) + if sites: + return redirect(f"/admin/{sites[0]}") + return "

Kein Standort konfiguriert

Bitte lege einen Standort im media-Verzeichnis an.

", 200 + + +@app.route("/admin/") +@login_required +def admin(site): cfg = load_config() + site_list = get_site_list(cfg) + + # Validate site exists + if site not in site_list: + # Create default site config if filesystem dir exists but no config + if os.path.isdir(os.path.join(MEDIA_DIR, site)): + cfg.setdefault("sites", {})[site] = {"screens": {}} + save_config(cfg) + site_list = get_site_list(cfg) + else: + abort(404) + + current_site = site screens = {} media_files = {} screen_status = {} - for screen in os.listdir(MEDIA_DIR): - if screen == "priority": - continue # ✅ kein echter Screen + site_media_base = os.path.join(MEDIA_DIR, current_site) - path = os.path.join(MEDIA_DIR, screen) - if not os.path.isdir(path): - continue + # Discover screens: from config + filesystem + known_screens = set() + site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"screens": {}}) + config_screens = site_cfg.setdefault("screens", {}) + + # Merge with filesystem directories + if os.path.isdir(site_media_base): + for entry in os.listdir(site_media_base): + entry_path = os.path.join(site_media_base, entry) + if os.path.isdir(entry_path) and not entry.startswith("._"): + known_screens.add(entry) + + # Add screens from config that might not have a directory yet + for s in config_screens: + known_screens.add(s) + + for screen_name in sorted(known_screens): + path = os.path.join(site_media_base, screen_name) + os.makedirs(path, exist_ok=True) files = [] - screen_cfg = cfg.setdefault("screens", {}).setdefault(screen, { - "interval": 10, - "show_images": True, - "show_videos": True, - "playlist": [] - }) + screen_cfg = get_screen_config(cfg, current_site, screen_name) playlist = screen_cfg.get("playlist", []) screen_cfg["newsticker_text"] = screen_cfg.get("newsticker_text", "") screen_cfg["newsticker_enabled"] = screen_cfg.get("newsticker_enabled", False) - # 1️⃣ Playlist-Reihenfolge + # 1 Playlist-Reihenfolge for item in playlist: item_name = playlist_item_name(item) @@ -374,10 +550,10 @@ def admin(): ext = os.path.splitext(item_name)[1].lower() if ext == ".mp4": ftype = "video" - if ext in (".jpg", ".jpeg", ".png"): + if ext in (".jpg", ".jpeg", ".png"): ftype = "image" if ext == ".html": - ftype = "html" + ftype = "html" size = os.path.getsize(file_path) // 1024 @@ -388,7 +564,7 @@ def admin(): "enabled": playlist_item_enabled(item) }) - # 2️⃣ Neue Dateien anhängen (nicht in Playlist) + # 2 Neue Dateien anhängen playlist_names = {playlist_item_name(item) for item in playlist} for f in sorted(os.listdir(path)): if f in playlist_names or f.startswith("._"): @@ -405,72 +581,40 @@ def admin(): "size": size }) - screens[screen] = screen_cfg - media_files[screen] = files - screen_status[screen] = "active" if files else "empty" + screens[screen_name] = screen_cfg + media_files[screen_name] = files + screen_status[screen_name] = "active" if files else "empty" - - # --- PRIORITY EXTENSION: Admin --- - prio_dir = os.path.join(MEDIA_DIR, "priority") - os.makedirs(prio_dir, exist_ok=True) - - priority_files = [] - prio_playlist = cfg["priority"].get("playlist", []) - - # 1️⃣ Playlist-Reihenfolge - for item in prio_playlist: - item_name = playlist_item_name(item) - if is_url(item): - url_data = normalize_url(item) - if url_data: - priority_files.append({ - "name": url_data["url"], - "type": "url", - "size": "URL", - "zoom": url_data.get("zoom", 1.0), - "enabled": playlist_item_enabled(item) - }) - continue - - file_path = os.path.join(prio_dir, item_name) - if not os.path.exists(file_path): - continue - - ext = os.path.splitext(item_name)[1].lower() - ftype = "video" if ext == ".mp4" else "image" - size = os.path.getsize(file_path) // 1024 - - priority_files.append({ - "name": item_name, - "type": ftype, - "size": size, - "enabled": playlist_item_enabled(item) - }) - - # 2️⃣ Neue Dateien anhängen (nicht in Playlist) - for f in sorted(os.listdir(prio_dir)): - if f in prio_playlist or f.startswith("._"): - continue - - file_path = os.path.join(prio_dir, f) - ext = os.path.splitext(f)[1].lower() - ftype = "video" if ext == ".mp4" else "image" - size = os.path.getsize(file_path) // 1024 - - priority_files.append({ - "name": f, - "type": ftype, - "size": size - }) - - return render_template( "admin.html", + site_list=site_list, + current_site=current_site, screens=screens, media_files=media_files, screen_status=screen_status, version=APP_VERSION, year=datetime.now().year, + hostname=os.uname().nodename + ) + + +# ------------------------------------------------- +# Admin: Priority-Playlist (separate Seite) +# ------------------------------------------------- +@app.route("/admin//priority") +@login_required +def admin_priority(site): + cfg = load_config() + site_list = sorted(cfg.get("sites", {}).keys()) + + priority_files = load_priority_files() + + return render_template( + "priority.html", + site_list=site_list, + current_site=site, + version=APP_VERSION, + year=datetime.now().year, hostname=os.uname().nodename, priority_files=priority_files ) @@ -479,58 +623,106 @@ def admin(): # ------------------------------------------------- # Admin: Screen-Einstellungen speichern # ------------------------------------------------- -@app.route("/admin/update/", methods=["POST"]) +@app.route("/admin//update/", methods=["POST"]) @login_required -def update_screen(screen): +def update_screen(site, screen): config = load_config() - cfg = config.setdefault("screens", {}).setdefault(screen, { - "interval": 10, - "show_images": True, - "show_videos": True, - "playlist": [] - }) + cfg = get_screen_config(config, site, screen) - cfg["interval"] = int(request.form.get("interval", 10)) + cfg["interval"] = int(request.form.get("interval") or 10) cfg["show_images"] = "show_images" in request.form cfg["show_videos"] = "show_videos" in request.form cfg["newsticker_text"] = request.form.get("newsticker_text", "") cfg["newsticker_enabled"] = "newsticker_enabled" in request.form + cfg["custom_url"] = request.form.get("custom_url", "").strip() + cfg["custom_url_label"] = request.form.get("custom_url_label", "").strip() + cfg["custom_url_enabled"] = "custom_url_enabled" in request.form + cfg["custom_url_target"] = request.form.get("custom_url_target", "overlay") save_config(config) - return redirect("/admin") + return redirect(f"/admin/{site}") + + +# ------------------------------------------------- +# Admin: Screen löschen +# ------------------------------------------------- +@app.route("/admin//delete-screen/", methods=["POST"]) +@login_required +def delete_screen(site, screen): + config = load_config() + + # Aus config.json entfernen + screens_cfg = config.get("sites", {}).get(site, {}).get("screens", {}) + screens_cfg.pop(screen, None) + save_config(config) + + # Medien-Verzeichnis löschen + screen_dir = os.path.join(MEDIA_DIR, site, screen) + if os.path.isdir(screen_dir): + import shutil + shutil.rmtree(screen_dir) + + return redirect(f"/admin/{site}") + + +# ------------------------------------------------- +# Admin: Neuen Screen anlegen +# ------------------------------------------------- +@app.route("/admin//add-screen", methods=["GET"]) +@login_required +def add_screen(site): + screen_name = request.args.get("name", "").strip().lower() + if not screen_name: + return redirect(f"/admin/{site}") + + config = load_config() + + os.makedirs(os.path.join(MEDIA_DIR, site, screen_name), exist_ok=True) + + screens_cfg = config.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}) + if screen_name not in screens_cfg: + screens_cfg[screen_name] = {"playlist": [], "interval": 10, "show_images": True, "show_videos": True} + + save_config(config) + return redirect(f"/admin/{site}#screen-{screen_name}") + # ------------------------------------------------- # Admin: Upload # ------------------------------------------------- -@app.route("/admin/upload/", methods=["POST"]) +@app.route("/admin//upload/", methods=["POST"]) @login_required -def upload(screen): +def upload(site, screen): file = request.files.get("file") if not file: - return redirect("/admin") + return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}") ext = os.path.splitext(file.filename)[1].lower() if ext not in UPLOAD_EXTENSIONS: return "Ungültiger Dateityp", 400 filename = secure_filename(file.filename) - file.save(os.path.join(MEDIA_DIR, screen, filename)) - return redirect("/admin") + if screen == "priority": + file.save(os.path.join(MEDIA_DIR, "priority", filename)) + else: + file.save(os.path.join(MEDIA_DIR, site, screen, filename)) + + return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}") # ------------------------------------------------- # Admin: URL hinzufügen # ------------------------------------------------- -@app.route("/admin/add-url/", methods=["POST"]) +@app.route("/admin//add-url/", methods=["POST"]) @login_required -def add_url(screen): +def add_url(site, screen): url = request.form.get("url", "").strip() zoom = request.form.get("zoom", "1.0").strip() - + if not is_url(url): - return redirect("/admin") - + return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}") + try: zoom_factor = float(zoom) if zoom_factor <= 0: @@ -538,83 +730,73 @@ def add_url(screen): except (ValueError, TypeError): zoom_factor = 1.0 - # Store as object with zoom factor url_data = {"url": url, "zoom": zoom_factor} - + config = load_config() if screen == "priority": config.setdefault("priority", {}).setdefault("playlist", []).append(url_data) else: - screen_cfg = config.setdefault("screens", {}).setdefault(screen, { - "interval": 10, - "show_images": True, - "show_videos": True, - "playlist": [] - }) - screen_cfg.setdefault("playlist", []).append(url_data) + cfg = get_screen_config(config, site, screen) + cfg.setdefault("playlist", []).append(url_data) save_config(config) - return redirect("/admin") + return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}") # ------------------------------------------------- # Admin: Datei löschen # ------------------------------------------------- -@app.route("/admin/delete//", methods=["POST"]) +@app.route("/admin//delete//", methods=["POST"]) @login_required -def delete_file(screen, filename): +def delete_file(site, screen, filename): cfg = load_config() - # Datei löschen - path = os.path.join(MEDIA_DIR, screen, filename) - if os.path.exists(path): - os.remove(path) - - # --- Playlist bereinigen --- if screen == "priority": + path = os.path.join(MEDIA_DIR, "priority", filename) playlist = cfg.get("priority", {}).get("playlist", []) - # Remove both string URLs and dict-based URLs playlist[:] = [item for item in playlist if not ( (isinstance(item, str) and item == filename) or (isinstance(item, dict) and item.get("url") == filename) )] else: - playlist = cfg["screens"].get(screen, {}).get("playlist", []) - # Remove both string URLs and dict-based URLs + path = os.path.join(MEDIA_DIR, site, screen, filename) + screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) + playlist = screens_cfg.get(screen, {}).get("playlist", []) playlist[:] = [item for item in playlist if not ( (isinstance(item, str) and item == filename) or (isinstance(item, dict) and item.get("url") == filename) )] + if os.path.exists(path): + os.remove(path) + save_config(cfg) - return redirect("/admin") + return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}") # ------------------------------------------------- # Admin: Playlist speichern # ------------------------------------------------- -@app.route("/admin/playlist/", methods=["POST"]) +@app.route("/admin//playlist/", methods=["POST"]) @login_required -def save_playlist(screen): +def save_playlist(site, screen): cfg = load_config() data = request.get_json() - new_order = data["playlist"] # List of filenames/URLs as strings + new_order = data["playlist"] enabled_map = data.get("enabled", {}) - - # Get the current playlist with all data (including zoom factors) + if screen == "priority": old_playlist = cfg.get("priority", {}).get("playlist", []) else: - old_playlist = cfg.get("screens", {}).get(screen, {}).get("playlist", []) - - # Create a mapping: filename/url -> full item (preserves zoom factor) + screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) + old_playlist = screens_cfg.get(screen, {}).get("playlist", []) + item_map = {} for item in old_playlist: item_map[playlist_item_name(item)] = item - - # Build new playlist: use the mapping to preserve zoom factors + new_playlist = [] for key in new_order: if key in item_map: @@ -623,11 +805,11 @@ def save_playlist(screen): new_playlist.append(item) else: new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)}) - + if screen == "priority": cfg.setdefault("priority", {})["playlist"] = new_playlist else: - cfg.setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist + cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist save_config(cfg) return "", 204 diff --git a/config.json b/config.json index b879c61..d51b040 100755 --- a/config.json +++ b/config.json @@ -9,68 +9,92 @@ } ] }, - "screens": { - "lobby": { - "interval": 10, - "show_images": true, - "show_videos": false, - "playlist": [ - "welcome.html", - { - "name": "welcome.html", - "enabled": true + "sites": { + "stuttgart": { + "screens": { + "lobby": { + "interval": 10, + "show_images": true, + "show_videos": false, + "playlist": [ + "welcome.html", + { + "url": "https://www.meteoblue.com/en/meteotv/d7b0fd", + "zoom": 1.0, + "enabled": false + }, + { + "name": "Cancom_Leitsatz.JPG", + "enabled": true + }, + { + "url": "http://localhost:5005/static/heise.html", + "zoom": 0.9, + "enabled": false + } + ], + "newsticker_text": "Herzlich Willkommen bei der CANCOM - wir begr\u00fc\u00dfen unsere G\u00e4ste und w\u00fcnschen Ihnen einen angenehmen Tag! !", + "newsticker_enabled": true, + "custom_url": "https://dva-stage.cancom-ds.de/voice-agent", + "custom_url_label": "Welcome Agent", + "custom_url_enabled": true, + "custom_url_target": "redirect" }, - { - "url": "https://www.meteoblue.com/en/meteotv/d7b0fd", - "zoom": 1.0, - "enabled": false + "casino": { + "interval": 15, + "show_images": true, + "show_videos": true, + "playlist": [ + { + "url": "https://www.meteoblue.com/en/meteotv/d7b0fd", + "zoom": 1.0, + "enabled": true + }, + { + "name": "Video_CANCOM_LIVE_2025_Stuttgart.MP4", + "enabled": true + } + ], + "newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag", + "newsticker_enabled": true, + "custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd", + "custom_url_label": "Wetter", + "custom_url_enabled": false }, - { - "name": "Cancom_Leitsatz.JPG", - "enabled": false - }, - { - "url": "http://localhost:5005/static/heise.html", - "zoom": 0.9, - "enabled": false - }, - { - "name": "heise.html", - "enabled": false + "videosysteme": { + "interval": 20, + "show_images": false, + "show_videos": false, + "playlist": [], + "newsticker_text": "Hallo dies ist ein Test f\u00fcr Michael", + "newsticker_enabled": false } - ], - "newsticker_text": "Herzlich Willkommen bei der CANCOM - wir begr\u00fc\u00dfen unsere G\u00e4ste und w\u00fcnschen Ihnen einen angenehmen Tag! !", - "newsticker_enabled": true + } }, - "casino": { - "interval": 15, - "show_images": true, - "show_videos": true, - "playlist": [ - { - "url": "https://www.meteoblue.com/en/meteotv/d7b0fd", - "zoom": 1.0, - "enabled": true - }, - { - "name": "Video_CANCOM_LIVE_2025_Stuttgart.MP4", - "enabled": true + "karlsruhe": { + "screens": { + "lobby": { + "playlist": [ + "welcome.html" + ], + "interval": 10, + "show_images": false, + "show_videos": false, + "newsticker_text": "Herzlich Willkommen bei der CANCOM - wir begr\u00fc\u00dfen unsere G\u00e4ste und w\u00fcnschen Ihnen einen angenehmen Tag!", + "newsticker_enabled": true } - ], - "newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag", - "newsticker_enabled": true - }, - "videosysteme": { - "interval": 20, - "show_images": false, - "show_videos": false, - "playlist": [], - "newsticker_text": "Hallo dies ist ein Test f\u00fcr Michael", - "newsticker_enabled": false + } } }, "admin": { "username": "admin", "password": "cancom@2026!" + }, + "screens": { + "lobby": { + "playlist": [ + "welcome.html" + ] + } } } \ No newline at end of file diff --git a/generate_welcome_page.py b/generate_welcome_page.py index 91b0c21..4a41f4b 100644 --- a/generate_welcome_page.py +++ b/generate_welcome_page.py @@ -6,9 +6,8 @@ from openai import OpenAI # Customer / Willkommensseite # ------------------------------------------------- -WELCOME_DIR = os.path.join(os.path.dirname(__file__), "media", "lobby") - -os.makedirs(WELCOME_DIR, exist_ok=True) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MEDIA_DIR = os.path.join(BASE_DIR, "media") WELCOME_FILENAME = "welcome.html" @@ -73,129 +72,82 @@ def search_customer_logo(customer_name): return None -def generate_welcome_html(customer_name, logo_url): - print(f"✅ Generate - Willkommensseite wird generiert für: {customer_name} mit Logo: {logo_url}") - """Generate a welcome page HTML with full-screen design""" - # CANCOM SVG logo inline +def generate_welcome_html(customer_names, logo_urls, site="stuttgart"): + site_display = site.capitalize() + names_str = ", ".join(customer_names) if customer_names else "Unbekannt" + print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})") + site_display = site.capitalize() cancom_svg = '''''' + logo_html = "" + for i, (name, url) in enumerate(zip(customer_names, logo_urls)): + if url: + logo_html += f''' +
+ +
''' + html_content = f""" Willkommen bei CANCOM - - - - @@ -205,13 +157,15 @@ def generate_welcome_html(customer_name, logo_url):
meets
- {f'' if logo_url else ''} +
+ {logo_html} +
Herzlich Willkommen
in unserer
- Niederlassung Stuttgart! + Niederlassung {site_display}!
@@ -220,42 +174,51 @@ def generate_welcome_html(customer_name, logo_url): return html_content -def save_welcome_page(customer_name, logo_url): +def save_welcome_page(customer_names, logo_urls, site="stuttgart"): """Save welcome page and return filename""" try: - filepath = os.path.join(os.path.abspath(WELCOME_DIR), WELCOME_FILENAME) - html_content = generate_welcome_html(customer_name, logo_url) + welcome_dir = os.path.join(MEDIA_DIR, site, "lobby") + os.makedirs(welcome_dir, exist_ok=True) + filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME) + html_content = generate_welcome_html(customer_names, logo_urls, site=site) with open(filepath, "w", encoding="utf-8") as f: f.write(html_content) if os.path.getsize(filepath) == 0: raise IOError("File not written") - print(f"✅ Generate - Willkommensseite generiert für: {customer_name}") + names_str = ", ".join(customer_names) + print(f"✅ Generate - Willkommensseite generiert für: {names_str} (site: {site})") print(f"✅ Generate - Willkommensseite gespeichert in: {filepath}") - print(f"✅ Generate - Willkommensseite gespeichert: {WELCOME_FILENAME}") return WELCOME_FILENAME except Exception as e: print(f"❌ Error saving welcome page: {e}") return None -def add_customer_to_lobby_playlist(html_filename): - """Add customer welcome page to lobby playlist""" +def add_customer_to_lobby_playlist(html_filename, site="stuttgart"): + """Add customer welcome page to lobby playlist for a specific site""" try: # Import here to avoid circular imports from app import load_config, save_config config = load_config() - screens = config.setdefault("screens", {}) + sites_cfg = config.setdefault("sites", {}) + site_cfg = sites_cfg.setdefault(site, {"screens": {}}) + screens = site_cfg.setdefault("screens", {}) lobby = screens.setdefault("lobby", {}) playlist = lobby.setdefault("playlist", []) full_filename = f"{html_filename}" - if full_filename in playlist: - playlist.remove(full_filename) + playlist[:] = [ + item for item in playlist + if not ( + (isinstance(item, str) and item == full_filename) or + (isinstance(item, dict) and item.get("name") == full_filename) + ) + ] playlist.insert(0, full_filename) save_config(config) - print(f"✅ Generate - Willkommensseite in Lobby-Playliste platziert") + print(f"✅ Generate - Willkommensseite in Lobby-Playliste platziert (site: {site})") return True except Exception as e: print(f"❌ Generate - Error adding to playlist: {e}") diff --git a/templates/_footer.html b/templates/_footer.html new file mode 100644 index 0000000..28eb16d --- /dev/null +++ b/templates/_footer.html @@ -0,0 +1,12 @@ + +
+ +
diff --git a/templates/_header.html b/templates/_header.html new file mode 100644 index 0000000..e786e2b --- /dev/null +++ b/templates/_header.html @@ -0,0 +1,33 @@ + + diff --git a/templates/_styles.html b/templates/_styles.html new file mode 100644 index 0000000..02b8353 --- /dev/null +++ b/templates/_styles.html @@ -0,0 +1,239 @@ + diff --git a/templates/admin.html b/templates/admin.html index 8e86d66..c0e9ba0 100755 --- a/templates/admin.html +++ b/templates/admin.html @@ -5,71 +5,47 @@ CANCOM Simple Signage Admin - + + - - + - - +{% include "_styles.html" %} + -
- - - - -
-
- - - - -
-
- -
⚠ PRIORITY Playlist
-
wirkt auf alle Player
-
-
- -
-

- Inhalte dieser Playlist werden auf allen Playern - zusätzlich zur normalen Playlist angezeigt. -
- Die Wiedergabe erfolgt abwechselnd - (Normal → Priority → Normal → …). -

- - -

Medien hochladen - ⚠ PRIORITY Playlist

-
-
- - + + + +
+
{% for screen, cfg in screens.items() %} -
+
-
Screen: +
Screen:
 - {{ screen }} -
-
https://signage.ccmake.de/player/{{ screen }}
+
http://localhost:5005/player/{{ current_site }}/{{ screen }}
+
{% if screen_status[screen] == "active" %} Aktiv {% else %} Leer {% endif %} +
+ +
+

Einstellungen

-
+
Videos anzeigen
+
+

Aktions-Button (Player)

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ @@ -329,7 +213,7 @@

Medien hochladen / URL hinzufügen - {{ screen }}

-
@@ -337,7 +221,7 @@
-
+

- +

Playlist Reihenfolge - {{ screen }}

@@ -369,7 +253,7 @@ {% if file.type == "image" %} data-bs-toggle="tooltip" data-bs-html="true" - title="Vorschau" + title="Vorschau" {% elif file.type == "url" %} data-bs-toggle="tooltip" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" @@ -394,7 +278,7 @@ - @@ -422,11 +306,7 @@
-
-
- © {{ year }} CANCOM GmbH · Version {{ version }} · {{ hostname }} -
-
+ {% include "_footer.html" %}
@@ -450,12 +330,7 @@ document.addEventListener("DOMContentLoaded", function () { initSortable("{{ screen }}"); {% endfor %} - // ✅ Priority explizit - initSortable("priority"); - - - - function savePlaylist(screen) { + function savePlaylist(site, screen) { const items = document.querySelectorAll( "#playlist-" + screen + " li" ); @@ -469,7 +344,7 @@ document.addEventListener("DOMContentLoaded", function () { playlist.push(fileName); }); - fetch("/admin/playlist/" + screen, { + fetch("/admin/" + site + "/playlist/" + screen, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -487,6 +362,28 @@ document.addEventListener("DOMContentLoaded", function () { return new bootstrap.Tooltip(tooltipTriggerEl); }); + // Dark Mode Toggle + const themeToggle = document.getElementById("theme-toggle"); + const root = document.documentElement; + + const syncThemeToggle = () => { + const theme = root.getAttribute("data-bs-theme") || "light"; + if (!themeToggle) return; + themeToggle.innerHTML = theme === "dark" + ? '' + : ''; + }; + + if (themeToggle) { + syncThemeToggle(); + themeToggle.addEventListener("click", () => { + const nextTheme = (root.getAttribute("data-bs-theme") || "light") === "dark" ? "light" : "dark"; + root.setAttribute("data-bs-theme", nextTheme); + window.localStorage.setItem("signage-theme", nextTheme); + syncThemeToggle(); + }); + } + }); diff --git a/templates/customer.html b/templates/customer.html index 86ded99..0997001 100644 --- a/templates/customer.html +++ b/templates/customer.html @@ -3,131 +3,173 @@ - Kunde hinzufügen + Willkommensseite + + + + {% include "_styles.html" %} +
- +
+
-
+
Willkommensseite
-

Kundeninformationen eingeben

+

Kundeninformationen eingeben (max. 3)

{% if error %} -
- {{ error }} -
+
{{ error }}
{% endif %} {% if success %} -
- {{ success }} -
+
{{ success }}
{% endif %} - {% if logo_url %} -
-
- Logo Vorschau -
Wenn das Logo erfolgreich gefunden wurde, wird es hier angezeigt.
+ +
+ {% for i in range(3) %} +
+ +
+ {% if logo_urls and logo_urls[i] %} + Logo {{ i + 1 }} + {% else %} +
kein Logo
+ {% endif %}
- Logo Vorschau
- {% endif %} + {% endfor %} +
-
- - + +
+
+ + +
+
+ + +
+
+ + +
-

Das System sucht automatisch nach dem Kundenlogo

+

Das System sucht automatisch nach den Kundenlogos

und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.

+ +
+ {% include "_footer.html" %} +
+ + diff --git a/templates/player.html b/templates/player.html index a4f7013..ffbe87c 100755 --- a/templates/player.html +++ b/templates/player.html @@ -16,7 +16,7 @@ overflow: hidden; } - img, video, iframe { + img, video, #iframe { width: 100vw; height: 100vh; background: black; @@ -28,7 +28,7 @@ z-index: 1; } - img, iframe { + img, #iframe { object-fit: contain; border: 0; } @@ -71,6 +71,36 @@ + + +{% if custom_url and custom_url_enabled %} +{% if custom_url_target == "redirect" %} + + {{ custom_url_label or "Info" }} + +{% else %} + +{% endif %} +{% endif %} + @@ -88,6 +118,20 @@ {% endif %} + + + + + + +{% include "_styles.html" %} + + + + +
+ +{% set brand_subtitle = "Priority Playlist" %} +{% set site_dropdown_url_prefix = "/admin/" %} +{% include "_header.html" %} + + + + + +
+
+ + + + +
+
+ +
⚠ PRIORITY Playlist
+
wirkt auf alle Player
+
+
+ +
+

+ Inhalte dieser Playlist werden auf allen Playern + zusätzlich zur normalen Playlist angezeigt. +
+ Die Wiedergabe erfolgt abwechselnd + (Normal → Priority → Normal → …). +

+ + +

Medien hochladen

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ + +

Playlist Reihenfolge

+ +
    + {% for file in priority_files %} +
  • + + + + + {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} [{{ file.zoom }}x]{% endif %} + + + {% if file.type == "image" %} + Bild + {% elif file.type == "url" or file.type == "html" %} + URL + {% else %} + Video + {% endif %} + + + {% if file.type == "url" %} + {{ file.size }} + {% else %} + {{ file.size }} KB + {% endif %} + + + +
    + +
    + + + +
  • + {% endfor %} +
+ + + +
+
+ +
+
+ + +{% include "_footer.html" %} + +
+ + + + +