diff --git a/app.py b/app.py index 931d6d1..7bf081d 100755 --- a/app.py +++ b/app.py @@ -27,6 +27,7 @@ import base64 import json import io import search +import urllib.request import auth import analytics as a import folder_secret_config_editor as fsce @@ -461,6 +462,30 @@ def create_calendar_entry(): 'details': details }), 201 +@app.route('/api/livestream/status', methods=['GET']) +@auth.require_secret +def livestream_status(): + status_url = auth.return_app_config().get('LIVESTREAM_STATUS_URL') + result = {"audio_live": False, "live": False} + if not status_url: + reply = jsonify(result) + reply.headers['Cache-Control'] = 'no-store' + return reply + try: + with urllib.request.urlopen(status_url, timeout=5) as response: + if response.status != 200: + raise ValueError(f"Status endpoint returned {response.status}") + payload = response.read() + parsed = json.loads(payload.decode('utf-8')) + result["audio_live"] = bool(parsed.get("audio_live", False)) + result["live"] = bool(parsed.get("live", False)) + except Exception as err: + app.logger.warning("Livestream status proxy failed: %s", err) + + reply = jsonify(result) + reply.headers['Cache-Control'] = 'no-store' + return reply + # Grab the HOST_RULE environment variable host_rule = os.getenv("HOST_RULE", "") # Use a regex to extract domain names between backticks in patterns like Host(`something`) @@ -1289,6 +1314,7 @@ def index(path): features=app_config.get('FEATURES', None), og_title=og_title, og_description=og_description, + livestream_url=app_config.get('LIVESTREAM_URL') or '', admin_enabled=auth.is_admin() ) diff --git a/example_config_files/app_config.json b/example_config_files/app_config.json index d2a6010..449e0b5 100644 --- a/example_config_files/app_config.json +++ b/example_config_files/app_config.json @@ -4,9 +4,11 @@ "ADMIN_KEY": "THIS_IS_USED_TO_AUTHENTICATE_ADMIN", "TITLE_SHORT": "Gottesdienste", "TITLE_LONG": "Gottesdienste App", + "LIVESTREAM_URL": "https://streaming.bethaus-speyer.de", + "LIVESTREAM_STATUS_URL": "https://streaming.bethaus-speyer.de/status", "BASE_DIR": "/mnt", "filecache_size_limit_audio": 16, "filecache_size_limit_image": 16, "filecache_size_limit_video": 16, "filecache_size_limit_other": 16 -} \ No newline at end of file +} diff --git a/static/app.css b/static/app.css index 3fae975..3e37d51 100644 --- a/static/app.css +++ b/static/app.css @@ -128,6 +128,46 @@ main > * { margin: 12px 0; } +.livestream-alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.livestream-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.12); + border: 1px solid rgba(0, 0, 0, 0.2); + color: inherit; + font-weight: 600; + text-decoration: none; + transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.livestream-button:hover, +.livestream-button:focus-visible { + background: rgba(0, 0, 0, 0.2); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.15); + transform: translateY(-1px); +} + +.livestream-button:active { + transform: translateY(0); + box-shadow: none; +} + +@media (max-width: 640px) { + .livestream-alert { + flex-direction: column; + align-items: flex-start; + } +} + /* Search view container (custom element) */ search { display: block; diff --git a/static/app.js b/static/app.js index 8142c57..f3ef662 100644 --- a/static/app.js +++ b/static/app.js @@ -796,9 +796,60 @@ function initTranscriptModal() { modal.dataset.bound = '1'; } +const LIVESTREAM_STATUS_URL = '/api/livestream/status'; +const LIVESTREAM_POLL_INTERVAL_MS = 60000; +let livestreamStatusIntervalId = null; +let livestreamStatusErrorLogged = false; + +function setLivestreamAlertVisible(isVisible) { + const alertEl = document.getElementById('livestream-alert'); + if (!alertEl) return; + alertEl.classList.toggle('d-none', !isVisible); + alertEl.setAttribute('aria-hidden', String(!isVisible)); +} + +async function checkLivestreamStatus() { + const alertEl = document.getElementById('livestream-alert'); + if (!alertEl) return; + + try { + const response = await fetch(LIVESTREAM_STATUS_URL, { + cache: 'no-store', + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }); + if (!response.ok) throw new Error(`Livestream status request failed (${response.status})`); + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await response.text(); + throw new Error(`Livestream status returned non-JSON: ${text.slice(0, 120)}`); + } + const data = await response.json(); + const isLive = Boolean(data && (data.live || data.audio_live)); + setLivestreamAlertVisible(isLive); + } catch (err) { + if (!livestreamStatusErrorLogged) { + console.warn('Livestream status check failed', err); + livestreamStatusErrorLogged = true; + } + setLivestreamAlertVisible(false); + } +} + +function initLivestreamStatus() { + const alertEl = document.getElementById('livestream-alert'); + if (!alertEl) return; + + checkLivestreamStatus(); + if (!livestreamStatusIntervalId) { + livestreamStatusIntervalId = setInterval(checkLivestreamStatus, LIVESTREAM_POLL_INTERVAL_MS); + } +} + function initAppPage() { attachEventListeners(); initTranscriptModal(); + initLivestreamStatus(); if (typeof window.initSearch === 'function') window.initSearch(); if (typeof window.initMessages === 'function') window.initMessages(); if (typeof window.initGallery === 'function') window.initGallery(); diff --git a/templates/app.html b/templates/app.html index f7bba3e..2d9c303 100644 --- a/templates/app.html +++ b/templates/app.html @@ -21,6 +21,13 @@ {% endblock %} {% block content %} +