morz-infoboard/player/agent/internal/playerserver/server.go
Jesko Anschütz f11bd4f6c4 Bugfixes: Player-UI Content-Rendering, Backend-URL Dev-Display, MIME-Type-Erkennung
- Player-UI: Content-Type-Handling (image/video/web statt alles-iframe),
  Fast-Retry-Polling beim Start, Splash wird korrekt ausgeblendet,
  Fallback-Anzeige bei X-Frame-Options-Blockade
- Dev-Display: Backend-URL auf 192.168.64.1 für Multipass-Netz korrigiert
- Media-Upload: Typ wird aus MIME-Type abgeleitet statt blind aus Formular
- TODO: Daten-Bug dokumentiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:50:17 +01:00

499 lines
16 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"
"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
}
// 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 {
return &Server{listenAddr: listenAddr, nowFn: nowFn}
}
// 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.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
}
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;
}
#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 ───────────────────────────────────────────────
function renderSysInfo(items) {
overlay.innerHTML = '';
(items || []).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;
// 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.
function hideAllContent() {
frame.style.display = 'none';
imgView.style.display = 'none';
videoView.style.display = 'none';
frameError.style.display = 'none';
// Laufendes Video stoppen damit kein Audio weiterläuft.
videoView.pause();
videoView.src = '';
}
// 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);
}
function showItem(item) {
if (!item) { showSplash(); return; }
hideAllContent();
hideSplash();
overlay.style.display = 'none';
var type = item.type || 'web';
if (type === 'image') {
imgView.src = item.src;
imgView.style.display = '';
scheduleNext(item.duration_seconds);
} else if (type === 'video') {
videoView.src = item.src;
videoView.style.display = '';
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 = '';
// Bug 3: 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) {
dot.className = data.connectivity || '';
// Legacy single-URL fallback.
if (data.url && (!data.playlist || data.playlist.length === 0)) {
var key = data.url + ':legacy';
if (lastPlaylistKey !== key) {
lastPlaylistKey = key;
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
currentIdx = 0;
showItem(items[0]);
}
return;
}
var playlist = data.playlist || [];
if (playlist.length === 0) {
showSplash();
lastPlaylistKey = '';
items = [];
return;
}
var key = playlistKey(playlist);
if (key === lastPlaylistKey) {
return; // unchanged — let current rotation continue
}
// Playlist changed: start from beginning.
lastPlaylistKey = key;
items = playlist;
currentIdx = 0;
showItem(items[0]);
}
// ── Polling ───────────────────────────────────────────────────────
function pollSysInfo() {
fetch('/api/sysinfo')
.then(function(r) { return r.json(); })
.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;
slowPollInterval = setInterval(function() {
pollNowPlaying();
pollSysInfo();
}, 30000);
}
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);
</script>
</body>
</html>`