konfigurierbare farben und texte
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user