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