morz-infoboard/player/agent/internal/playerserver/server.go
Jesko Anschütz 62c1b8cd5c UX Block 2: Lösch-Modals, Status-Page Deutsch, Transitions, lokale Assets, Accessibility
- Lösch-Bestätigung: Bulma-Modal statt browser-nativer confirm()
- Status-Page komplett auf Deutsch, relative Zeitstempel ("vor 2 Min")
- Querlinks Admin ↔ Status-Page
- Bulma CSS + SortableJS als lokale go:embed Assets statt CDN
- Player-UI: sanfte Fade-Transitions (500ms) bei Content-Wechsel
- Player-UI: erweitertes Sysinfo-Overlay (Titel, Playlist-Länge, Netzwerk)
- Aria-Labels für Lösch-Buttons und Drag-Handles
- Larry-Fixes: Null-Checks in copy()/switchTab(), Umlaut-Korrektur

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

572 lines
18 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;
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.
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() { 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 = '';
requestAnimationFrame(function() {
requestAnimationFrame(function() { imgView.style.opacity = '1'; });
});
scheduleNext(item.duration_seconds);
} else if (type === 'video') {
videoView.src = item.src;
videoView.style.display = '';
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 = '';
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;
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>`