From b87f34a9ca2d89d7dff81d24544dee7b58d48186 Mon Sep 17 00:00:00 2001 From: Bernd Reuther Date: Wed, 30 Sep 2026 18:30:05 +0200 Subject: [PATCH] konfigurierbare farben und texte --- README.md | 63 +++++++++++++++ editor.py | 194 +++++++++++++++++++++++++++++++-------------- recorder.py | 152 +++++++++++++++++++++++------------ theme.json.example | 40 ++++++++++ 4 files changed, 337 insertions(+), 112 deletions(-) create mode 100644 README.md create mode 100644 theme.json.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..b659dc2 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Branding und Farbschema + +Die Web-UIs können ohne eigenes Docker-Image an eine Veranstaltung oder Organisation angepasst werden. + +## Umgebungsvariablen + +```yaml +environment: + SITE_NAME: "Meine Veranstaltung" + SITE_SUBTITLE: "Vorträge und Aufzeichnungen" + PAGE_TITLE: "Meine Veranstaltung – Aufnahmen" + PAGE_HEADING: "Meine Veranstaltung – Aufnahmen" + THEME_FILE: "/config/theme.json" + LOGO_FILE: "/config/logo.svg" + FAVICON_FILE: "/config/favicon.svg" +``` + +Beim Editor stehen zusätzlich zur Verfügung: + +```yaml + EDIT_PAGE_TITLE: "Meine Veranstaltung – Aufnahme bearbeiten" + EDIT_PAGE_HEADING: "Aufnahme bearbeiten" +``` + +`LOGO_FILE` und `FAVICON_FILE` sind optional. Werden sie nicht gesetzt, werden weder Logo noch Favicon eingeblendet. + +## Theme + +`theme.json.example` kann nach `theme.json` kopiert und angepasst werden. Die Farben sind semantisch benannt; dadurch muss die Anwendung selbst nicht geändert werden, wenn das Farbschema angepasst wird. + +Beispiel: + +```json +{ + "colors": { + "background": "#111", + "surface": "#181818", + "surface_alt": "#222", + "text": "#eee", + "text_secondary": "#ccc", + "text_muted": "#aaa", + "primary": "#1769aa", + "primary_hover": "#1f7fc2", + "accent": "#ffcc33", + "success": "#173d24", + "warning": "#6b4e00", + "danger": "#8b0000" + } +} +``` + +Nicht angegebene Farben verwenden automatisch die eingebauten Standardwerte. + +## Docker + +Die Konfiguration sollte als read-only Verzeichnis eingebunden werden: + +```yaml +volumes: + - /opt/event-audio-recorder/config:/config:ro +``` + +Das gleiche Konfigurationsverzeichnis kann für Recorder und Editor verwendet werden. diff --git a/editor.py b/editor.py index 43a68d7..6c5793a 100644 --- a/editor.py +++ b/editor.py @@ -4,11 +4,12 @@ import threading import time import uuid import json +import re from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo -from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for, Response, stream_with_context +from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for, Response, stream_with_context, send_file from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -24,6 +25,56 @@ INDEX_FILE = Path(os.environ.get("INDEX_FILE", str(FINISHED_DIR / ".event-audio- WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms")) WEB_PORT = int(os.environ.get("WEB_PORT", "8080")) +# --------------------------------------------------------------------------- +# Branding / theme configuration + +SITE_NAME = os.environ.get("SITE_NAME", "Event Audio") +SITE_SUBTITLE = os.environ.get("SITE_SUBTITLE", "Audiostream-Aufzeichnungen") +PAGE_TITLE = os.environ.get("PAGE_TITLE", "Event Audio – Editor") +PAGE_HEADING = os.environ.get("PAGE_HEADING", "Event Audio – Editor") +EDIT_PAGE_TITLE = os.environ.get("EDIT_PAGE_TITLE", "Event Audio – Aufnahme bearbeiten") +EDIT_PAGE_HEADING = os.environ.get("EDIT_PAGE_HEADING", "Aufnahme bearbeiten") +THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json")) +LOGO_FILE = Path(os.environ["LOGO_FILE"]) if os.environ.get("LOGO_FILE") else None +FAVICON_FILE = Path(os.environ["FAVICON_FILE"]) if os.environ.get("FAVICON_FILE") else None + +DEFAULT_THEME = {'background': '#111', 'surface': '#181818', 'surface_alt': '#222', 'surface_dark': '#0b0b0b', 'text': '#eee', 'text_secondary': '#ccc', 'text_muted': '#aaa', 'text_faint': '#777', 'border': '#333', 'border_subtle': '#2b2b2b', 'control': '#333', 'control_hover': '#444', 'primary': '#1769aa', 'primary_hover': '#1f7fc2', 'accent': '#ffcc33', 'success': '#173d24', 'success_text': '#b8f5c9', 'warning': '#6b4e00', 'danger': '#8b0000', 'danger_soft': '#4a2020', 'danger_soft_hover': '#6a2b2b', 'error': '#4a1b1b', 'error_text': '#ffd0d0', 'recording': '#b00000', 'recording_border': '#3a2222', 'selection': 'rgba(80,150,255,.16)', 'shadow': '#000', 'scroll_thumb': '#777', 'scroll_thumb_hover': '#999', 'scroll_thumb_active': '#aaa', 'live_surface': '#1d1111', 'table_header': '#252525', 'footer': '#666', 'white': '#fff','surface_modal':'#202020','overlay':'rgba(0,0,0,.78)'} + +def load_theme(): + """Load semantic theme colors from JSON, falling back to built-in defaults.""" + theme = dict(DEFAULT_THEME) + if THEME_FILE.is_file(): + try: + data = json.loads(THEME_FILE.read_text(encoding="utf-8")) + colors = data.get("colors", data) if isinstance(data, dict) else {} + if isinstance(colors, dict): + for key in theme: + value = colors.get(key) + if isinstance(value, str) and re.fullmatch(r"(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))", value.strip()): + theme[key] = value.strip() + except Exception as exc: + print(f"[branding] Theme konnte nicht geladen werden: {exc}", flush=True) + return theme + +def theme_css(): + theme = load_theme() + return ":root {\n" + "\n".join( + f" --color-{key.replace('_', '-')}: {value};" for key, value in theme.items() + ) + "\n}" + +def branding_context(): + return { + "site_name": SITE_NAME, + "site_subtitle": SITE_SUBTITLE, + "page_title": PAGE_TITLE, + "page_heading": PAGE_HEADING, + "edit_page_title": EDIT_PAGE_TITLE, + "edit_page_heading": EDIT_PAGE_HEADING, + "theme_css": theme_css(), + "logo_url": "/branding/logo" if LOGO_FILE and LOGO_FILE.is_file() else None, + "favicon_url": "/branding/favicon" if FAVICON_FILE and FAVICON_FILE.is_file() else None, + } + NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no") NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16") NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5") @@ -801,59 +852,62 @@ EDITOR_HTML = """ -Event Audio – Aufnahme bearbeiten +{{ page_title }} +{% if favicon_url %}{% endif %}
-

Event Audio – Editor

+{% if logo_url %}{% endif %} +

{{ page_heading }}

Fertige Aufnahmen aus {{ recordings_dir }}
@@ -1628,6 +1686,20 @@ async function deleteFile(kind, filename) { # Routes # --------------------------------------------------------------------------- +@app.route("/branding/logo") +def branding_logo(): + if LOGO_FILE is None or not LOGO_FILE.is_file(): + abort(404) + return send_file(LOGO_FILE) + + +@app.route("/branding/favicon") +def branding_favicon(): + if FAVICON_FILE is None or not FAVICON_FILE.is_file(): + abort(404) + return send_file(FAVICON_FILE) + + @app.route("/") def index(): with INDEX_LOCK: @@ -1683,6 +1755,7 @@ def index(): artists=artists, albums=albums, recordings_dir=str(RECORDINGS_DIR), + **branding_context(), ) @@ -1722,6 +1795,7 @@ def edit_recording(filename): metadata_datetime=metadata_form_value(path), waveform_url=url_for("waveform", filename=filename), audio_url=url_for("recording", filename=filename), + **branding_context(), ) diff --git a/recorder.py b/recorder.py index 4bed120..7d2a256 100644 --- a/recorder.py +++ b/recorder.py @@ -3,13 +3,14 @@ import signal import subprocess import threading import json +import re import time from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo import requests -from flask import Flask, render_template_string, send_from_directory, abort, Response, request, stream_with_context +from flask import Flask, render_template_string, send_from_directory, abort, Response, request, stream_with_context, send_file # --------------------------------------------------------------------------- @@ -48,21 +49,51 @@ WEB_PORT = int( os.environ.get("WEB_PORT", "8080") ) -TIMEZONE = ZoneInfo( - os.environ.get("TIMEZONE", "Europe/Berlin") -) +# --------------------------------------------------------------------------- +# Branding / theme configuration -START_DELAY = float( - os.environ.get("START_DELAY", "3") -) +SITE_NAME = os.environ.get("SITE_NAME", "Event Audio") +SITE_SUBTITLE = os.environ.get("SITE_SUBTITLE", "Audiostream-Aufzeichnungen") +PAGE_TITLE = os.environ.get("PAGE_TITLE", "Event Audio – Aufnahmen") +PAGE_HEADING = os.environ.get("PAGE_HEADING", "EVENT AUDIO – Aufnahmen") +THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json")) +LOGO_FILE = Path(os.environ["LOGO_FILE"]) if os.environ.get("LOGO_FILE") else None +FAVICON_FILE = Path(os.environ["FAVICON_FILE"]) if os.environ.get("FAVICON_FILE") else None -STOP_DELAY = float( - os.environ.get("STOP_DELAY", "10") -) +DEFAULT_THEME = {'background': '#111', 'surface': '#181818', 'surface_alt': '#222', 'surface_dark': '#0b0b0b', 'text': '#eee', 'text_secondary': '#ccc', 'text_muted': '#aaa', 'text_faint': '#777', 'border': '#333', 'border_subtle': '#2b2b2b', 'control': '#333', 'control_hover': '#444', 'primary': '#1769aa', 'primary_hover': '#1f7fc2', 'accent': '#ffcc33', 'success': '#173d24', 'success_text': '#b8f5c9', 'warning': '#6b4e00', 'danger': '#8b0000', 'danger_soft': '#4a2020', 'danger_soft_hover': '#6a2b2b', 'error': '#4a1b1b', 'error_text': '#ffd0d0', 'recording': '#b00000', 'recording_border': '#3a2222', 'selection': 'rgba(80,150,255,.16)', 'shadow': '#000', 'scroll_thumb': '#777', 'scroll_thumb_hover': '#999', 'scroll_thumb_active': '#aaa', 'live_surface': '#1d1111', 'table_header': '#252525', 'footer': '#666', 'white': '#fff','surface_modal':'#202020','overlay':'rgba(0,0,0,.78)'} -POLL_INTERVAL = float( - os.environ.get("POLL_INTERVAL", "2") -) +def load_theme(): + """Load semantic theme colors from JSON, falling back to built-in defaults.""" + theme = dict(DEFAULT_THEME) + if THEME_FILE.is_file(): + try: + data = json.loads(THEME_FILE.read_text(encoding="utf-8")) + colors = data.get("colors", data) if isinstance(data, dict) else {} + if isinstance(colors, dict): + for key in theme: + value = colors.get(key) + if isinstance(value, str) and re.fullmatch(r"(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))", value.strip()): + theme[key] = value.strip() + except Exception as exc: + print(f"[branding] Theme konnte nicht geladen werden: {exc}", flush=True) + return theme + +def theme_css(): + theme = load_theme() + return ":root {\n" + "\n".join( + f" --color-{key.replace('_', '-')}: {value};" for key, value in theme.items() + ) + "\n}" + +def branding_context(): + return { + "site_name": SITE_NAME, + "site_subtitle": SITE_SUBTITLE, + "page_title": PAGE_TITLE, + "page_heading": PAGE_HEADING, + "theme_css": theme_css(), + "logo_url": "/branding/logo" if LOGO_FILE and LOGO_FILE.is_file() else None, + "favicon_url": "/branding/favicon" if FAVICON_FILE and FAVICON_FILE.is_file() else None, + } NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no") NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16") @@ -569,18 +600,21 @@ HTML = """ -Event Audio – Aufnahmen - +{{ page_title }} +{% if favicon_url %}{% endif %}