morz-infoboard/player/agent/internal/playerserver/server.go
Jesko Anschütz bbcf0a1228 Baue Ebene 1: Player-UI, Kiosk-Display und vollstaendiges Ansible-Deployment
Player-UI (playerserver):
- Lokale Kiosk-Seite unter /player mit orientierungsgerechtem Splash-Bild
- Splash-PNGs (Portrait/Landscape) eingebettet via go:embed
- Unteres-Drittel-Overlay mit erweiterbaren Sysinfo-Items (Hostname, Uptime)
- /api/now-playing und /api/sysinfo JSON-Endpunkte
- iframe-Overlay fuer spaetere Inhalts-URL

Ansible-Rolle signage_display (neu):
- Pakete: xserver-xorg-core, xinit, openbox, chromium, unclutter
- Kiosk-Skript mit openbox als WM (noetig fuer korrektes --kiosk-Vollbild)
- systemd-Unit mit Conflicts=getty@tty1 (behebt TTY-Blockierung beim Start)
- Chromium Managed Policy: TranslateEnabled=false, Notifications/Geolocation blockiert
- --lang=de Flag gegen Sprachauswahl-Dialog

Ansible-Rolle signage_player (erweitert):
- Legt signage_user an falls nicht vorhanden
- PlayerListenAddr und PlayerContentURL in Konfiguration
- journald volatile Storage (SD-Karten-Schonung)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:34:16 +01:00

284 lines
7.4 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
// NowPlaying describes what the player should currently display.
type NowPlaying struct {
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.
// Add new items in collectSysInfo to extend the 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 Bild wird per JS orientierungsabhängig gesetzt */
#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-iframe liegt über dem Splash, wenn eine URL gesetzt ist */
#frame {
position: fixed; inset: 0;
width: 100%; height: 100%;
border: none;
display: none;
z-index: 10;
}
/* 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>
<div id="dot"></div>
<script>
var splash = document.getElementById('splash');
var overlay = document.getElementById('info-overlay');
var frame = document.getElementById('frame');
var dot = document.getElementById('dot');
// Orientierungsgerechtes Splash-Bild wählen
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 rendern
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;');
}
// Now-Playing anwenden: iframe zeigen wenn URL gesetzt, sonst Splash
function applyNowPlaying(data) {
dot.className = data.connectivity || '';
if (data.url) {
if (frame.src !== data.url) { frame.src = data.url; }
frame.style.display = '';
} else {
frame.style.display = 'none';
}
}
function pollSysInfo() {
fetch('/api/sysinfo')
.then(function(r) { return r.json(); })
.then(function(d) { renderSysInfo(d.items); })
.catch(function() {});
}
function pollNowPlaying() {
fetch('/api/now-playing')
.then(function(r) { return r.json(); })
.then(applyNowPlaying)
.catch(function() { dot.className = 'offline'; });
}
pollSysInfo();
pollNowPlaying();
setInterval(pollSysInfo, 30000);
setInterval(pollNowPlaying, 15000);
</script>
</body>
</html>`