Backend:
- ScreenStore.Upsert(): idempotentes INSERT ON CONFLICT für Self-Registration
- POST /api/v1/screens/register: Agent registriert sich beim Start (upsert)
- manage/register.go: neuer Handler, immer unter Tenant "morz"
Agent:
- config: screen_name + screen_orientation (mit Fallback auf screen_id / landscape)
- app.go: registerScreen() — POST /api/v1/screens/register beim Start (Retry 30s)
- app.go: pollPlaylist() — GET /api/v1/screens/{slug}/playlist alle 60s
- app.go: nowFn liefert Playlist statt statischer URL; PlayerContentURL als Fallback
- playerserver: PlaylistItem-Struct in NowPlaying; JS rotiert Items per duration_seconds
- JS: Playlist-Fingerprint verhindert Reset laufender Rotation bei unverändertem Stand
Ansible:
- config.json.j2: screen_name + screen_orientation ergänzt
- host_vars/info10: screen_name + screen_orientation
- host_vars/info01-dev: screen_name + screen_orientation
Kopplung per Konvention: screen_id (config.json) = slug (DB)
Beim ersten Neustart der Agents erscheinen die Bildschirme automatisch im Admin-UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
9.9 KiB
Go
342 lines
9.9 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-iframe */
|
|
#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');
|
|
|
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// ── 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; }
|
|
}
|
|
|
|
function showItem(item) {
|
|
if (!item) { showSplash(); return; }
|
|
if (frame.src !== item.src) { frame.src = item.src; }
|
|
frame.style.display = '';
|
|
// Hide splash overlay while content is visible.
|
|
overlay.style.display = 'none';
|
|
|
|
clearRotation();
|
|
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
|
rotateTimer = setTimeout(function() {
|
|
currentIdx = (currentIdx + 1) % items.length;
|
|
showItem(items[currentIdx]);
|
|
}, ms);
|
|
}
|
|
|
|
function showSplash() {
|
|
clearRotation();
|
|
frame.style.display = 'none';
|
|
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() {});
|
|
}
|
|
|
|
function pollNowPlaying() {
|
|
fetch('/api/now-playing')
|
|
.then(function(r) { return r.json(); })
|
|
.then(applyNowPlaying)
|
|
.catch(function() { dot.className = 'offline'; });
|
|
}
|
|
|
|
pollSysInfo();
|
|
pollNowPlaying();
|
|
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
|
|
setInterval(pollNowPlaying, 30000); // playlist alle 30s
|
|
</script>
|
|
</body>
|
|
</html>`
|