Bugfixes: Player-UI Content-Rendering, Backend-URL Dev-Display, MIME-Type-Erkennung

- Player-UI: Content-Type-Handling (image/video/web statt alles-iframe),
  Fast-Retry-Polling beim Start, Splash wird korrekt ausgeblendet,
  Fallback-Anzeige bei X-Frame-Options-Blockade
- Dev-Display: Backend-URL auf 192.168.64.1 für Multipass-Netz korrigiert
- Media-Upload: Typ wird aus MIME-Type abgeleitet statt blind aus Formular
- TODO: Daten-Bug dokumentiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 10:50:17 +01:00
parent aff12a4d81
commit f11bd4f6c4
4 changed files with 194 additions and 17 deletions

View file

@ -74,6 +74,7 @@
- [x] Minimalen Player-Agent-Prototyp bauen
- [x] Minimale Player-UI bauen
- [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen
- [ ] **BUG**: Datei `120papag.mpg` ist als `type: image` gespeichert, muss `type: video` sein Player-UI versucht `<img>`-Laden, was fehlschlägt
- [x] Fallback-Verzeichnisbetrieb demonstrieren
- [ ] `valid_from`/`valid_until` im Prototyp pruefen
- [x] Offline-Sync mit lokalem Cache pruefen

View file

@ -4,3 +4,4 @@ ansible_user: admin
screen_id: info01-dev
screen_name: "Info01 Entwicklung"
screen_orientation: landscape
morz_server_base_url: "http://192.168.64.1:8080"

View file

@ -187,12 +187,37 @@ const playerHTML = `<!DOCTYPE html>
font-weight: 500; letter-spacing: 0.03em; color: #fff;
}
/* Inhalts-iframe */
#frame {
/* 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;
}
#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 {
@ -210,12 +235,22 @@ const playerHTML = `<!DOCTYPE html>
<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 ───────────────────────────────────────────
@ -260,24 +295,115 @@ const playerHTML = `<!DOCTYPE html>
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';
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
function hideAllContent() {
frame.style.display = 'none';
imgView.style.display = 'none';
videoView.style.display = 'none';
frameError.style.display = 'none';
// Laufendes Video stoppen damit kein Audio weiterläuft.
videoView.pause();
videoView.src = '';
}
// 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((item.duration_seconds || 20), 1) * 1000;
var ms = Math.max((durationSeconds || 20), 1) * 1000;
rotateTimer = setTimeout(function() {
currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]);
}, ms);
}
function showItem(item) {
if (!item) { showSplash(); return; }
hideAllContent();
hideSplash();
overlay.style.display = 'none';
var type = item.type || 'web';
if (type === 'image') {
imgView.src = item.src;
imgView.style.display = '';
scheduleNext(item.duration_seconds);
} else if (type === 'video') {
videoView.src = item.src;
videoView.style.display = '';
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 = '';
// Bug 3: 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();
frame.style.display = 'none';
hideAllContent();
showSplashDiv();
overlay.style.display = '';
}
@ -326,17 +452,48 @@ const playerHTML = `<!DOCTYPE html>
.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(applyNowPlaying)
.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 alle 30s
setInterval(pollNowPlaying, 30000); // playlist alle 30s
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>`

View file

@ -83,6 +83,9 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
defer file.Close()
mimeType := header.Header.Get("Content-Type")
if detectedType := mimeToAssetType(mimeType); detectedType != "" {
assetType = detectedType
}
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
@ -149,6 +152,21 @@ func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFu
}
}
// mimeToAssetType leitet den Asset-Typ aus dem MIME-Type ab.
func mimeToAssetType(mime string) string {
mime = strings.ToLower(strings.TrimSpace(mime))
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case mime == "application/pdf":
return "pdf"
default:
return ""
}
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {