- 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>
572 lines
18 KiB
Go
572 lines
18 KiB
Go
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ── 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>`
|