morz-infoboard/player/agent/internal/playerserver/server.go
Jesko Anschütz 6931181916 Fix: Transition-Race, Auto-Reload nach Deploy, Playlist-Latenz < 1s
- hideAllContent() prüft opacity bevor display=none gesetzt wird
  (verhindert Race mit displayItem)
- Neuer /api/startup-token Endpoint: Browser erkennt Agent-Neustart
  und reloaded automatisch
- MQTT-Debounce von 3s auf 500ms, Browser-Poll von 30s auf 5s
  reduziert für sub-sekunden Playlist-Updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:48:57 +01:00

621 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package playerserver
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"math/rand"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
)
//go:embed assets
var assetsFS embed.FS
// PlaylistItem is a single displayable content item fetched from the backend.
type PlaylistItem struct {
Src string `json:"src"`
Type string `json:"type"` // web | image | video | pdf
Title string `json:"title,omitempty"`
DurationSeconds int `json:"duration_seconds"`
}
// NowPlaying describes the current playback state returned to the browser.
type NowPlaying struct {
// Playlist holds the ordered items to display (primary).
Playlist []PlaylistItem `json:"playlist,omitempty"`
// URL is a legacy single-URL fallback (kept for backwards compatibility).
URL string `json:"url,omitempty"`
Status string `json:"status"`
Connectivity string `json:"connectivity"`
}
// InfoItem is a single entry shown in the lower-third sysinfo overlay.
type InfoItem struct {
Label string `json:"label"`
Value string `json:"value"`
}
// SysInfo holds the items shown in the lower-third overlay.
type SysInfo struct {
Items []InfoItem `json:"items"`
}
// Server serves the local player UI to Chromium.
type Server struct {
listenAddr string
nowFn func() NowPlaying
startupToken string // zufälliger Token der sich bei jedem Start ändert
}
// New creates a Server. listenAddr is e.g. "127.0.0.1:8090".
// nowFn is called on each request and returns the current playback state.
func New(listenAddr string, nowFn func() NowPlaying) *Server {
token := fmt.Sprintf("%016x", rand.Uint64())
return &Server{listenAddr: listenAddr, nowFn: nowFn, startupToken: token}
}
// Run starts the HTTP server and blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
return err
}
mux := http.NewServeMux()
mux.HandleFunc("GET /player", s.handlePlayer)
mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying)
mux.HandleFunc("GET /api/sysinfo", handleSysInfo)
mux.HandleFunc("GET /api/startup-token", s.handleStartupToken)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
srv := &http.Server{Handler: mux}
ln, err := net.Listen("tcp", s.listenAddr)
if err != nil {
return err
}
go func() {
<-ctx.Done()
srv.Close()
}()
if err := srv.Serve(ln); err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handlePlayer(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(playerHTML)) //nolint:errcheck
}
func (s *Server) handleNowPlaying(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.nowFn()) //nolint:errcheck
}
// handleStartupToken gibt einen zufälligen Token zurück der sich bei jedem
// Agent-Start ändert. Der Browser erkennt daran, dass der Agent neu gestartet
// wurde und lädt die Seite automatisch neu.
func (s *Server) handleStartupToken(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"token": s.startupToken}) //nolint:errcheck
}
func handleSysInfo(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck
}
// collectSysInfo builds the list of info items shown in the overlay.
// To add more items, append to the slice here.
func collectSysInfo() SysInfo {
var items []InfoItem
if h, err := os.Hostname(); err == nil {
items = append(items, InfoItem{Label: "Hostname", Value: h})
}
if up := readUptime(); up != "" {
items = append(items, InfoItem{Label: "Uptime", Value: up})
}
return SysInfo{Items: items}
}
func readUptime() string {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return ""
}
parts := strings.Fields(string(data))
if len(parts) == 0 {
return ""
}
secs, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return ""
}
d := time.Duration(secs) * time.Second
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
const playerHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Morz Infoboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
/* Splash-Hintergrund */
#splash {
position: fixed; inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Unteres-Drittel-Overlay mit Systeminformationen */
#info-overlay {
position: fixed; bottom: 0; left: 0; right: 0;
height: 33.3%;
background: linear-gradient(
to bottom,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0.7) 40%,
rgba(0,0,0,0.85) 100%
);
display: flex;
align-items: flex-end;
padding: 0 3% 2.5%;
gap: 3rem;
z-index: 1;
}
.info-item { display: flex; flex-direction: column; }
.info-item .label {
font-family: sans-serif; font-size: 0.7rem;
letter-spacing: 0.12em; text-transform: uppercase;
color: rgba(255,255,255,0.5); margin-bottom: 0.25em;
}
.info-item .value {
font-family: monospace; font-size: 1.1rem;
font-weight: 500; letter-spacing: 0.03em; color: #fff;
}
/* Inhalts-Elemente: iframe, img, video */
#frame, #img-view, #video-view {
position: fixed; inset: 0;
width: 100%; height: 100%;
border: none; display: none; z-index: 10;
opacity: 0;
transition: opacity 0.5s ease;
}
#img-view {
object-fit: contain;
background: #000;
}
#video-view {
object-fit: contain;
background: #000;
}
/* Fehler-Fallback für blockierte iframes */
#frame-error {
position: fixed; inset: 0;
width: 100%; height: 100%;
display: none; z-index: 10;
background: #000;
align-items: center; justify-content: center; flex-direction: column;
gap: 1rem;
}
#frame-error .error-title {
font-family: sans-serif; font-size: 2rem; color: rgba(255,255,255,0.7);
text-align: center; padding: 0 10%;
}
#frame-error .error-hint {
font-family: sans-serif; font-size: 1rem; color: rgba(255,255,255,0.35);
}
/* Verbindungsstatus-Punkt */
#dot {
position: fixed; bottom: 10px; right: 10px;
width: 10px; height: 10px; border-radius: 50%;
background: #444; opacity: 0.6; z-index: 9999;
transition: background 0.5s;
}
#dot.online { background: #4caf50; }
#dot.degraded { background: #ff9800; }
#dot.offline { background: #f44336; }
</style>
</head>
<body>
<div id="splash"></div>
<div id="info-overlay"></div>
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe>
<img id="img-view" alt="">
<video id="video-view" autoplay muted playsinline></video>
<div id="frame-error">
<span class="error-title" id="frame-error-title"></span>
<span class="error-hint">Seite kann nicht eingebettet werden</span>
</div>
<div id="dot"></div>
<script>
var splash = document.getElementById('splash');
var overlay = document.getElementById('info-overlay');
var frame = document.getElementById('frame');
var imgView = document.getElementById('img-view');
var videoView = document.getElementById('video-view');
var frameError = document.getElementById('frame-error');
var frameErrorTitle = document.getElementById('frame-error-title');
var dot = document.getElementById('dot');
// ── Splash-Orientierung ───────────────────────────────────────────
function updateSplash() {
var portrait = window.innerHeight > window.innerWidth;
splash.style.backgroundImage = portrait
? 'url(/assets/splash-portrait.png)'
: 'url(/assets/splash-landscape.png)';
}
updateSplash();
window.addEventListener('resize', updateSplash);
// ── Sysinfo-Overlay ───────────────────────────────────────────────
// staticSysItems wird beim pollSysInfo-Callback gesetzt und enthält
// Hostname und Uptime vom Server.
var staticSysItems = [];
function renderSysInfo(staticItems) {
if (staticItems) { staticSysItems = staticItems; }
var all = staticSysItems.slice();
// Dynamische Einträge aus Playlist-Daten anhängen.
if (dynCurrentTitle) {
all.push({ label: 'Jetzt', value: dynCurrentTitle });
}
if (dynPlaylistLength > 0) {
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
}
if (dynConnectivity) {
var connLabel = dynConnectivity === 'online' ? 'Online'
: dynConnectivity === 'degraded' ? 'Eingeschränkt'
: 'Offline';
all.push({ label: 'Netzwerk', value: connLabel });
}
overlay.innerHTML = '';
all.forEach(function(item) {
var el = document.createElement('div');
el.className = 'info-item';
el.innerHTML =
'<span class="label">' + esc(item.label) + '</span>' +
'<span class="value">' + esc(item.value) + '</span>';
overlay.appendChild(el);
});
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Playlist-Rotation ─────────────────────────────────────────────
var items = []; // current playlist
var currentIdx = 0;
var rotateTimer = null;
// ── Sysinfo-Erweiterung: dynamische Overlay-Daten ─────────────────
var dynCurrentTitle = ''; // Titel des aktuell spielenden Items
var dynPlaylistLength = 0; // Anzahl Einträge in der Playlist
var dynConnectivity = ''; // online / degraded / offline
// Returns a fingerprint string for change detection.
function playlistKey(pl) {
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|');
}
function clearRotation() {
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
}
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
// Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
// Transition (500ms), damit der Fade-Out sichtbar ist.
//
// Race-Condition-Fix: Das setTimeout-Callback prüft vor dem display=none,
// ob das Element noch opacity=0 hat. Falls displayItem() das Element
// inzwischen wieder auf display=block+opacity=1 gesetzt hat, wird es
// nicht fälschlicherweise versteckt.
function hideAllContent() {
// Laufendes Video sofort stoppen damit kein Audio weiterläuft.
videoView.pause();
videoView.src = '';
[frame, imgView, videoView].forEach(function(el) {
if (el.style.display !== 'none') {
el.style.opacity = '0';
(function(e) {
setTimeout(function() {
// Nur verstecken wenn das Element noch ausgeblendet ist
// (opacity=0 oder leer). Falls displayItem() es inzwischen
// wieder sichtbar gemacht hat, nicht anfassen.
if (e.style.opacity === '0' || e.style.opacity === '') {
e.style.display = 'none';
}
}, 500);
})(el);
}
});
frameError.style.display = 'none';
}
// Blendet den Splash-Screen aus (wird aufgerufen wenn echter Content angezeigt wird).
function hideSplash() {
splash.style.display = 'none';
}
// Blendet den Splash-Screen wieder ein.
function showSplashDiv() {
splash.style.display = '';
}
function scheduleNext(durationSeconds) {
clearRotation();
var ms = Math.max((durationSeconds || 20), 1) * 1000;
rotateTimer = setTimeout(function() {
currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]);
}, ms);
}
// TRANSITION_MS muss mit der CSS-Transition-Dauer übereinstimmen.
var TRANSITION_MS = 500;
function showItem(item) {
if (!item) { showSplash(); return; }
// Erst Fade-Out des aktuellen Inhalts abwarten, dann neuen anzeigen.
hideAllContent();
hideSplash();
overlay.style.display = 'none';
setTimeout(function() { displayItem(item); }, TRANSITION_MS);
}
function displayItem(item) {
var type = item.type || 'web';
if (type === 'image') {
// display setzen, dann per doppeltem rAF opacity auf 1 für Fade-In.
imgView.src = item.src;
imgView.style.display = 'block';
requestAnimationFrame(function() {
requestAnimationFrame(function() { imgView.style.opacity = '1'; });
});
scheduleNext(item.duration_seconds);
} else if (type === 'video') {
videoView.src = item.src;
videoView.style.display = 'block';
requestAnimationFrame(function() {
requestAnimationFrame(function() { videoView.style.opacity = '1'; });
});
videoView.load();
videoView.play().catch(function() {});
// Nach Ablauf der konfigurierten Dauer oder am Ende des Videos rotieren.
var advanced = false;
function advanceOnce() {
if (advanced) return;
advanced = true;
currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]);
}
clearRotation();
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
rotateTimer = setTimeout(advanceOnce, ms);
videoView.onended = advanceOnce;
} else {
// type === 'web' oder unbekannt → iframe
frame.src = item.src;
frame.style.display = 'block';
requestAnimationFrame(function() {
requestAnimationFrame(function() { frame.style.opacity = '1'; });
});
// Fehler-Fallback wenn iframe-Laden fehlschlägt (z.B. X-Frame-Options).
frame.onerror = null;
frame.onload = function() {
// Prüfen ob der iframe-Inhalt zugänglich ist. Bei cross-origin-Blockierung
// wirft der Zugriff auf contentDocument einen SecurityError das bedeutet
// aber, dass die Seite geladen wurde. Wir können X-Frame-Options-Fehler im
// Browser leider nicht direkt erkennen; stattdessen setzen wir einen
// kurzen Timeout: Wenn der iframe leer bleibt (about:blank nach dem Load-
// Event wegen Blockierung), zeigen wir den Fallback.
try {
var doc = frame.contentDocument || frame.contentWindow.document;
// Wenn der Body keine Kinder hat und URL nicht die gewünschte ist →
// Seite wurde blockiert und durch about:blank ersetzt.
if (doc && doc.body && doc.body.children.length === 0 &&
doc.location && doc.location.href === 'about:blank') {
showFrameError(item);
}
} catch (e) {
// Cross-origin SecurityError → Seite wurde tatsächlich geladen, kein Fehler.
}
};
scheduleNext(item.duration_seconds);
}
}
function showFrameError(item) {
hideAllContent();
overlay.style.display = 'none';
frameErrorTitle.textContent = item.title || item.src;
frameError.style.display = 'flex';
// Nach kurzer Wartezeit (3s) zum nächsten Item rotieren.
clearRotation();
rotateTimer = setTimeout(function() {
currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]);
}, 3000);
}
function showSplash() {
clearRotation();
hideAllContent();
showSplashDiv();
overlay.style.display = '';
}
var lastPlaylistKey = '';
function applyNowPlaying(data) {
// Connectivity-Punkt und dynamische Overlay-Variable aktualisieren.
dot.className = data.connectivity || '';
dynConnectivity = data.connectivity || '';
// Legacy single-URL fallback.
if (data.url && (!data.playlist || data.playlist.length === 0)) {
var key = data.url + ':legacy';
dynPlaylistLength = 1;
dynCurrentTitle = data.url;
renderSysInfo();
if (lastPlaylistKey !== key) {
lastPlaylistKey = key;
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
currentIdx = 0;
showItem(items[0]);
}
return;
}
var playlist = data.playlist || [];
dynPlaylistLength = playlist.length;
if (playlist.length === 0) {
dynCurrentTitle = '';
renderSysInfo();
showSplash();
lastPlaylistKey = '';
items = [];
return;
}
// Titel des aktuell laufenden Items ermitteln (currentIdx kann nach
// einem Playlist-Wechsel ggf. noch auf dem alten Index stehen wir
// nehmen das Item des zuletzt gesetzten currentIdx, falls vorhanden).
var cur = playlist[currentIdx] || playlist[0];
dynCurrentTitle = cur.title || cur.src || '';
renderSysInfo();
var key = playlistKey(playlist);
if (key === lastPlaylistKey) {
return; // unchanged — let current rotation continue
}
// Playlist changed: start from beginning.
lastPlaylistKey = key;
items = playlist;
currentIdx = 0;
dynCurrentTitle = items[0].title || items[0].src || '';
renderSysInfo();
showItem(items[0]);
}
// ── Polling ───────────────────────────────────────────────────────
function pollSysInfo() {
fetch('/api/sysinfo')
.then(function(r) { return r.json(); })
// Statische Items (Hostname, Uptime) übergeben; renderSysInfo hängt
// die dynamischen Daten (Titel, Playlist-Länge, Konnektivität) selbst an.
.then(function(d) { renderSysInfo(d.items); })
.catch(function() {});
}
// Bug 1: Fast-Retry beim Start alle 2s pollen bis erste Playlist vorliegt,
// dann auf 30s-Intervall wechseln.
var fastRetryTimer = null;
var slowPollInterval = null;
var playlistReady = false;
function startSlowPoll() {
if (slowPollInterval) return;
// Playlist alle 5s prüfen (fängt MQTT-getriggerte Backend-Änderungen schnell ab).
// Sysinfo läuft separat alle 30s (weiter unten).
slowPollInterval = setInterval(function() {
pollNowPlaying();
}, 5000);
}
function pollNowPlaying() {
fetch('/api/now-playing')
.then(function(r) { return r.json(); })
.then(function(data) {
applyNowPlaying(data);
// Sobald eine echte Playlist vorhanden ist, Fast-Retry beenden.
if (!playlistReady && items.length > 0) {
playlistReady = true;
if (fastRetryTimer) { clearInterval(fastRetryTimer); fastRetryTimer = null; }
startSlowPoll();
}
})
.catch(function() { dot.className = 'offline'; });
}
pollSysInfo();
pollNowPlaying();
setInterval(pollSysInfo, 30000); // sysinfo weiterhin alle 30s
// Fast-Retry: alle 2s bis Playlist bereit.
fastRetryTimer = setInterval(pollNowPlaying, 2000);
// Spätestens nach 60s auf langsames Polling wechseln (Fallback).
setTimeout(function() {
if (!playlistReady) {
if (fastRetryTimer) { clearInterval(fastRetryTimer); fastRetryTimer = null; }
startSlowPoll();
}
}, 60000);
// ── Auto-Reload bei Agent-Neustart ───────────────────────────────
// Der Agent gibt bei jedem Start einen neuen zufälligen Token zurück.
// Falls sich der Token ändert, hat der Agent neu gestartet → Seite neu laden.
var knownStartupToken = null;
function pollStartupToken() {
fetch('/api/startup-token')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d || !d.token) return;
if (knownStartupToken === null) {
// Erster Aufruf: Token merken, kein Reload.
knownStartupToken = d.token;
} else if (knownStartupToken !== d.token) {
// Token hat sich geändert → Agent wurde neu gestartet.
window.location.reload();
}
})
.catch(function() {}); // Agent offline → ignorieren
}
pollStartupToken();
setInterval(pollStartupToken, 5000); // alle 5s prüfen
</script>
</body>
</html>`