konfigurierbare farben und texte

This commit is contained in:
Bernd Reuther
2026-09-30 18:30:05 +02:00
parent 3a94fe829b
commit b87f34a9ca
4 changed files with 337 additions and 112 deletions
+63
View File
@@ -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.
+134 -60
View File
@@ -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 = """
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Aufnahme bearbeiten</title>
<title>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.site-logo { max-width:220px; max-height:72px; object-fit:contain; display:block; margin-bottom:12px; }
body { margin:0; padding:24px; background:var(--color-background); color:var(--color-text); font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; font-size:28px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.editor { background:#181818; border:1px solid #333; border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:#0b0b0b; border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
.subtitle { color:var(--color-text-muted); margin-bottom:22px; }
.editor { background:var(--color-surface); border:1px solid var(--color-border); border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:var(--color-surface-dark); border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
.wave-image { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:fill; display:block; opacity:.9; pointer-events:none; }
.selection { position:absolute; top:0; bottom:0; background:rgba(80,150,255,.16); border-left:2px solid #fff; border-right:2px solid #fff; pointer-events:none; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000; z-index:4; pointer-events:none; }
.selection { position:absolute; top:0; bottom:0; background:var(--color-selection); border-left:2px solid var(--color-white); border-right:2px solid var(--color-white); pointer-events:none; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:var(--color-accent); box-shadow:0 0 5px var(--color-shadow); z-index:4; pointer-events:none; }
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:#292929; border-radius:7px; overflow:hidden; touch-action:none; cursor:grab; }
.view-scroll-thumb { position:absolute; top:2px; bottom:2px; left:0; min-width:14px; background:#777; border-radius:5px; cursor:grab; touch-action:none; }
.view-scroll-thumb:hover { background:#999; }
.view-scroll-thumb.dragging { background:#aaa; cursor:grabbing; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:var(--color-white); border-radius:2px; box-shadow:0 0 5px var(--color-shadow); }
.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:var(--color-border-subtle); border-radius:7px; overflow:hidden; touch-action:none; cursor:grab; }
.view-scroll-thumb { position:absolute; top:2px; bottom:2px; left:0; min-width:14px; background:var(--color-text-faint); border-radius:5px; cursor:grab; touch-action:none; }
.view-scroll-thumb:hover { background:var(--color-text-muted); }
.view-scroll-thumb.dragging { background:var(--color-text-muted); cursor:grabbing; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:var(--color-text-muted); font-size:14px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
button:hover, a.button:hover { background:#444; }
.button.danger { background:#4a2020; }
.button.danger:hover { background:#6a2b2b; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:var(--color-border); color:var(--color-white); cursor:pointer; text-decoration:none; font-size:15px; }
button:hover, a.button:hover { background:var(--color-control-hover); }
.button.danger { background:var(--color-danger-soft); }
.button.danger:hover { background:var(--color-danger-soft-hover); }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
button.primary { background:#1769aa; }
button.danger { background:#8b0000; }
button.primary { background:var(--color-primary); }
button.danger { background:var(--color-danger); }
button:disabled { opacity:.45; cursor:not-allowed; }
.position { margin-left:auto; color:#ccc; font-variant-numeric:tabular-nums; }
.zoom { color:#aaa; font-variant-numeric:tabular-nums; }
.position { margin-left:auto; color:var(--color-text-secondary); font-variant-numeric:tabular-nums; }
.zoom { color:var(--color-text-muted); font-variant-numeric:tabular-nums; }
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
.value { background:#222; border-radius:6px; padding:12px; }
.value span { display:block; color:#999; font-size:13px; margin-bottom:5px; }
.value { background:var(--color-surface-alt); border-radius:6px; padding:12px; }
.value span { display:block; color:var(--color-text-muted); font-size:13px; margin-bottom:5px; }
.value strong { font-size:20px; font-variant-numeric:tabular-nums; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
.info { margin-top:16px; color:#999; font-size:14px; }
.info { margin-top:16px; color:var(--color-text-muted); font-size:14px; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.message.ok { display:block; background:var(--color-success); color:var(--color-success-text); }
.message.error { display:block; background:var(--color-error); color:var(--color-error-text); }
.meta-dialog { position:fixed; inset:0; background:var(--color-overlay); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card { width:min(560px,100%); background:var(--color-surface-modal); border:1px solid var(--color-control-hover); border-radius:10px; padding:22px; box-shadow:0 10px 40px var(--color-shadow); }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-card label { display:block; margin:14px 0; color:var(--color-text-secondary); }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.progress-box { display:none; margin-top:16px; }
.progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:#1769aa; transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:#aaa; font-size:14px; font-variant-numeric:tabular-nums; }
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
.progress-track { height:18px; background:var(--color-border-subtle); border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:var(--color-primary); transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:var(--color-text-muted); font-size:14px; font-variant-numeric:tabular-nums; }
.back { display:inline-block; margin-bottom:18px; color:var(--color-text-secondary); text-decoration:none; }
@media(max-width:700px) {
body { padding:12px; }
.editor { padding:12px; }
@@ -870,8 +924,9 @@ button:disabled { opacity:.45; cursor:not-allowed; }
<body>
<div class="container">
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
<h1>Aufnahme bearbeiten</h1>
<div class="subtitle">{{ filename }} · Originaldauer {{ duration }}</div>
{% if logo_url %}<img class="site-logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>{{ edit_page_heading }}</h1>
<div class="subtitle">{{ site_name }} · {{ filename }} · Originaldauer {{ duration }}</div>
<div class="editor">
<div class="wave-wrap" id="wave">
@@ -1247,51 +1302,54 @@ INDEX_HTML = """
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Editor</title>
<title>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
body { margin:0; padding:24px; background:var(--color-background); color:var(--color-text); font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
.subtitle { color:var(--color-text-muted); margin-bottom:22px; }
.section { background:var(--color-surface); border:1px solid var(--color-border); border-radius:10px; padding:18px; margin-bottom:18px; }
.filter-bar { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)); gap:10px; margin-bottom:14px; }
.filter-bar label { color:#aaa; font-size:13px; }
.filter-bar input, .filter-bar select { width:100%; margin-top:5px; padding:9px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:15px; }
.filter-bar label { color:var(--color-text-muted); font-size:13px; }
.filter-bar input, .filter-bar select { width:100%; margin-top:5px; padding:9px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); border-radius:5px; font-size:15px; }
.filter-bar .search { grid-column:auto; }
.filter-info { color:#999; font-size:13px; margin-bottom:10px; }
.filter-info { color:var(--color-text-muted); font-size:13px; margin-bottom:10px; }
.upload-bar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.upload-info { color:#999; font-size:13px; }
.upload-progress { display:none; flex:1 1 240px; min-width:180px; height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.upload-info { color:var(--color-text-muted); font-size:13px; }
.upload-progress { display:none; flex:1 1 240px; min-width:180px; height:18px; background:var(--color-border-subtle); border-radius:9px; overflow:hidden; }
.upload-progress.active { display:block; }
.upload-progress-bar { width:0; height:100%; background:#1769aa; transition:width .15s ease; }
.upload-progress-bar { width:0; height:100%; background:var(--color-primary); transition:width .15s ease; }
table { width:100%; border-collapse:collapse; }
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
th { color:#aaa; font-weight:normal; }
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:#333; color:#fff; cursor:pointer; text-decoration:none; }
.button:hover { background:#444; }
.empty { color:#999; padding:15px 0; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
th,td { padding:10px 8px; border-bottom:1px solid var(--color-border); text-align:left; }
th { color:var(--color-text-muted); font-weight:normal; }
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:var(--color-border); color:var(--color-white); cursor:pointer; text-decoration:none; }
.button:hover { background:var(--color-control-hover); }
.empty { color:var(--color-text-muted); padding:15px 0; }
.meta-dialog { position:fixed; inset:0; background:var(--color-overlay); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card { width:min(560px,100%); background:var(--color-surface-modal); border:1px solid var(--color-control-hover); border-radius:10px; padding:22px; box-shadow:0 10px 40px var(--color-shadow); }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-card label { display:block; margin:14px 0; color:var(--color-text-secondary); }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.loading-overlay { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:1000; }
.loading-overlay { position:fixed; inset:0; background:var(--color-overlay); display:none; align-items:center; justify-content:center; z-index:1000; }
.loading-overlay.active { display:flex; }
.spinner { width:54px; height:54px; border:5px solid #444; border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
.spinner { width:54px; height:54px; border:5px solid var(--color-control-hover); border-top-color:var(--color-white); border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
@media(max-width:900px) { .filter-bar { grid-template-columns:1fr 1fr; } .filter-bar .search { grid-column:auto; } }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid var(--color-border); } td { border:0; padding:5px 0; } }
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay"><div class="spinner" aria-label="Laden"></div></div>
<div class="container">
<h1>Event Audio – Editor</h1>
{% if logo_url %}<img class="site-logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>{{ page_heading }}</h1>
<div class="subtitle">Fertige Aufnahmen aus {{ recordings_dir }}</div>
<div class="section">
@@ -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(),
)
+100 -52
View File
@@ -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 = """
<title>Event Audio – Aufnahmen</title>
<title>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* {
box-sizing: border-box;
}
.site-logo { max-width:220px; max-height:72px; object-fit:contain; display:block; margin-bottom:12px; }
body {
margin: 0;
padding: 24px;
background: #111;
color: #eee;
background: var(--color-background);
color: var(--color-text);
font-family: Arial, Helvetica, sans-serif;
}
@@ -590,7 +624,7 @@ h1 {
}
.subtitle {
color: #aaa;
color: var(--color-text-muted);
margin-bottom: 24px;
}
@@ -603,23 +637,23 @@ h1 {
}
.status.recording {
background: #8b0000;
color: white;
background: var(--color-danger);
color:var(--color-white);
}
.status.idle {
background: #333;
color: #ccc;
background: var(--color-border);
color: var(--color-text-secondary);
}
.status.error {
background: #6b4e00;
color: white;
background: var(--color-warning);
color:var(--color-white);
}
.recording {
border: 1px solid #b00000;
background: #1d1111;
border: 1px solid var(--color-recording);
background: var(--color-live-surface);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
@@ -632,15 +666,15 @@ h1 {
}
.recording-info {
color: #bbb;
color: var(--color-text-secondary);
margin-bottom: 12px;
}
.live-panel {
display: flex;
gap: 16px;
border: 1px solid #b00000;
background: #1d1111;
border: 1px solid var(--color-recording);
background: var(--color-live-surface);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
@@ -652,7 +686,7 @@ h1 {
}
.live-item + .live-item {
border-left: 1px solid #3a2222;
border-left: 1px solid var(--color-recording-border);
padding-left: 16px;
}
@@ -663,7 +697,7 @@ h1 {
}
.live-info {
color: #aaa;
color: var(--color-text-muted);
font-size: 14px;
margin-bottom: 10px;
}
@@ -676,7 +710,7 @@ h1 {
}
.filter-bar label {
color: #aaa;
color: var(--color-text-muted);
font-size: 13px;
}
@@ -685,9 +719,9 @@ h1 {
width: 100%;
margin-top: 5px;
padding: 9px;
background: #111;
color: #eee;
border: 1px solid #555;
background: var(--color-background);
color: var(--color-text);
border: 1px solid var(--color-control-hover);
border-radius: 5px;
font-size: 15px;
}
@@ -697,7 +731,7 @@ h1 {
}
.filter-info {
color: #999;
color: var(--color-text-muted);
font-size: 13px;
margin-bottom: 10px;
}
@@ -705,21 +739,21 @@ h1 {
table {
width: 100%;
border-collapse: collapse;
background: #181818;
background: var(--color-surface);
border-radius: 8px;
overflow: hidden;
}
th {
text-align: left;
background: #252525;
background: var(--color-table-header);
padding: 12px;
color: #ccc;
color: var(--color-text-secondary);
}
td {
padding: 12px;
border-top: 1px solid #2b2b2b;
border-top: 1px solid var(--color-border-subtle);
vertical-align: middle;
}
@@ -744,25 +778,25 @@ a.button {
display: inline-block;
padding: 8px 12px;
margin-left: 8px;
background: #333;
color: #fff;
background: var(--color-border);
color: var(--color-white);
text-decoration: none;
border-radius: 5px;
}
a.button:hover {
background: #444;
background: var(--color-control-hover);
}
.empty {
padding: 30px;
text-align: center;
color: #777;
color: var(--color-text-faint);
}
.footer {
margin-top: 24px;
color: #666;
color: var(--color-footer);
font-size: 13px;
}
@@ -790,7 +824,7 @@ a.button:hover {
}
tr {
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--color-border);
padding: 12px 0;
}
@@ -809,7 +843,7 @@ a.button:hover {
.live-item + .live-item {
border-left: 0;
border-top: 1px solid #3a2222;
border-top: 1px solid var(--color-recording-border);
padding-left: 0;
padding-top: 16px;
margin-top: 16px;
@@ -825,11 +859,10 @@ a.button:hover {
<body>
<h1>EVENT AUDIO – Aufnahmen</h1>
{% if logo_url %}<img class="site-logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>{{ page_heading }}</h1>
<div class="subtitle">
Audiostream-Aufzeichnungen
</div>
<div class="subtitle">{{ site_subtitle }}</div>
<div id="dynamic-content">
@@ -1219,9 +1252,24 @@ def render_index():
normalizing_filename=normalizing_filename,
status_error=status_error,
icecast_public_url=ICECAST_PUBLIC_URL,
**branding_context(),
)
@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():
return render_index()
@@ -1380,4 +1428,4 @@ if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=WEB_PORT,
)
)
+40
View File
@@ -0,0 +1,40 @@
{
"colors": {
"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)"
}
}