Positionen Action Button und DVA Button hinzugefügt

This commit is contained in:
Erik Thiele
2026-06-19 12:09:50 +02:00
parent 59be0fd789
commit 35e22718e3
10 changed files with 2940 additions and 2308 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -38,6 +38,8 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen | | `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen | | `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen | | `GET /add-site?name=<name>` | Neuen Standort anlegen |
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL + Position) |
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position) |
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen | | `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen |
## Behavior To Preserve ## Behavior To Preserve
@@ -58,12 +60,19 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `add_customer_to_lobby_playlist` entfernt `welcome.html` sowohl als String als auch als Dict aus der Playlist vor dem Einfügen. - `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/<site>/<screen>/` an und einen Config-Eintrag. - `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag.
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv. - `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_enabled` + `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. - Custom-URL-Button: pro Screen konfigurierbar (`custom_url` + `custom_url_label` + `custom_url_enabled` + `custom_url_target` + `custom_url_position`) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) 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. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.
- Voice-Agent-Button: globale `config.voice_agent_url` wird pro Screen im Tab "Digital Voice Agent" konfiguriert; Einstellungen (`voice_agent_enabled`, `voice_agent_label`, `voice_agent_target`, `voice_agent_position`) funktionieren identisch zum Aktionen-Tab. Die URL ist read-only im Admin sichtbar und wird global in `config.json` gesetzt.
- Voice-Agent-Typewriter: Tagline im Button zeigt wechselnde mehrsprachige Texte mit Buchstaben-für-Buchstaben-Effekt; nach 2,5s Pause fade-out über 0,8s, dann nächster Text.
- Voice-Agent-Overlay: positioniert den Zurück-Button exakt an der Position des geklickten Buttons (identisches Positionssystem).
- `voice_agent_show_image` (per Screen): optionales Bild (`static/dva.png`) über dem Button, per Admin-Toggle schaltbar (Beta).
- Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden. - Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden.
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv. - `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv.
- `config.json["server_url"]` (z.B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet. - `config.json["server_url"]` (z.B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Medien** (4); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**. - Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Medien. - `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien.
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe. - Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
## Repo Quirks ## Repo Quirks
@@ -78,3 +87,5 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `welcome.html` wird in `media/<site>/lobby/` gespeichert. - `welcome.html` wird in `media/<site>/lobby/` gespeichert.
- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN. - `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN.
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`. - `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.
- `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).

View File

@@ -10,12 +10,14 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens - **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player - **Priority-Playlist**: globale Inhalte wirken auf alle Player
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch - **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge - Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
- Auto-Reload bei Playlist-Änderungen - Auto-Reload bei Playlist-Änderungen
- Newsticker pro Screen - 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) - **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Medien (Tabler Tabs) - **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien (Tabler Tabs)
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien - Priority-Seite ebenfalls mit Tabs: Playlist und Medien
- Dark Mode (localStorage-persistiert) - Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot) - CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
@@ -74,6 +76,7 @@ signage/
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode) │ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
├── static/ ├── static/
│ ├── cancom.svg │ ├── cancom.svg
│ ├── dva.png
│ └── wallpaper.png │ └── wallpaper.png
└── AGENTS.md └── AGENTS.md
``` ```
@@ -114,13 +117,15 @@ docker compose up -d
| Route | Beschreibung | | Route | Beschreibung |
|-------|-------------| |-------|-------------|
| `GET /` | 404 kein Root-Endpunkt | | `GET /` | Weiterleitung zum Login |
| `GET /login` | Admin-Login | | `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen | | `GET /logout` | Ausloggen |
| `GET /admin` | Redirect zum ersten konfigurierten Standort | | `GET /admin` | Redirect zum ersten konfigurierten Standort |
| `GET /admin/<site>` | Admin-Dashboard für einen Standort | | `GET /admin/<site>` | Admin-Dashboard für einen Standort |
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) | | `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
| `POST /admin/<site>/update/<screen>` | Screen-Einstellungen speichern | | `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL) |
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position, Bild) |
| `POST /admin/<site>/upload/<screen>` | Medien hochladen | | `POST /admin/<site>/upload/<screen>` | Medien hochladen |
| `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen | | `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen |
| `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen | | `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen |
@@ -129,12 +134,15 @@ docker compose up -d
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen | | `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen | | `GET /add-site?name=<name>` | Neuen Standort anlegen |
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen | | `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen |
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht | | `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) | | `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) | | `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/priority/<file>` | Priority-Medien (global) |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Admin-Portal ### Admin-Portal
@@ -142,12 +150,14 @@ docker compose up -d
http://localhost:5005/admin/<standort> http://localhost:5005/admin/<standort>
``` ```
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Medien) - Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien)
- Medien hochladen / löschen - Medien hochladen / löschen
- Playlist per Drag & Drop sortieren - Playlist per Drag & Drop sortieren
- Priority-Playlist verwalten (ebenfalls mit Tabs) - Priority-Playlist verwalten (ebenfalls mit Tabs)
- Willkommensseite generieren (bis zu 3 Kundenlogos) - Willkommensseite generieren (bis zu 3 Kundenlogos, Hintergrundbild pro Standort)
- Custom-URL-Aktionsbutton pro Screen konfigurieren (iframe-Overlay oder Weiterleitung) - Custom-URL-Aktionsbutton pro Screen konfigurieren (Position, iframe-Overlay oder Weiterleitung)
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
- Standorte anlegen & löschen - Standorte anlegen & löschen
### Player-URL ### Player-URL
@@ -167,15 +177,23 @@ http://localhost:5005/player/stuttgart/lobby
http://localhost:5005/willkommen?site=stuttgart 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. Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
--- ---
### Globale Konfiguration
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `server_url` | string | Öffentliche Server-URL für Player-Links |
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
## Konfiguration (`config.json`) ## Konfiguration (`config.json`)
```json ```json
{ {
"server_url": "http://signage.ccmake.de", "server_url": "http://signage.ccmake.de",
"voice_agent_url": "https://voice-agent.example.com",
"admin": { "username": "...", "password": "..." }, "admin": { "username": "...", "password": "..." },
"sites": { "sites": {
"stuttgart": { "stuttgart": {
@@ -190,9 +208,21 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
"custom_url_enabled": false, "custom_url_enabled": false,
"custom_url": "https://...", "custom_url": "https://...",
"custom_url_label": "Infos", "custom_url_label": "Infos",
"custom_url_target": "overlay" "custom_url_target": "overlay",
"custom_url_position": "top-left",
"stay_on_first": false,
"voice_agent_enabled": true,
"voice_agent_show_image": false,
"voice_agent_label": "Digitaler Assistent",
"voice_agent_target": "overlay",
"voice_agent_position": "top-left"
}, },
"casino": { "playlist": [...], "interval": 15 } "casino": { "playlist": [...], "interval": 15 }
},
"welcome_data": {
"names": ["Firma A"],
"logo_urls": ["https://..."],
"background_url": null
} }
} }
}, },
@@ -219,6 +249,13 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
| `custom_url` | string | URL des Aktions-Buttons | | `custom_url` | string | URL des Aktions-Buttons |
| `custom_url_label` | string | Button-Beschriftung | | `custom_url_label` | string | Button-Beschriftung |
| `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` | | `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
| `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) |
| `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen |
| `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen |
| `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") |
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
## Helper-Funktionen (`app.py`) ## Helper-Funktionen (`app.py`)
@@ -231,6 +268,7 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
- `playlist_item_enabled(item)` Enabled-Status prüfen - `playlist_item_enabled(item)` Enabled-Status prüfen
- `load_priority_files()` Priority-Playlist + Dateien laden - `load_priority_files()` Priority-Playlist + Dateien laden
- `prio_redirect(site)` Redirect-Pfad zur Priority-Seite - `prio_redirect(site)` Redirect-Pfad zur Priority-Seite
- `get_background_url(site)` URL zum benutzerdefinierten Hintergrundbild oder `None`
--- ---

61
app.py
View File

@@ -35,7 +35,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
APP_VERSION = "5.1.0" APP_VERSION = "5.5.0"
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
app = Flask(__name__) app = Flask(__name__)
@@ -534,7 +534,15 @@ def player(site, screen):
custom_url=screen_cfg.get("custom_url", ""), custom_url=screen_cfg.get("custom_url", ""),
custom_url_label=screen_cfg.get("custom_url_label", ""), custom_url_label=screen_cfg.get("custom_url_label", ""),
custom_url_enabled=screen_cfg.get("custom_url_enabled", False), custom_url_enabled=screen_cfg.get("custom_url_enabled", False),
custom_url_target=screen_cfg.get("custom_url_target", "overlay") custom_url_target=screen_cfg.get("custom_url_target", "overlay"),
custom_url_position=screen_cfg.get("custom_url_position", "top-left"),
stay_on_first=screen_cfg.get("stay_on_first", False),
voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False),
voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False,
voice_agent_url=config.get("voice_agent_url", ""),
voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"),
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left")
) )
@@ -565,7 +573,13 @@ def playlist_hash(site, screen):
# Admin # Admin
# ------------------------------------------------- # -------------------------------------------------
@app.route("/admin") @app.route("/", strict_slashes=False)
def root_redirect():
"""Root weiterleiten zum Login."""
return redirect("/login")
@app.route("/admin", strict_slashes=False)
@login_required @login_required
def admin_redirect(): def admin_redirect():
"""Leitet zum ersten konfigurierten Standort weiter.""" """Leitet zum ersten konfigurierten Standort weiter."""
@@ -609,7 +623,7 @@ def delete_site(site):
return redirect(f"/admin/{sites[0]}" if sites else "/admin") return redirect(f"/admin/{sites[0]}" if sites else "/admin")
@app.route("/admin/<site>") @app.route("/admin/<site>", strict_slashes=False)
@login_required @login_required
def admin(site): def admin(site):
""" """
@@ -730,7 +744,8 @@ def admin(site):
version=APP_VERSION, version=APP_VERSION,
year=datetime.now().year, year=datetime.now().year,
hostname=os.uname().nodename, hostname=os.uname().nodename,
server_url=cfg.get("server_url", "") server_url=cfg.get("server_url", ""),
voice_agent_url=cfg.get("voice_agent_url", "")
) )
@@ -812,7 +827,7 @@ def delete_background(site):
@app.route("/admin/<site>/update/<screen>", methods=["POST"]) @app.route("/admin/<site>/update/<screen>", methods=["POST"])
@login_required @login_required
def update_screen(site, screen): def update_screen(site, screen):
"""Speichert die Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos, Custom-URL).""" """Speichert die allgemeinen Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos)."""
config = load_config() config = load_config()
cfg = get_screen_config(config, site, screen) cfg = get_screen_config(config, site, screen)
@@ -821,10 +836,44 @@ def update_screen(site, screen):
cfg["show_videos"] = "show_videos" in request.form cfg["show_videos"] = "show_videos" in request.form
cfg["newsticker_text"] = request.form.get("newsticker_text", "") cfg["newsticker_text"] = request.form.get("newsticker_text", "")
cfg["newsticker_enabled"] = "newsticker_enabled" in request.form cfg["newsticker_enabled"] = "newsticker_enabled" in request.form
cfg["stay_on_first"] = "stay_on_first" in request.form
save_config(config)
return redirect(f"/admin/{site}")
@app.route("/admin/<site>/update-actions/<screen>", methods=["POST"])
@login_required
def update_screen_actions(site, screen):
"""Speichert die Aktionen-Einstellungen für einen Screen (Custom-URL + Position)."""
config = load_config()
cfg = get_screen_config(config, site, screen)
cfg["custom_url"] = request.form.get("custom_url", "").strip() cfg["custom_url"] = request.form.get("custom_url", "").strip()
cfg["custom_url_label"] = request.form.get("custom_url_label", "").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_enabled"] = "custom_url_enabled" in request.form
cfg["custom_url_target"] = request.form.get("custom_url_target", "overlay") cfg["custom_url_target"] = request.form.get("custom_url_target", "overlay")
cfg["custom_url_position"] = request.form.get("custom_url_position", "top-left")
save_config(config)
return redirect(f"/admin/{site}")
# -------------------------------------------------
# Admin: Voice-Agent-Einstellungen speichern
# -------------------------------------------------
@app.route("/admin/<site>/update-voice/<screen>", methods=["POST"])
@login_required
def update_screen_voice(site, screen):
"""Speichert die Voice-Agent-Einstellungen für einen Screen (Enabled, Label, Target, Position)."""
config = load_config()
cfg = get_screen_config(config, site, screen)
cfg["voice_agent_enabled"] = "voice_agent_enabled" in request.form
cfg["voice_agent_show_image"] = "voice_agent_show_image" in request.form
cfg["voice_agent_label"] = request.form.get("voice_agent_label", "").strip()
cfg["voice_agent_target"] = request.form.get("voice_agent_target", "overlay")
cfg["voice_agent_position"] = request.form.get("voice_agent_position", "top-left")
save_config(config) save_config(config)
return redirect(f"/admin/{site}") return redirect(f"/admin/{site}")

View File

@@ -1,5 +1,6 @@
{ {
"server_url": "http://localhost:5005", "server_url": "http://localhost:5005",
"voice_agent_url": "https://dva-stage.buds.cancom.ai/",
"priority": { "priority": {
"enabled": true, "enabled": true,
"playlist": [ "playlist": [
@@ -38,8 +39,14 @@
"newsticker_enabled": false, "newsticker_enabled": false,
"custom_url": "https://dva-stage.cancom-ds.de/voice-agent", "custom_url": "https://dva-stage.cancom-ds.de/voice-agent",
"custom_url_label": "Welcome Agent", "custom_url_label": "Welcome Agent",
"custom_url_enabled": true, "custom_url_enabled": false,
"custom_url_target": "redirect" "custom_url_target": "redirect",
"voice_agent_enabled": true,
"voice_agent_label": "Digitaler Assistent",
"voice_agent_target": "overlay",
"voice_agent_position": "top-left",
"custom_url_position": "top-left",
"voice_agent_show_image": false
}, },
"casino": { "casino": {
"interval": 15, "interval": 15,
@@ -88,47 +95,61 @@
"screens": { "screens": {
"lobby": { "lobby": {
"playlist": [ "playlist": [
"welcome.html" "welcome.html",
{
"url": "https://dva-stage.cancom-ds.de/",
"zoom": 1.0,
"enabled": false
}
], ],
"interval": 10, "interval": 10,
"show_images": false, "show_images": true,
"show_videos": false, "show_videos": true,
"newsticker_text": "Herzlich Willkommen bei der CANCOM - wir begr\u00fc\u00dfen unsere G\u00e4ste und w\u00fcnschen Ihnen einen angenehmen Tag!", "newsticker_text": "Wichtige Mitteilung",
"newsticker_enabled": true "newsticker_enabled": true,
"custom_url": "https://dva-stage.cancom-ds.de/",
"custom_url_label": "Sprich mit mir",
"custom_url_enabled": false,
"custom_url_target": "overlay",
"brand_symbol_enabled": false,
"stay_on_first": false
} }
}, },
"welcome_data": { "welcome_data": {
"names": [ "names": [
"cisco" "weisenburger"
], ],
"logo_urls": [ "logo_urls": [
"https://cdn.brandfetch.io/www.cisco.com/logo?c=1idyd4Tpb2nKaXIIc8T" "https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
] ]
} }
}, },
"holthaus": { "dva": {
"screens": { "screens": {
"dva": { "dva": {
"playlist": [ "playlist": [
{ {
"url": "https://dva.cancom-ds.de", "url": "https://dva-stage.cancom-ds.de/",
"zoom": 1.0, "zoom": 1.0,
"enabled": true "enabled": true
},
{
"name": "map_Karlsruhe.png",
"enabled": true
} }
], ],
"interval": 10, "interval": 10,
"show_images": false, "show_images": true,
"show_videos": false, "show_videos": true,
"newsticker_text": "", "newsticker_text": "Wichtige Meldung",
"newsticker_enabled": false, "newsticker_enabled": false,
"custom_url": "https://www.cancom.de", "stay_on_first": true,
"custom_url_label": "DVA Agent", "custom_url": "https://npm.teamthiele.de/nginx/proxy",
"custom_url_enabled": true, "custom_url_label": "Reverse Proxy",
"custom_url_target": "redirect" "custom_url_enabled": false,
"custom_url_target": "overlay",
"custom_url_position": "top-center",
"voice_agent_enabled": true,
"voice_agent_label": "",
"voice_agent_target": "overlay",
"voice_agent_position": "top-left",
"voice_agent_show_image": false
} }
} }
} }

File diff suppressed because it is too large Load Diff

56
static/brand-symbol.html Normal file
View File

@@ -0,0 +1,56 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 100vw; height: 100vh;
display: flex; align-items: center; justify-content: center;
background: transparent;
overflow: hidden;
}
.logo-wrap {
width: 80px; height: 80px;
position: relative;
}
.logo-wrap svg {
width: 100%; height: 100%;
}
.line {
fill: none;
stroke: #DA002D;
stroke-width: 6;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 600;
stroke-dashoffset: 600;
animation: draw 3s ease-in-out infinite alternate;
}
.line:nth-child(2) { animation-delay: 0.3s; }
.line:nth-child(3) { animation-delay: 0.6s; }
.line:nth-child(4) { animation-delay: 0.9s; }
.line:nth-child(5) { animation-delay: 1.2s; }
.line:nth-child(6) { animation-delay: 1.5s; }
.line:nth-child(7) { animation-delay: 1.8s; }
@keyframes draw {
0% { stroke-dashoffset: 600; opacity: 0.2; }
50% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 600; opacity: 0.2; }
}
</style>
</head>
<body>
<div class="logo-wrap">
<svg viewBox="0 0 430 430" xmlns="http://www.w3.org/2000/svg">
<path class="line" d="M160,250 C160,250 160,180 160,180 C160,180 160,320 160,320" />
<path class="line" d="M205,130 C205,130 205,370 205,370" />
<path class="line" d="M250,250 C250,250 250,195 250,195 C250,195 250,305 250,305" />
<path class="line" d="M295,120 C295,120 295,380 295,380" />
<path class="line" d="M340,250 C340,250 340,185 340,185 C340,185 340,315 340,315" />
<path class="line" d="M360,295 C360,295 360,210 360,210 C360,210 385,275 385,275" />
<path class="line" d="M100,280 C100,280 100,220 100,220 C100,220 100,280 100,280" />
</svg>
</div>
</body>
</html>

View File

@@ -24,7 +24,7 @@
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for s in site_list %} {% for s in site_list %}
<a class="dropdown-item{% if s == current_site %} active{% endif %}" <a class="dropdown-item{% if s == current_site %} active{% endif %}"
href="{{ site_dropdown_url_prefix }}{{ s }}">{{ s | capitalize }}</a> href="{{ site_dropdown_url_prefix }}{{ s }}"><i class="ti ti-building me-1"></i>{{ s | capitalize }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -73,6 +73,24 @@
color: var(--ccm-primary); color: var(--ccm-primary);
} }
/* ─── Dropdown-Hover (grauer Hintergrund) ─── */
.dropdown-item:hover,
.dropdown-item:focus {
background-color: #e9ecef !important;
color: inherit !important;
}
.dropdown-item.active,
.dropdown-item:active {
background-color: #e9ecef !important;
color: inherit !important;
}
[data-bs-theme="dark"] .dropdown-item:hover,
[data-bs-theme="dark"] .dropdown-item:focus,
[data-bs-theme="dark"] .dropdown-item.active,
[data-bs-theme="dark"] .dropdown-item:active {
background-color: #3a3f45 !important;
}
/* ─── Drag-Handle für SortableJS ─── */ /* ─── Drag-Handle für SortableJS ─── */
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
@@ -186,7 +204,7 @@
</div> </div>
</div> </div>
<!-- Card-Body: Tabs (Playlist | Einstellungen | Medien) --> <!-- Card-Body: Tabs (Playlist | Einstellungen | Aktionen | Digital Voice Agent | Medien) -->
<div class="card-body"> <div class="card-body">
<ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist"> <ul class="nav nav-tabs" data-bs-toggle="tabs" role="tablist">
@@ -205,6 +223,11 @@
<i class="ti ti-click me-1"></i>Aktionen <i class="ti ti-click me-1"></i>Aktionen
</a> </a>
</li> </li>
<li class="nav-item" role="presentation">
<a href="#voice-{{ screen }}" class="nav-link" data-bs-toggle="tab" role="tab">
<i class="ti ti-robot me-1"></i>Digital Voice Agent
</a>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a href="#media-{{ screen }}" class="nav-link" data-bs-toggle="tab" role="tab"> <a href="#media-{{ screen }}" class="nav-link" data-bs-toggle="tab" role="tab">
<i class="ti ti-upload me-1"></i>Medien <i class="ti ti-upload me-1"></i>Medien
@@ -315,6 +338,13 @@
<label class="form-check-label">Videos anzeigen</label> <label class="form-check-label">Videos anzeigen</label>
</div> </div>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox"
name="stay_on_first"
{% if cfg.stay_on_first %}checked{% endif %}>
<label class="form-check-label">Auf erstem Element stehen bleiben (kein Refresh)</label>
</div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
Einstellungen speichern Einstellungen speichern
</button> </button>
@@ -323,7 +353,7 @@
<!-- ═══ Tab: Aktionen ═══ --> <!-- ═══ Tab: Aktionen ═══ -->
<div class="tab-pane" id="actions-{{ screen }}" role="tabpanel"> <div class="tab-pane" id="actions-{{ screen }}" role="tabpanel">
<form action="/admin/{{ current_site }}/update/{{ screen }}" method="post"> <form action="/admin/{{ current_site }}/update-actions/{{ screen }}" method="post">
<h3>Aktions-Button (Player)</h3> <h3>Aktions-Button (Player)</h3>
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" <input class="form-check-input" type="checkbox"
@@ -348,6 +378,84 @@
<option value="redirect" {% if cfg.custom_url_target|default('overlay') == 'redirect' %}selected{% endif %}>Weiterleitung (gleiches Fenster)</option> <option value="redirect" {% if cfg.custom_url_target|default('overlay') == 'redirect' %}selected{% endif %}>Weiterleitung (gleiches Fenster)</option>
</select> </select>
</div> </div>
<div class="mb-4">
<label class="form-label">Position</label>
<select name="custom_url_position" class="form-select">
<optgroup label="Oben">
<option value="top-left" {% if cfg.custom_url_position|default('top-left') == 'top-left' %}selected{% endif %}>Oben links</option>
<option value="top-center" {% if cfg.custom_url_position|default('top-left') == 'top-center' %}selected{% endif %}>Oben mitte</option>
<option value="top-right" {% if cfg.custom_url_position|default('top-left') == 'top-right' %}selected{% endif %}>Oben rechts</option>
</optgroup>
<optgroup label="Mitte">
<option value="middle-left" {% if cfg.custom_url_position|default('top-left') == 'middle-left' %}selected{% endif %}>Mitte links</option>
<option value="middle-center" {% if cfg.custom_url_position|default('top-left') == 'middle-center' %}selected{% endif %}>Mitte zentriert</option>
<option value="middle-right" {% if cfg.custom_url_position|default('top-left') == 'middle-right' %}selected{% endif %}>Mitte rechts</option>
</optgroup>
<optgroup label="Unten">
<option value="bottom-left" {% if cfg.custom_url_position|default('top-left') == 'bottom-left' %}selected{% endif %}>Unten links</option>
<option value="bottom-center" {% if cfg.custom_url_position|default('top-left') == 'bottom-center' %}selected{% endif %}>Unten mitte</option>
<option value="bottom-right" {% if cfg.custom_url_position|default('top-left') == 'bottom-right' %}selected{% endif %}>Unten rechts</option>
</optgroup>
</select>
</div>
<button type="submit" class="btn btn-primary">
Einstellungen speichern
</button>
</form>
</div>
<!-- ═══ Tab: Digital Voice Agent (pro Screen: Enabled, Label, Ziel, Position, Bild) ═══ -->
<div class="tab-pane" id="voice-{{ screen }}" role="tabpanel">
<form action="/admin/{{ current_site }}/update-voice/{{ screen }}" method="post">
<h3>Digital Voice Agent</h3>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox"
name="voice_agent_enabled"
{% if cfg.voice_agent_enabled %}checked{% endif %}>
<label class="form-check-label">Voice Agent anzeigen</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox"
name="voice_agent_show_image"
{% if cfg.voice_agent_show_image %}checked{% endif %}>
<label class="form-check-label">Bild (dva.png) über dem Button anzeigen <span class="badge bg-warning text-dark ms-1">Beta</span></label>
</div>
<div class="mb-2">
<label class="form-label">URL (fest in config.json definiert)</label>
<input type="url" class="form-control" value="{{ voice_agent_url|default('') }}" readonly>
</div>
<div class="mb-4">
<label class="form-label">Button-Beschriftung</label>
<input type="text" name="voice_agent_label" class="form-control"
placeholder="z. B. Digitaler Assistent" value="{{ cfg.voice_agent_label|default('Digitaler Assistent') }}" maxlength="50">
</div>
<div class="mb-4">
<label class="form-label">Verhalten bei Klick</label>
<select name="voice_agent_target" class="form-select">
<option value="overlay" {% if cfg.voice_agent_target|default('overlay') == 'overlay' %}selected{% endif %}>Im Player (iframe mit Zurück-Button)</option>
<option value="redirect" {% if cfg.voice_agent_target|default('overlay') == 'redirect' %}selected{% endif %}>Weiterleitung (gleiches Fenster)</option>
</select>
</div>
<div class="mb-4">
<label class="form-label">Position</label>
<select name="voice_agent_position" class="form-select">
<optgroup label="Oben">
<option value="top-left" {% if cfg.voice_agent_position|default('top-left') == 'top-left' %}selected{% endif %}>Oben links</option>
<option value="top-center" {% if cfg.voice_agent_position|default('top-left') == 'top-center' %}selected{% endif %}>Oben mitte</option>
<option value="top-right" {% if cfg.voice_agent_position|default('top-left') == 'top-right' %}selected{% endif %}>Oben rechts</option>
</optgroup>
<optgroup label="Mitte">
<option value="middle-left" {% if cfg.voice_agent_position|default('top-left') == 'middle-left' %}selected{% endif %}>Mitte links</option>
<option value="middle-center" {% if cfg.voice_agent_position|default('top-left') == 'middle-center' %}selected{% endif %}>Mitte zentriert</option>
<option value="middle-right" {% if cfg.voice_agent_position|default('top-left') == 'middle-right' %}selected{% endif %}>Mitte rechts</option>
</optgroup>
<optgroup label="Unten">
<option value="bottom-left" {% if cfg.voice_agent_position|default('top-left') == 'bottom-left' %}selected{% endif %}>Unten links</option>
<option value="bottom-center" {% if cfg.voice_agent_position|default('top-left') == 'bottom-center' %}selected{% endif %}>Unten mitte</option>
<option value="bottom-right" {% if cfg.voice_agent_position|default('top-left') == 'bottom-right' %}selected{% endif %}>Unten rechts</option>
</optgroup>
</select>
</div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
Einstellungen speichern Einstellungen speichern
</button> </button>

View File

@@ -77,39 +77,227 @@
Wenn der Aktions-Button angeklickt wird, öffnet Wenn der Aktions-Button angeklickt wird, öffnet
sich dieser Overlay-Bereich mit Zurück-Button. sich dieser Overlay-Bereich mit Zurück-Button.
═══════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════ -->
<div id="overlay" style="display:none;position:fixed;inset:0;z-index:99999;background:#fff;"> <div id="overlay" style="display:none;position:fixed;inset:0;z-index:99999;background:#000;">
<button onclick="closeOverlay()" <style>
style="position:absolute;top:16px;left:16px;z-index:10; /* Zurück-Button-Position: 9 Positionen, dynamisch per openOverlay(pos) gesetzt */
padding:8px 16px;background:#DA002D;color:#fff; #overlay-back.overlay-back-top-left { top: 16px; left: 16px; }
border:none;border-radius:6px;font-size:1rem;cursor:pointer;"> #overlay-back.overlay-back-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
#overlay-back.overlay-back-top-right { top: 16px; right: 16px; }
#overlay-back.overlay-back-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
#overlay-back.overlay-back-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
#overlay-back.overlay-back-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
#overlay-back.overlay-back-bottom-left { bottom: 16px; left: 16px; }
#overlay-back.overlay-back-bottom-center { bottom: 16px; left: 50%; transform: translateX(-50%); }
#overlay-back.overlay-back-bottom-right { bottom: 16px; right: 16px; }
</style>
<button id="overlay-back" onclick="closeOverlay()"
style="position:absolute;z-index:10;
padding:8px 20px;background:#fff;color:#000;
border:none;border-radius:20px;font-size:1rem;font-weight:600;
cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
← Zurück ← Zurück
</button> </button>
<iframe id="overlay-iframe" style="width:100%;height:100%;border:none;display:block;"></iframe> <iframe id="overlay-iframe" style="width:100%;height:100%;border:none;display:block;"></iframe>
</div> </div>
<!-- ─── Custom-URL-Button (oben links) ─── --> <!-- ─── Custom-URL-Button (Position: 9 Positionen oben/mitte/unten × links/mitte/rechts)
Bottom-Positionen weichen automatisch 16px über dem Newsticker-Balken aus ─── -->
{% if custom_url and custom_url_enabled %} {% if custom_url and custom_url_enabled %}
<style>
/* Einheitliches Button-Styling: Weiß, abgerundet, fetter Text (Custom-URL + Voice-Agent) */
.btn-pos-{{ screen }} {
position: fixed;
z-index: 9999;
padding: 10px 20px;
background: #fff;
color: #000;
border: none;
border-radius: 20px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.btn-pos-{{ screen }}-top-left { top: 16px; left: 16px; }
.btn-pos-{{ screen }}-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
.btn-pos-{{ screen }}-top-right { top: 16px; right: 16px; }
.btn-pos-{{ screen }}-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
.btn-pos-{{ screen }}-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
.btn-pos-{{ screen }}-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
.btn-pos-{{ screen }}-bottom-left { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; left: 16px; }
.btn-pos-{{ screen }}-bottom-center { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; left: 50%; transform: translateX(-50%); }
.btn-pos-{{ screen }}-bottom-right { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; right: 16px; }
</style>
{% if custom_url_target == "redirect" %} {% if custom_url_target == "redirect" %}
<!-- Direkt-Weiterleitung (verlässt den Player) -->
<a href="{{ custom_url }}" <a href="{{ custom_url }}"
style="position:fixed;top:16px;left:16px;z-index:9999; class="btn-pos-{{ screen }} btn-pos-{{ screen }}-{{ custom_url_position|default('top-left') }}">
padding:10px 20px;background:#DA002D;color:#fff;
border:none;border-radius:8px;font-size:1rem;cursor:pointer;
text-decoration:none;box-shadow:0 2px 8px rgba(0,0,0,0.3);">
{{ custom_url_label or "Info" }} {{ custom_url_label or "Info" }}
</a> </a>
{% else %} {% else %}
<!-- iframe-Overlay (bleibt im Player, Zurück-Button) --> <button onclick='openOverlay({{ custom_url | tojson }}, "{{ custom_url_position|default('top-left') }}")'
<button onclick='openOverlay({{ custom_url | tojson }})' class="btn-pos-{{ screen }} btn-pos-{{ screen }}-{{ custom_url_position|default('top-left') }}">
style="position:fixed;top:16px;left:16px;z-index:9999;
padding:10px 20px;background:#DA002D;color:#fff;
border:none;border-radius:8px;font-size:1rem;cursor:pointer;
box-shadow:0 2px 8px rgba(0,0,0,0.3);">
{{ custom_url_label or "Info" }} {{ custom_url_label or "Info" }}
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
<!-- ═══════════════════════════════════════════════════
Voice-Agent-Button (fix positioniert, 9 Positionen)
Typewriter-Tagline, optionales DVA-Bild, animierte
Balken, Verhalten overlay/redirect. Bottom-Positionen
weichen dem Newsticker aus.
═══════════════════════════════════════════════════ -->
{% if voice_agent_url and voice_agent_enabled %}
<style>
/* Voice-Agent-Wrap: Fixpositionierter Container, Column-Layout für Bild + Button */
.voice-wrap-{{ screen }} {
position: fixed;
z-index: 9998;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
line-height: 1.3;
min-width: 160px;
}
/* Typewriter-Tagline: Mehrsprachig, Buchstabe für Buchstabe, 0,8 s Fade-Out */
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-tagline-{{ screen }} {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #888;
height: 1.2em;
line-height: 1.2em;
white-space: nowrap;
transition: opacity 0.8s ease;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-label-{{ screen }} {
display: block;
font-size: 1.05rem;
font-weight: 600;
color: #000;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-tagline-{{ screen }} .cursor {
display: inline-block;
width: 2px;
height: 1em;
background: #DA002D;
margin-left: 2px;
animation: voice-blink-{{ screen }} 0.6s step-end infinite;
vertical-align: text-bottom;
}
@keyframes voice-blink-{{ screen }} {
50% { opacity: 0; }
}
/* Optionales DVA-Bild (dva.png) oberhalb des Buttons, Beta-Feature */
.voice-wrap-{{ screen }} .voice-img-{{ screen }} {
width: 200px;
aspect-ratio: 648 / 596;
margin-bottom: 6px;
background: url('/static/dva.png') no-repeat center/contain;
flex-shrink: 0;
}
/* Voice-Agent-Button: Weiß, 20 px Border-Radius, fett einheitlich mit Aktions-Button */
.voice-pos-{{ screen }} {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 20px;
gap: 12px;
min-width: 280px;
height: 56px;
background: #fff;
color: #000;
border: none;
border-radius: 20px;
font-size: 1.05rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.voice-pos-{{ screen }}:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.voice-pos-{{ screen }} .voice-icon {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: auto;
flex-shrink: 0;
}
/* Animierte Balken (5 Stück): Welle-Effekt mit abgestuften Verzögerungen */
.voice-pos-{{ screen }} .voice-icon .bar {
width: 3px;
border-radius: 2px;
background: #DA002D;
transform-origin: center bottom;
animation: voice-bar-{{ screen }} 0.6s ease-in-out infinite alternate;
}
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(1) { height: 10px; animation-delay: 0.0s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(2) { height: 16px; animation-delay: 0.2s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(3) { height: 22px; animation-delay: 0.4s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(4) { height: 14px; animation-delay: 0.1s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(5) { height: 8px; animation-delay: 0.3s; }
@keyframes voice-bar-{{ screen }} {
0% { transform: scaleY(0.4); }
100% { transform: scaleY(1.2); }
}
/* Voice-Agent-Positionen: 9 Positionen (oben/mitte/unten × links/mitte/rechts), Bottom weicht Newsticker aus */
.voice-wrap-{{ screen }}-top-left { top: 16px; left: 16px; }
.voice-wrap-{{ screen }}-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
.voice-wrap-{{ screen }}-top-right { top: 16px; right: 16px; }
.voice-wrap-{{ screen }}-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
.voice-wrap-{{ screen }}-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
.voice-wrap-{{ screen }}-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
.voice-wrap-{{ screen }}-bottom-left { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); left: 16px; }
.voice-wrap-{{ screen }}-bottom-center { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); left: 50%; transform: translateX(-50%); }
.voice-wrap-{{ screen }}-bottom-right { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); right: 16px; }
</style>
<!-- Voice-Agent-Wrap: Fixpositionierter Container mit Button + optionalem Bild -->
<div class="voice-wrap-{{ screen }} voice-wrap-{{ screen }}-{{ voice_agent_position|default('top-left') }}">
{% if voice_agent_show_image %}<span class="voice-img-{{ screen }}"></span>{% endif %}
{% if voice_agent_target == "redirect" %}
<a href="{{ voice_agent_url }}"
class="voice-pos-{{ screen }}">
<span class="voice-icon">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span class="voice-text-{{ screen }}">
<span class="voice-tagline-{{ screen }}" id="voice-tagline-{{ screen }}"></span>
<span class="voice-label-{{ screen }}">{{ voice_agent_label or "Digitaler Assistent" }}</span>
</span>
</a>
{% else %}
<button onclick='openOverlay({{ voice_agent_url | tojson }}, "{{ voice_agent_position|default('top-left') }}")'
class="voice-pos-{{ screen }}">
<span class="voice-icon">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span class="voice-text-{{ screen }}">
<span class="voice-tagline-{{ screen }}" id="voice-tagline-{{ screen }}"></span>
<span class="voice-label-{{ screen }}">{{ voice_agent_label or "Digitaler Assistent" }}</span>
</span>
</button>
{% endif %}
</div>
{% endif %}
<!-- ─── Medien-Elemente (eines ist jeweils sichtbar) ─── --> <!-- ─── Medien-Elemente (eines ist jeweils sichtbar) ─── -->
<img id="image"> <img id="image">
<video id="video" muted autoplay playsinline></video> <video id="video" muted autoplay playsinline></video>
@@ -135,11 +323,13 @@
let playerTimer = null; let playerTimer = null;
// ─── Custom-URL-Overlay ─── // ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
function openOverlay(url) { function openOverlay(url, pos) {
if (playerTimer) clearTimeout(playerTimer); if (playerTimer) clearTimeout(playerTimer);
document.getElementById("overlay").style.display = "block"; document.getElementById("overlay").style.display = "block";
document.getElementById("overlay-iframe").src = url; document.getElementById("overlay-iframe").src = url;
var backBtn = document.getElementById("overlay-back");
backBtn.className = "overlay-back-" + (pos || "top-left");
} }
function closeOverlay() { function closeOverlay() {
@@ -162,8 +352,10 @@ updateClock();
const normalFiles = {{ normal_files | tojson }}; const normalFiles = {{ normal_files | tojson }};
const prioFiles = {{ prio_files | tojson }}; const prioFiles = {{ prio_files | tojson }};
const interval = {{ interval }} * 1000; const interval = {{ interval }} * 1000;
const stayOnFirst = {{ "true" if stay_on_first else "false" }};
const screen = "{{ screen }}"; const screen = "{{ screen }}";
const site = "{{ site }}"; const site = "{{ site }}";
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
// ─── Player-Status ─── // ─── Player-Status ───
let normalIndex = 0; let normalIndex = 0;
@@ -270,7 +462,7 @@ function playNext() {
iframe.style.height = `100vh`; iframe.style.height = `100vh`;
iframe.style.transform = `translate(-50%, -50%)`; iframe.style.transform = `translate(-50%, -50%)`;
} }
playerTimer = setTimeout(playNext, interval); if (!stayOnFirst) playerTimer = setTimeout(playNext, interval);
return; return;
} }
@@ -279,7 +471,7 @@ function playNext() {
if (isVideo(item)) { if (isVideo(item)) {
vid.style.display = "block"; vid.style.display = "block";
vid.src = src; vid.src = src;
vid.onended = playNext; vid.onended = stayOnFirst ? null : playNext;
vid.play(); vid.play();
} else if (isHtml(item)) { } else if (isHtml(item)) {
iframe.style.display = "block"; iframe.style.display = "block";
@@ -312,6 +504,51 @@ async function checkForUpdates() {
} }
} }
// ─── Voice-Agent-Tagline Typewriter ───
// Wechselt mehrsprachige Sätze, Buchstabe für Buchstabe, 2,5 s Pause, 0,8 s Fade-Out
(function() {
const el = document.getElementById('voice-tagline-{{ screen }}');
if (!el) return;
const phrases = [
"Always ready to help",
"How can I assist you?",
"Questions? Just ask.",
"Wie kann ich helfen?",
"Ich bin für Sie da",
"Sprechen Sie mich einfach an",
"Comment puis-je vous aider?",
"¿En qué puedo ayudarle?",
"Come posso aiutarti?",
"I'm here for you",
"Let me know what you need"
];
let idx = 0, charIdx = 0, fading = false;
function type() {
if (fading) {
el.style.opacity = '0';
fading = false;
setTimeout(function() {
el.textContent = '';
el.style.opacity = '1';
setTimeout(type, 200);
}, 800);
return;
}
const phrase = phrases[idx % phrases.length];
if (charIdx < phrase.length) {
el.textContent = phrase.slice(0, charIdx + 1);
charIdx++;
setTimeout(type, 50 + Math.random() * 30);
} else {
charIdx = 0;
idx++;
fading = true;
setTimeout(type, 2500);
}
}
type();
})();
// ─── Start ─── // ─── Start ───
playNext(); playNext();
setInterval(checkForUpdates, 5000); setInterval(checkForUpdates, 5000);