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:
parent
aff12a4d81
commit
f11bd4f6c4
4 changed files with 194 additions and 17 deletions
1
TODO.md
1
TODO.md
|
|
@ -74,6 +74,7 @@
|
||||||
- [x] Minimalen Player-Agent-Prototyp bauen
|
- [x] Minimalen Player-Agent-Prototyp bauen
|
||||||
- [x] Minimale Player-UI bauen
|
- [x] Minimale Player-UI bauen
|
||||||
- [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen
|
- [ ] 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
|
- [x] Fallback-Verzeichnisbetrieb demonstrieren
|
||||||
- [ ] `valid_from`/`valid_until` im Prototyp pruefen
|
- [ ] `valid_from`/`valid_until` im Prototyp pruefen
|
||||||
- [x] Offline-Sync mit lokalem Cache pruefen
|
- [x] Offline-Sync mit lokalem Cache pruefen
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ ansible_user: admin
|
||||||
screen_id: info01-dev
|
screen_id: info01-dev
|
||||||
screen_name: "Info01 Entwicklung"
|
screen_name: "Info01 Entwicklung"
|
||||||
screen_orientation: landscape
|
screen_orientation: landscape
|
||||||
|
morz_server_base_url: "http://192.168.64.1:8080"
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,37 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
font-weight: 500; letter-spacing: 0.03em; color: #fff;
|
font-weight: 500; letter-spacing: 0.03em; color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inhalts-iframe */
|
/* Inhalts-Elemente: iframe, img, video */
|
||||||
#frame {
|
#frame, #img-view, #video-view {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
border: none; display: none; z-index: 10;
|
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 */
|
/* Verbindungsstatus-Punkt */
|
||||||
#dot {
|
#dot {
|
||||||
|
|
@ -210,13 +235,23 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
<div id="splash"></div>
|
<div id="splash"></div>
|
||||||
<div id="info-overlay"></div>
|
<div id="info-overlay"></div>
|
||||||
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe>
|
<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>
|
<div id="dot"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var splash = document.getElementById('splash');
|
var splash = document.getElementById('splash');
|
||||||
var overlay = document.getElementById('info-overlay');
|
var overlay = document.getElementById('info-overlay');
|
||||||
var frame = document.getElementById('frame');
|
var frame = document.getElementById('frame');
|
||||||
var dot = document.getElementById('dot');
|
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 ───────────────────────────────────────────
|
// ── Splash-Orientierung ───────────────────────────────────────────
|
||||||
function updateSplash() {
|
function updateSplash() {
|
||||||
|
|
@ -260,24 +295,115 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
|
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showItem(item) {
|
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
|
||||||
if (!item) { showSplash(); return; }
|
function hideAllContent() {
|
||||||
if (frame.src !== item.src) { frame.src = item.src; }
|
frame.style.display = 'none';
|
||||||
frame.style.display = '';
|
imgView.style.display = 'none';
|
||||||
// Hide splash overlay while content is visible.
|
videoView.style.display = 'none';
|
||||||
overlay.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();
|
clearRotation();
|
||||||
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
var ms = Math.max((durationSeconds || 20), 1) * 1000;
|
||||||
rotateTimer = setTimeout(function() {
|
rotateTimer = setTimeout(function() {
|
||||||
currentIdx = (currentIdx + 1) % items.length;
|
currentIdx = (currentIdx + 1) % items.length;
|
||||||
showItem(items[currentIdx]);
|
showItem(items[currentIdx]);
|
||||||
}, ms);
|
}, 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() {
|
function showSplash() {
|
||||||
clearRotation();
|
clearRotation();
|
||||||
frame.style.display = 'none';
|
hideAllContent();
|
||||||
|
showSplashDiv();
|
||||||
overlay.style.display = '';
|
overlay.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,17 +452,48 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
.catch(function() {});
|
.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() {
|
function pollNowPlaying() {
|
||||||
fetch('/api/now-playing')
|
fetch('/api/now-playing')
|
||||||
.then(function(r) { return r.json(); })
|
.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'; });
|
.catch(function() { dot.className = 'offline'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
pollSysInfo();
|
pollSysInfo();
|
||||||
pollNowPlaying();
|
pollNowPlaying();
|
||||||
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
|
setInterval(pollSysInfo, 30000); // sysinfo weiterhin alle 30s
|
||||||
setInterval(pollNowPlaying, 30000); // playlist 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
mimeType := header.Header.Get("Content-Type")
|
mimeType := header.Header.Get("Content-Type")
|
||||||
|
if detectedType := mimeToAssetType(mimeType); detectedType != "" {
|
||||||
|
assetType = detectedType
|
||||||
|
}
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
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 {
|
func sanitize(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue