Fix: Transition-Race, Auto-Reload nach Deploy, Playlist-Latenz < 1s

- hideAllContent() prüft opacity bevor display=none gesetzt wird
  (verhindert Race mit displayItem)
- Neuer /api/startup-token Endpoint: Browser erkennt Agent-Neustart
  und reloaded automatisch
- MQTT-Debounce von 3s auf 500ms, Browser-Poll von 30s auf 5s
  reduziert für sub-sekunden Playlist-Updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 11:48:57 +01:00
parent 585cb83ed0
commit 6931181916
2 changed files with 57 additions and 7 deletions

View file

@ -12,7 +12,8 @@ import (
const ( const (
// debounceDuration is the minimum interval between two callback invocations. // debounceDuration is the minimum interval between two callback invocations.
// Any MQTT message arriving while the timer is still running resets it. // Any MQTT message arriving while the timer is still running resets it.
debounceDuration = 3 * time.Second // 500ms reicht aus um Bursts zu absorbieren, ohne die Latenz merklich zu erhöhen.
debounceDuration = 500 * time.Millisecond
// playlistChangedTopicTemplate is the topic the backend publishes to. // playlistChangedTopicTemplate is the topic the backend publishes to.
playlistChangedTopic = "signage/screen/%s/playlist-changed" playlistChangedTopic = "signage/screen/%s/playlist-changed"

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"math/rand"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -48,14 +49,16 @@ type SysInfo struct {
// Server serves the local player UI to Chromium. // Server serves the local player UI to Chromium.
type Server struct { type Server struct {
listenAddr string listenAddr string
nowFn func() NowPlaying nowFn func() NowPlaying
startupToken string // zufälliger Token der sich bei jedem Start ändert
} }
// New creates a Server. listenAddr is e.g. "127.0.0.1:8090". // 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. // nowFn is called on each request and returns the current playback state.
func New(listenAddr string, nowFn func() NowPlaying) *Server { func New(listenAddr string, nowFn func() NowPlaying) *Server {
return &Server{listenAddr: listenAddr, nowFn: nowFn} token := fmt.Sprintf("%016x", rand.Uint64())
return &Server{listenAddr: listenAddr, nowFn: nowFn, startupToken: token}
} }
// Run starts the HTTP server and blocks until ctx is cancelled. // Run starts the HTTP server and blocks until ctx is cancelled.
@ -69,6 +72,7 @@ func (s *Server) Run(ctx context.Context) error {
mux.HandleFunc("GET /player", s.handlePlayer) mux.HandleFunc("GET /player", s.handlePlayer)
mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying) mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying)
mux.HandleFunc("GET /api/sysinfo", handleSysInfo) mux.HandleFunc("GET /api/sysinfo", handleSysInfo)
mux.HandleFunc("GET /api/startup-token", s.handleStartupToken)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
srv := &http.Server{Handler: mux} srv := &http.Server{Handler: mux}
@ -99,6 +103,14 @@ func (s *Server) handleNowPlaying(w http.ResponseWriter, _ *http.Request) {
json.NewEncoder(w).Encode(s.nowFn()) //nolint:errcheck json.NewEncoder(w).Encode(s.nowFn()) //nolint:errcheck
} }
// handleStartupToken gibt einen zufälligen Token zurück der sich bei jedem
// Agent-Start ändert. Der Browser erkennt daran, dass der Agent neu gestartet
// wurde und lädt die Seite automatisch neu.
func (s *Server) handleStartupToken(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"token": s.startupToken}) //nolint:errcheck
}
func handleSysInfo(w http.ResponseWriter, _ *http.Request) { func handleSysInfo(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck
@ -326,6 +338,11 @@ const playerHTML = `<!DOCTYPE html>
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs. // Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
// Blendet zunächst auf opacity:0 aus und entfernt display erst nach der // Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
// Transition (500ms), damit der Fade-Out sichtbar ist. // Transition (500ms), damit der Fade-Out sichtbar ist.
//
// Race-Condition-Fix: Das setTimeout-Callback prüft vor dem display=none,
// ob das Element noch opacity=0 hat. Falls displayItem() das Element
// inzwischen wieder auf display=block+opacity=1 gesetzt hat, wird es
// nicht fälschlicherweise versteckt.
function hideAllContent() { function hideAllContent() {
// Laufendes Video sofort stoppen damit kein Audio weiterläuft. // Laufendes Video sofort stoppen damit kein Audio weiterläuft.
videoView.pause(); videoView.pause();
@ -335,7 +352,14 @@ const playerHTML = `<!DOCTYPE html>
if (el.style.display !== 'none') { if (el.style.display !== 'none') {
el.style.opacity = '0'; el.style.opacity = '0';
(function(e) { (function(e) {
setTimeout(function() { e.style.display = 'none'; }, 500); setTimeout(function() {
// Nur verstecken wenn das Element noch ausgeblendet ist
// (opacity=0 oder leer). Falls displayItem() es inzwischen
// wieder sichtbar gemacht hat, nicht anfassen.
if (e.style.opacity === '0' || e.style.opacity === '') {
e.style.display = 'none';
}
}, 500);
})(el); })(el);
} }
}); });
@ -533,10 +557,11 @@ const playerHTML = `<!DOCTYPE html>
function startSlowPoll() { function startSlowPoll() {
if (slowPollInterval) return; if (slowPollInterval) return;
// Playlist alle 5s prüfen (fängt MQTT-getriggerte Backend-Änderungen schnell ab).
// Sysinfo läuft separat alle 30s (weiter unten).
slowPollInterval = setInterval(function() { slowPollInterval = setInterval(function() {
pollNowPlaying(); pollNowPlaying();
pollSysInfo(); }, 5000);
}, 30000);
} }
function pollNowPlaying() { function pollNowPlaying() {
@ -567,6 +592,30 @@ const playerHTML = `<!DOCTYPE html>
startSlowPoll(); startSlowPoll();
} }
}, 60000); }, 60000);
// ── Auto-Reload bei Agent-Neustart ───────────────────────────────
// Der Agent gibt bei jedem Start einen neuen zufälligen Token zurück.
// Falls sich der Token ändert, hat der Agent neu gestartet → Seite neu laden.
var knownStartupToken = null;
function pollStartupToken() {
fetch('/api/startup-token')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d || !d.token) return;
if (knownStartupToken === null) {
// Erster Aufruf: Token merken, kein Reload.
knownStartupToken = d.token;
} else if (knownStartupToken !== d.token) {
// Token hat sich geändert → Agent wurde neu gestartet.
window.location.reload();
}
})
.catch(function() {}); // Agent offline → ignorieren
}
pollStartupToken();
setInterval(pollStartupToken, 5000); // alle 5s prüfen
</script> </script>
</body> </body>
</html>` </html>`