b2in/public/_cabinet/display/index.html
Kevin Adametz 193d37b1ce Display CMS: Upload-Fehler abfangen und kaputte Bilder im Player überspringen.
Mediathek-Uploads legen keinen Datensatz mehr an, wenn der Datei-Write
fehlschlägt (verhindert 404-Einträge auf den Displays). Der Player
überspringt Bilder mit Ladefehler nach 2s statt Schwarzbild zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 08:57:31 +00:00

1947 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cabinet – Display Player</title>
<link rel="apple-touch-icon" sizes="57x57" href="../../favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="../../favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="../../favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="../../favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="../../favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="../../favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="../../favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="../../favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="../../favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="../../favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="96x96" href="../../favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../favicon/favicon-16x16.png">
<link rel="shortcut icon" href="../../favicon/favicon.ico">
<link rel="manifest" href="../../favicon/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="../../favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #000;
font-family: 'IBM Plex Sans', -apple-system, sans-serif;
cursor: none;
}
/* ========================================
PLAYER FRAME – 9:16 Container
======================================== */
.player-frame {
position: fixed; inset: 0;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: #000;
}
.player-viewport {
width: min(100vw, calc(100vh * 9 / 16));
height: min(100vh, calc(100vw * 16 / 9));
max-width: 1080px; max-height: 1920px;
aspect-ratio: 9 / 16;
position: relative;
overflow: hidden;
background: #000;
container-type: size;
}
/* ========================================
VERSION LAYERS
Each version type renders in its own layer
======================================== */
.version-layer {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.8s ease;
pointer-events: none;
}
.version-layer.active {
opacity: 1;
pointer-events: auto;
}
/* ========================================
VIDEO-DISPLAY TYPE
======================================== */
.vd-layer {
display: flex;
flex-direction: column;
background: #000;
}
.vd-video-area {
flex: 1;
position: relative;
overflow: hidden;
}
.vd-video {
width: 100%; height: 100%;
object-fit: cover;
object-position: center 25%;
display: block;
}
.vd-footer {
height: 9.67%;
background: #1a1a1a; color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 3%;
font-size: clamp(5px, 0.92cqw, 10px);
position: relative;
}
.vd-footer.hidden { display: none; }
.vd-progress {
position: absolute; top: 0; left: 0;
height: 3px; background: #009FE3; width: 0%;
}
.vd-text { width: 75%; }
.vd-headline {
font-size: 2em; font-weight: 300; margin-bottom: 0.3em;
text-transform: uppercase; letter-spacing: 0.05em; color: #bbb;
}
.vd-subline { font-size: 2.4em; font-weight: 700; line-height: 1.1; }
.vd-qr {
width: 25%; display: flex; flex-direction: column; align-items: center;
}
.vd-qr img {
width: clamp(40px, 8.5cqw, 100px); aspect-ratio: 1;
object-fit: contain; background: #fff;
padding: 0.4em; border-radius: 0.6em;
}
.vd-qr-label {
margin-top: 0.8em; font-size: 1.3em;
text-transform: uppercase; letter-spacing: 0.05em;
font-weight: 600; color: #009FE3;
}
/* ========================================
B2IN TYPE
======================================== */
.b2in-layer { display: flex; flex-direction: column; }
/* B2in Header */
.b2in-header {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 2.5vh 3vh;
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
}
.b2in-header img { height: 3.5vh; }
.b2in-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.7);
}
/* B2in Brand (positionable logo + claim) */
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
.b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
.b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
.b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
/* Legibility scrims behind positioned brand elements */
.b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
.b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
.b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
/* B2in Media */
.b2in-media {
flex: 1; position: relative; overflow: hidden;
}
.b2in-media-layer {
position: absolute; inset: 0;
opacity: 0; transition: opacity 0.8s ease;
}
.b2in-media-layer.active { opacity: 1; }
.b2in-media-layer img,
.b2in-media-layer video {
width: 100%; height: 100%; object-fit: cover;
}
/* B2in Text */
.b2in-text {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 10;
padding: 4vh 3vh 12vh;
background: linear-gradient(to top, rgba(0,0,0,0.7) 40%, transparent);
}
.b2in-headline {
font-size: 3vh; font-weight: 600; line-height: 1.2;
color: #fff; margin-bottom: 1vh;
}
.b2in-subline {
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
line-height: 1.4;
}
/* B2in Footer */
.b2in-footer {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 1.5vh 3vh;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
}
.b2in-footer-url { font-size: 1.5vh; font-weight: 600; color: #20a0da; }
.b2in-footer-name { font-size: 1.2vh; color: rgba(255,255,255,0.5); margin-left: 1vh; }
.b2in-footer-qr img {
height: clamp(32px, 5cqh, 96px); aspect-ratio: 1; border-radius: 0.5vh;
background: #fff; padding: 0.3vh;
}
/* B2in Progress */
.b2in-progress-track {
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; background: rgba(255,255,255,0.1); z-index: 20;
}
.b2in-progress-fill {
height: 100%; width: 0%; background: #20a0da;
}
/* B2in Light Theme overrides */
.b2in-layer[data-theme="light"] {
background: #f7f8fa;
}
.b2in-layer[data-theme="light"] .b2in-header {
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
}
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
.b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-text {
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
}
.b2in-layer[data-theme="light"] .b2in-headline { color: #2b3f51; }
.b2in-layer[data-theme="light"] .b2in-subline { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-footer {
background: rgba(247,248,250,0.6);
}
.b2in-layer[data-theme="light"] .b2in-footer-name { color: rgba(43,63,81,0.5); }
/* Without footer the text reclaims the footer's space and loses the bottom scrim */
.b2in-layer.no-footer .b2in-text,
.b2in-layer.no-footer[data-theme="light"] .b2in-text {
padding-bottom: 4vh;
background: none;
}
/* ========================================
OFFERS TYPE (DOM-based slides)
======================================== */
.offers-layer {
background: #fff;
overflow: hidden;
}
.offers-slide-container {
position: absolute; inset: 0;
opacity: 0; transition: opacity 0.6s ease;
pointer-events: none;
display: flex; align-items: flex-start; justify-content: flex-start;
overflow: hidden;
}
.offers-slide-container.active { opacity: 1; }
/*
* Slide renders at fixed 1080x1920 design size.
* JS applies transform:scale() to fit any container.
*/
.offer-slide {
width: 1080px; height: 1920px;
padding: 64px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 32px;
background: #fff;
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
color: #1a1a1a;
transform-origin: top left;
flex-shrink: 0;
}
.offer-slide--no-header { grid-template-rows: 1fr auto; }
.offer-slide--no-bottom { grid-template-rows: auto 1fr; }
.offer-slide--no-header.offer-slide--no-bottom { grid-template-rows: 1fr; }
/* Offer Header */
.offer-header {
display: flex; align-items: flex-end; justify-content: space-between;
padding-bottom: 24px; border-bottom: 1px solid #e8e8e8;
min-height: 100px;
}
.offer-brand { display: flex; align-items: center; gap: 14px; }
.offer-brand-logo { height: 82px; width: auto; }
.offer-brand-text {
font-size: 28px; font-weight: 600; letter-spacing: 0.12em;
text-transform: uppercase; color: #000;
}
.offer-tagline {
font-size: 24px; color: #737373; text-align: right;
line-height: 1.4; font-weight: 400;
}
/* Offer Hero */
.offer-hero {
border-radius: 24px; overflow: hidden; position: relative;
display: flex; align-items: flex-end; justify-content: flex-start;
padding: 32px; border: 1px solid #e8e8e8;
background: linear-gradient(145deg, #f5f5f5, #fafafa);
background-size: cover; background-position: center;
}
.offer-hero-badge {
font-size: 20px; font-weight: 500; color: #1a1a1a;
background: rgba(255,255,255,0.92); border: 1px solid rgba(0,0,0,0.08);
border-radius: 100px; padding: 14px 24px;
backdrop-filter: blur(16px); box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.offer-hero-badge.large { font-size: 24px; padding: 16px 28px; }
/* Offer Bottom: Info + QR */
.offer-bottom {
display: grid; grid-template-columns: 1fr 300px; gap: 24px;
align-items: stretch;
}
.offer-bottom--info-only,
.offer-bottom--qr-only {
grid-template-columns: 1fr;
}
.offer-bottom--compact {
align-items: start;
}
/* Info Box */
.offer-info {
display: flex; flex-direction: column; justify-content: space-between;
background: linear-gradient(180deg, #fff, #fafafa);
border: 1px solid #e8e8e8; border-radius: 24px;
padding: 28px; min-height: 340px;
}
.offer-info--compact {
min-height: 0;
padding: 24px 28px;
}
.offer-info--title-only {
padding: 22px 28px;
}
.offer-info-content { flex: 1; }
.offer-eyebrow {
font-size: 18px; color: #737373; letter-spacing: 0.14em;
text-transform: uppercase; margin-bottom: 14px; font-weight: 500;
}
.offer-title {
font-size: 54px; line-height: 1.08; font-weight: 700;
margin-bottom: 14px; color: #000; letter-spacing: -0.02em;
}
.offer-title.large { font-size: 64px; letter-spacing: -0.025em; }
.offer-title.medium { font-size: 42px; letter-spacing: -0.015em; }
.offer-subline {
font-size: 28px; color: #737373; line-height: 1.35;
margin-bottom: 16px; font-weight: 400;
}
/* Price */
.offer-price-block {
margin-top: auto; padding-top: 24px; border-top: 1px solid #e8e8e8;
}
.offer-price-row {
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
}
.offer-price {
font-size: 84px; font-weight: 700; letter-spacing: -0.03em;
color: #000; line-height: 1; font-feature-settings: 'tnum' 1;
}
.offer-price-note {
font-size: 24px; color: #737373; text-align: right;
line-height: 1.35; font-weight: 400;
}
.offer-price-note.strike {
color: #dc2626; text-decoration: line-through;
text-decoration-color: #dc2626; text-decoration-thickness: 3px;
}
/* Bullets */
.offer-bullets {
list-style: none; display: flex; flex-direction: column;
gap: 14px; margin-top: 20px; padding: 0;
}
.offer-bullets li {
font-size: 28px; line-height: 1.35; display: flex;
align-items: flex-start; gap: 16px; color: #1a1a1a;
}
.offer-bullet-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #009FE3; margin-top: 13px; flex-shrink: 0;
}
/* Impulse Tag */
.offer-impulse-tag {
display: inline-block; background: #009FE3; color: #fff;
font-size: 18px; font-weight: 600; padding: 10px 18px;
border-radius: 10px; margin-top: 12px;
letter-spacing: 0.03em; text-transform: uppercase;
}
/* Footer within info */
.offer-info-footer {
margin-top: auto; padding-top: 20px; border-top: 1px solid #e8e8e8;
}
.offer-disclaimer {
font-size: 16px; color: #999; font-weight: 400; letter-spacing: 0.01em;
}
/* QR Box */
.offer-qr-box {
display: flex; flex-direction: column; background: #f5f5f5;
border: 1px solid #e8e8e8; border-radius: 24px;
padding: 20px; gap: 12px;
}
.offer-qr-header { text-align: center; }
.offer-qr-title {
font-size: 24px; font-weight: 600; color: #000;
margin-bottom: 6px; letter-spacing: -0.01em;
}
.offer-qr-subtitle { font-size: 18px; color: #737373; font-weight: 400; }
.offer-qr-wrapper {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #fff; border-radius: 16px; border: 1px dashed #ddd;
padding: 16px; min-height: 180px;
}
.offer-qr-wrapper img { width: 100%; max-width: 180px; aspect-ratio: 1; }
.offer-qr-contact {
font-size: 18px; color: #737373; text-align: center;
line-height: 1.5; font-weight: 400;
}
/* Offers progress bar */
.offers-progress-bar {
position: absolute; bottom: 0; left: 0; right: 0;
height: 4px; background: rgba(0,0,0,0.1); z-index: 20;
}
.offers-progress-fill {
height: 100%; width: 0%; background: #009FE3;
}
/* ========================================
LOADING / ERROR STATES
======================================== */
.status-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #000; color: #fff;
font-size: 2vh;
}
.status-overlay.hidden { display: none; }
.status-spinner {
width: 5vh; height: 5vh;
border: 3px solid rgba(255,255,255,0.2);
border-top-color: #009FE3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 3vh;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-message { font-weight: 300; opacity: 0.7; }
.status-error { color: #ef4444; font-weight: 500; }
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
.display-empty {
position: absolute; inset: 0; z-index: 10;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 6vw;
background: #000; color: #fff;
}
.display-empty__title { font-size: 2.4vh; font-weight: 600; }
.display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
.display-overview {
position: fixed; inset: 0; z-index: 10000;
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
color: #fff; cursor: auto; padding: clamp(24px, 5vw, 72px);
}
.display-overview.hidden { display: none; }
.display-overview__inner { width: min(1120px, 100%); margin: 0 auto; }
.display-overview__eyebrow {
color: #38bdf8; font-size: 13px; font-weight: 700;
letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 12px;
}
.display-overview h1 {
font-size: clamp(34px, 6vw, 76px); line-height: 0.95;
letter-spacing: -0.05em; margin-bottom: 18px;
}
.display-overview__intro {
max-width: 720px; color: rgba(255,255,255,0.68);
font-size: clamp(16px, 2vw, 22px); line-height: 1.5; margin-bottom: 36px;
}
.display-overview__grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.display-card {
display: flex; flex-direction: column; gap: 16px;
min-height: 220px; padding: 24px; border-radius: 28px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08); color: #fff; text-decoration: none;
box-shadow: 0 24px 70px rgba(0,0,0,0.24);
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.display-card:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.55);
background: rgba(255,255,255,0.12);
}
.display-card__badges { display: flex; flex-wrap: wrap; gap: 8px; }
.display-badge {
border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(134,239,172,0.28);
}
.display-badge--live { background: rgba(56,189,248,0.18); color: #7dd3fc; border-color: rgba(125,211,252,0.28); }
.display-card__title { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; }
.display-card__meta { display: grid; gap: 6px; color: rgba(255,255,255,0.62); font-size: 15px; }
.display-card__action { margin-top: auto; color: #7dd3fc; font-weight: 700; }
.display-overview__empty {
border: 1px dashed rgba(255,255,255,0.24); border-radius: 28px;
padding: 32px; color: rgba(255,255,255,0.62);
}
</style>
</head>
<body>
<div class="player-frame">
<div class="player-viewport" id="viewport">
<!-- Version layers are dynamically created here -->
</div>
</div>
<!-- Loading Overlay -->
<div class="status-overlay" id="loading">
<div class="status-spinner"></div>
<div class="status-message">Display wird geladen...</div>
<div class="status-sub" id="loading-info"></div>
</div>
<!-- Error Overlay -->
<div class="status-overlay hidden" id="error-overlay">
<div class="status-error" id="error-message">Fehler</div>
<div class="status-sub">Neustart in Kürze...</div>
</div>
<div class="display-overview hidden" id="display-overview">
<div class="display-overview__inner">
<div class="display-overview__eyebrow">Cabinet Display Player</div>
<h1>Aktive Live-Displays</h1>
<p class="display-overview__intro">
Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
</p>
<div class="display-overview__grid" id="display-overview-list"></div>
</div>
</div>
<script>
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function detectDisplayPortalBaseUrl() {
const { protocol, hostname, port } = window.location;
const portSuffix = port ? `:${port}` : '';
if (hostname === 'cabinet.b2in.eu') {
return 'https://portal.b2in.eu';
}
if (hostname.startsWith('cabinet.')) {
return `${protocol}//${hostname.replace(/^cabinet\./, 'portal.')}${portSuffix}`;
}
return window.location.origin;
}
const DISPLAY_PORTAL_BASE_URL = detectDisplayPortalBaseUrl();
function resolveDisplayUrl(url) {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/_cabinet/')) return url;
if (url.startsWith('/') && DISPLAY_PORTAL_BASE_URL !== window.location.origin) {
return `${DISPLAY_PORTAL_BASE_URL}${url}`;
}
if (url.startsWith('/')) return url;
if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`;
if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`;
if (url.startsWith('../')) return url;
return `../${url}`;
}
function normalizeQrUrl(url) {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `https://${url}`;
}
function qrCodeImageUrl(url, size = 300, margin = 8, extraParams = {}) {
const normalizedUrl = normalizeQrUrl(url);
if (!normalizedUrl) return '';
const params = new URLSearchParams({
size: `${size}x${size}`,
margin: String(margin),
data: normalizedUrl,
...extraParams,
});
return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`;
}
function setQrImage(img, url, options = {}) {
const baseSrc = options.imageUrl
? resolveDisplayUrl(options.imageUrl)
: qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {});
if (!img || !baseSrc) return false;
const maxAttempts = options.maxAttempts || 3;
let attempt = 0;
img.alt = options.alt || 'QR Code';
img.loading = 'eager';
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.style.visibility = 'hidden';
const load = () => {
attempt++;
const separator = baseSrc.includes('?') ? '&' : '?';
img.src = `${baseSrc}${separator}cb=${Date.now()}-${attempt}`;
};
img.onload = () => {
img.style.visibility = 'visible';
};
img.onerror = () => {
if (attempt < maxAttempts) {
setTimeout(load, attempt * 1000);
return;
}
img.style.visibility = 'hidden';
};
load();
return true;
}
/**
* Cabinet Display Player
*
* Loads a display config from the API and plays all assigned versions
* in sequence as an infinite loop. Supports: video-display, b2in, offers.
*
* URL: /display/{id} or /display/?id={id}
*/
class DisplayPlayer {
constructor() {
// Detect display context from URL
this.previewToken = this.detectPreviewToken();
this.moduleId = this.detectModuleId();
this.itemId = this.detectItemId();
this.displayId = this.detectDisplayId();
// API
this.BASE_URL = DISPLAY_PORTAL_BASE_URL;
this.API_CONFIG = this.detectConfigUrl();
this.API_CHECK = this.detectCheckUrl();
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
// Timing
this.POLL_INTERVAL = 60000;
this.FILE_CHECK_INTERVAL = 120000;
this.RELOAD_INTERVAL = 6 * 3600000;
this.MAX_FAILURES = 5;
this.RECOVERY_WAIT = 300000;
this.VIDEO_START_TIMEOUT = 10000;
this.VIDEO_WATCHDOG_INTERVAL = 5000;
// State
this.playlist = [];
this.currentVersionIndex = 0;
this.cachedTimestamp = null;
this.fileVersion = null;
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRunning = false;
this.activeVersionRenderer = null;
this.emptyVersionStreak = 0;
// DOM
this.viewport = document.getElementById('viewport');
this.loadingOverlay = document.getElementById('loading');
this.loadingInfo = document.getElementById('loading-info');
this.errorOverlay = document.getElementById('error-overlay');
this.errorMessage = document.getElementById('error-message');
this.overviewOverlay = document.getElementById('display-overview');
this.overviewList = document.getElementById('display-overview-list');
this.loadingInfo.textContent = this.detectLoadingLabel();
this.init();
}
detectDisplayId() {
// Try URL param: ?id=1
const params = new URLSearchParams(window.location.search);
if (params.get('id')) {
return params.get('id');
}
// Try path: /display/1 (if served via rewrite)
const pathMatch = window.location.pathname.match(/\/display\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectPreviewToken() {
const params = new URLSearchParams(window.location.search);
if (params.get('preview')) {
return params.get('preview');
}
const pathMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
if (pathMatch && pathMatch[1] !== 'module') {
return pathMatch[1];
}
return null;
}
detectModuleId() {
const params = new URLSearchParams(window.location.search);
if (params.get('module')) {
return params.get('module');
}
const pathMatch = window.location.pathname.match(/\/preview\/module\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectItemId() {
const params = new URLSearchParams(window.location.search);
if (params.get('item')) {
return params.get('item');
}
const pathMatch = window.location.pathname.match(/\/preview\/module\/\d+\/item\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectConfigUrl() {
if (this.previewToken) {
return `${this.BASE_URL}/api/display/preview/${this.previewToken}`;
}
if (this.moduleId && this.itemId) {
return `${this.BASE_URL}/api/display/module/${this.moduleId}/item/${this.itemId}/preview`;
}
if (this.moduleId) {
return `${this.BASE_URL}/api/display/module/${this.moduleId}/preview`;
}
return `${this.BASE_URL}/api/display/${this.displayId}/config`;
}
detectCheckUrl() {
if (this.previewToken || this.moduleId) {
return null;
}
return `${this.BASE_URL}/api/display/${this.displayId}/check`;
}
detectLoadingLabel() {
if (this.previewToken) {
return 'Display-Entwurf';
}
if (this.moduleId) {
if (this.itemId) {
return `Modul #${this.moduleId} / Item #${this.itemId}`;
}
return `Modul #${this.moduleId}`;
}
if (!this.displayId) {
return 'Display-Übersicht';
}
return `Display #${this.displayId}`;
}
detectBaseUrl() {
return DISPLAY_PORTAL_BASE_URL;
}
// ========================================
// INIT
// ========================================
async init() {
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
try {
if (!this.displayId && !this.previewToken && !this.moduleId) {
await this.fetchOverview();
} else {
await this.fetchConfig();
if (this.playlist.length === 0) {
this.showError('Keine Versionen zugewiesen');
this.scheduleRetry();
} else {
this.hideLoading();
this.startPlaylist();
}
}
} catch (error) {
console.error('[Display] Init failed:', error);
this.showError('Konfiguration konnte nicht geladen werden');
this.scheduleRetry();
}
this.startConfigPolling();
this.startFileVersionPolling();
this.scheduleAutoReload();
}
// ========================================
// DATA FETCHING
// ========================================
async fetchConfig() {
const response = await fetch(this.API_CONFIG);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.cachedTimestamp = data.updated_at;
this.playlist = data.playlist || [];
console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
}
async fetchOverview() {
const response = await fetch(this.API_OVERVIEW);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.renderOverview(data.displays || []);
}
startConfigPolling() {
if (!this.API_CHECK) {
return;
}
setInterval(async () => {
try {
const response = await fetch(this.API_CHECK);
if (!response.ok) return;
const data = await response.json();
if (data.updated_at !== this.cachedTimestamp) {
console.log('[Display] Playlist geändert – Seite wird neu geladen');
location.reload();
}
} catch (error) {
this.failureCount++;
console.warn('[Display] Poll failed:', error.message);
if (this.failureCount >= this.MAX_FAILURES) {
console.error('[Display] Too many failures, reloading in 30s...');
setTimeout(() => location.reload(), 30000);
}
}
}, this.POLL_INTERVAL);
}
startFileVersionPolling() {
this.recordFileVersion();
setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL);
}
playerFileCheckUrl() {
const url = new URL(window.location.href);
url.searchParams.set('_player_filecheck', '1');
return url.toString();
}
readPlayerFileVersion(response) {
return response.headers.get('ETag')
|| response.headers.get('Last-Modified')
|| response.headers.get('Content-Length');
}
async recordFileVersion() {
try {
const response = await fetch(this.playerFileCheckUrl(), {
method: 'HEAD',
cache: 'no-store',
});
this.fileVersion = this.readPlayerFileVersion(response);
} catch (e) {
// Ignorieren – wird beim nächsten Check erneut versucht
}
}
async checkFileVersion() {
try {
const response = await fetch(this.playerFileCheckUrl(), {
method: 'HEAD',
cache: 'no-store',
});
const version = this.readPlayerFileVersion(response);
if (!version) {
return;
}
if (this.fileVersion === null) {
this.fileVersion = version;
return;
}
if (version !== this.fileVersion) {
console.log('[Display] Player-Datei geändert – Seite wird neu geladen');
location.reload();
}
} catch (e) {
// Offline – ignorieren
}
}
scheduleAutoReload() {
setTimeout(() => {
console.log('[Display] Scheduled reload (6h)');
location.reload();
}, this.RELOAD_INTERVAL);
}
scheduleRetry() {
setTimeout(() => location.reload(), 30000);
}
// ========================================
// PLAYLIST SEQUENCER
// ========================================
startPlaylist() {
this.isRunning = true;
this.currentVersionIndex = 0;
this.playCurrentVersion();
}
async playCurrentVersion() {
if (!this.isRunning) return;
const version = this.playlist[this.currentVersionIndex];
if (!version) {
this.currentVersionIndex = 0;
this.playCurrentVersion();
return;
}
// Guard against a delay-free infinite restart loop when no version in the
// playlist has any playable content (e.g. a freshly created, empty module).
if (!this.versionHasContent(version)) {
this.emptyVersionStreak++;
if (this.emptyVersionStreak >= this.playlist.length) {
this.showEmptyPlaylist();
return;
}
this.advanceVersion();
return;
}
this.emptyVersionStreak = 0;
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
// Clean up previous renderer
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
// Clear viewport
this.viewport.innerHTML = '';
// Create renderer for this version type
switch (version.type) {
case 'video-display':
this.activeVersionRenderer = new VideoDisplayRenderer(this.viewport, version, () => this.advanceVersion());
break;
case 'b2in':
this.activeVersionRenderer = new B2inRenderer(this.viewport, version, () => this.advanceVersion());
break;
case 'offers':
this.activeVersionRenderer = new OffersRenderer(this.viewport, version, () => this.advanceVersion());
break;
default:
console.warn(`[Display] Unknown type: ${version.type}, skipping`);
this.advanceVersion();
return;
}
this.activeVersionRenderer.start();
}
advanceVersion() {
this.currentVersionIndex++;
if (this.currentVersionIndex >= this.playlist.length) {
this.currentVersionIndex = 0;
console.log('[Display] Playlist loop complete, restarting');
}
this.playCurrentVersion();
}
versionHasContent(version) {
if (!version) return false;
switch (version.type) {
case 'video-display':
return (version.videoPlaylist || []).length > 0;
case 'b2in':
return (version.items || []).some(item => item.is_active);
case 'offers':
return (version.slides || []).length > 0;
default:
return false;
}
}
showEmptyPlaylist() {
this.isRunning = false;
this.emptyVersionStreak = 0;
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
this.viewport.innerHTML = `
<div class="display-empty">
<p class="display-empty__title">Noch keine Inhalte vorhanden</p>
<p class="display-empty__hint">Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.</p>
</div>
`;
console.log('[Display] Playlist has no playable content – playback stopped.');
}
// ========================================
// UI HELPERS
// ========================================
hideLoading() {
this.loadingOverlay.classList.add('hidden');
}
renderOverview(displays) {
this.hideLoading();
this.errorOverlay.classList.add('hidden');
this.overviewOverlay.classList.remove('hidden');
if (displays.length === 0) {
this.overviewList.innerHTML = `
<div class="display-overview__empty">
Es sind aktuell keine aktiven Live-Displays veröffentlicht.
</div>
`;
return;
}
this.overviewList.innerHTML = displays.map(display => `
<a class="display-card" href="${this.escapeHtml(display.url)}">
<div class="display-card__badges">
<span class="display-badge">Aktiv</span>
<span class="display-badge display-badge--live">Live</span>
</div>
<div>
<div class="display-card__title">${this.escapeHtml(display.name)}</div>
<div class="display-card__meta">
<span>Display-ID: ${this.escapeHtml(display.id)}</span>
${display.location ? `<span>Standort: ${this.escapeHtml(display.location)}</span>` : ''}
<span>${this.escapeHtml(display.module_count)} Modul(e) veröffentlicht</span>
</div>
</div>
<div class="display-card__action">Display öffnen</div>
</a>
`).join('');
}
showError(msg) {
this.loadingOverlay.classList.add('hidden');
this.errorOverlay.classList.remove('hidden');
this.errorMessage.textContent = msg;
}
escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
}
// ============================================================
// VIDEO-DISPLAY RENDERER
// Plays all videos in sequence, then calls onComplete
// ============================================================
class VideoDisplayRenderer {
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.settings = data.settings || {};
this.onComplete = onComplete;
this.videos = data.videoPlaylist || [];
this.footerContent = data.footerContent || [];
this.currentVideoIndex = 0;
this.currentFooterIndex = 0;
this.footerTimer = null;
this.destroyed = false;
this.videoElement = null;
this.videoTimeout = null;
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer vd-layer active';
// Video area
const videoArea = document.createElement('div');
videoArea.className = 'vd-video-area';
this.videoElement = document.createElement('video');
this.videoElement.className = 'vd-video';
this.videoElement.autoplay = true;
this.videoElement.muted = true;
this.videoElement.playsInline = true;
this.videoElement.setAttribute('preload', 'metadata');
videoArea.appendChild(this.videoElement);
layer.appendChild(videoArea);
// Footer
if (this.footerContent.length > 0) {
this.footerEl = document.createElement('div');
this.footerEl.className = 'vd-footer';
this.footerEl.innerHTML = `
<div class="vd-progress" id="vd-progress"></div>
<div class="vd-text">
<div class="vd-headline"></div>
<div class="vd-subline"></div>
</div>
<div class="vd-qr">
<img alt="QR">
<span class="vd-qr-label">${escapeHtml(this.settings.qr_label || 'Website')}</span>
</div>
`;
layer.appendChild(this.footerEl);
this.progressEl = this.footerEl.querySelector('.vd-progress');
} else {
videoArea.style.height = '100%';
}
this.container.appendChild(layer);
this.layer = layer;
}
start() {
if (this.videos.length === 0) {
this.onComplete();
return;
}
this.playVideo(0);
if (this.footerContent.length > 0) {
this.showFooter(0);
this.footerTimer = setInterval(() => {
this.currentFooterIndex = (this.currentFooterIndex + 1) % this.footerContent.length;
this.showFooter(this.currentFooterIndex);
}, 30000);
}
}
playVideo(index) {
if (this.destroyed) return;
if (index >= this.videos.length) {
this.onComplete();
return;
}
// Vorherigen Fallback-Timeout immer clearen
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
this.currentVideoIndex = index;
const video = this.videos[index];
const src = this.resolveAssetUrl(video.src);
// Clean previous
this.videoElement.onended = null;
this.videoElement.onerror = null;
this.videoElement.pause();
this.videoElement.removeAttribute('src');
this.videoElement.load();
// Set position
if (video.position !== undefined) {
this.videoElement.style.objectPosition = `center ${video.position}%`;
}
// Progress
if (this.progressEl) {
this.progressEl.style.transition = 'none';
this.progressEl.style.width = '0%';
}
let handled = false;
const finish = () => {
if (handled || this.destroyed) return;
handled = true;
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
this.playVideo(index + 1);
};
this.videoElement.onended = finish;
this.videoElement.onerror = () => {
console.warn(`[VD] Video error: ${video.src}`);
finish();
};
this.videoElement.src = src;
this.videoElement.play().then(() => {
// Start progress based on duration
if (this.progressEl && this.videoElement.duration) {
const dur = this.videoElement.duration * 1000;
void this.progressEl.offsetWidth;
this.progressEl.style.transition = `width ${dur}ms linear`;
this.progressEl.style.width = '100%';
}
}).catch(e => {
// AbortError = play() wurde absichtlich durch pause()/load() unterbrochen – kein Fehler
if (e && e.name === 'AbortError') return;
finish();
});
// Timeout fallback: Metadaten laden asynchron, daher auf loadedmetadata warten
this.videoElement.addEventListener('loadedmetadata', () => {
if (handled || this.destroyed) return;
if (this.videoTimeout) clearTimeout(this.videoTimeout);
this.videoTimeout = setTimeout(() => finish(), this.videoElement.duration * 1000 + 2000);
}, { once: true });
// Absoluter Fallback wenn Metadaten nie laden (z.B. offline)
this.videoTimeout = setTimeout(() => finish(), 60000);
}
showFooter(index) {
const content = this.footerContent[index];
if (!content || !this.footerEl) return;
const headline = this.footerEl.querySelector('.vd-headline');
const subline = this.footerEl.querySelector('.vd-subline');
const qrContainer = this.footerEl.querySelector('.vd-qr');
const qrImg = this.footerEl.querySelector('.vd-qr img');
headline.textContent = content.headline || '';
subline.textContent = content.subline || '';
if ((content.url || content.qr_image_url) && setQrImage(qrImg, content.url, { imageUrl: content.qr_image_url, margin: 10, alt: 'QR' })) {
qrContainer.style.display = 'flex';
} else {
qrContainer.style.display = 'none';
}
}
resolveAssetUrl(src) {
return resolveDisplayUrl(src);
}
destroy() {
this.destroyed = true;
if (this.footerTimer) clearInterval(this.footerTimer);
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
if (this.videoElement) {
this.videoElement.onended = null;
this.videoElement.onerror = null;
this.videoElement.pause();
this.videoElement.removeAttribute('src');
this.videoElement.load();
}
if (this.layer) this.layer.remove();
}
}
// ============================================================
// B2IN RENDERER
// Plays all media items in sequence, then calls onComplete
// ============================================================
class B2inRenderer {
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.onComplete = onComplete;
this.settings = data.settings || {};
this.items = (data.items || []).filter(i => i.is_active).sort((a, b) => a.sort_order - b.sort_order);
this.currentIndex = 0;
this.itemTimer = null;
this.destroyed = false;
this.theme = this.settings.theme || 'dark';
this.activeLayer = 'a';
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
layer.setAttribute('data-theme', this.theme);
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/assets/b2in-logo-positive.svg');
const headerClaim = this.settings.header_claim || 'Connecting Design & Property';
const footerUrl = this.settings.footer_url || 'B2in.eu';
const footerName = this.settings.footer_name || '';
const footerPrefix = this.settings.footer_prefix || 'by';
const footerNameHtml = footerName
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
: '';
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
const qrImageUrl = this.settings.qr_image_url || '';
// Footer visibility, brand element visibility + corner positioning
const showFooter = this.settings.show_footer !== false;
const showLogo = this.settings.show_logo !== false;
const showClaim = this.settings.show_claim !== false;
const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left';
let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right';
// Bottom corners only allowed when the footer is hidden
if (showFooter) {
if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-');
if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-');
}
// Claim must not share the logo corner
if (claimPos === logoPos) {
claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos;
}
const logoVisible = showLogo;
const claimVisible = showClaim && !!headerClaim;
const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top'));
const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom'));
const logoHtml = logoVisible
? `<div class="b2in-brand b2in-brand-logo pos-${logoPos}">
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
</div>`
: '';
const claimHtml = claimVisible
? `<div class="b2in-brand b2in-brand-claim pos-${claimPos}">${escapeHtml(headerClaim)}</div>`
: '';
const footerHtml = showFooter
? `<footer class="b2in-footer">
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${footerNameHtml}
</div>
<div class="b2in-footer-qr">
<img alt="QR">
</div>
</footer>`
: '';
layer.innerHTML = `
${hasTop ? '<div class="b2in-scrim b2in-scrim-top"></div>' : ''}
${(hasBottom && !showFooter) ? '<div class="b2in-scrim b2in-scrim-bottom"></div>' : ''}
${logoHtml}
${claimHtml}
<section class="b2in-media">
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
<div class="b2in-media-layer" id="b2in-layer-b"></div>
</section>
<section class="b2in-text">
<div class="b2in-headline" id="b2in-headline"></div>
<div class="b2in-subline" id="b2in-subline"></div>
</section>
${footerHtml}
<div class="b2in-progress-track">
<div class="b2in-progress-fill" id="b2in-progress"></div>
</div>
`;
this.container.appendChild(layer);
this.layer = layer;
this.layerA = layer.querySelector('#b2in-layer-a');
this.layerB = layer.querySelector('#b2in-layer-b');
this.headline = layer.querySelector('#b2in-headline');
this.subline = layer.querySelector('#b2in-subline');
this.progress = layer.querySelector('#b2in-progress');
const footerQrImg = layer.querySelector('.b2in-footer-qr img');
if (footerQrImg) {
setQrImage(footerQrImg, qrUrl, { imageUrl: qrImageUrl, size: 200, margin: 5, alt: 'QR' });
}
}
start() {
if (this.items.length === 0) {
this.onComplete();
return;
}
this.showItem(0);
}
showItem(index) {
if (this.destroyed) return;
if (index >= this.items.length) {
this.onComplete();
return;
}
this.currentIndex = index;
const item = this.items[index];
const transitionDuration = this.settings.transition?.duration_ms || 800;
// Text
this.headline.textContent = item.headline || '';
this.subline.textContent = item.subline || '';
// Media crossfade
const incoming = this.activeLayer === 'a' ? this.layerB : this.layerA;
const outgoing = this.activeLayer === 'a' ? this.layerA : this.layerB;
incoming.style.transition = `opacity ${transitionDuration}ms ease`;
outgoing.style.transition = `opacity ${transitionDuration}ms ease`;
// Create media element
incoming.innerHTML = '';
if (item.media_type === 'video') {
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.playsInline = true;
video.style.cssText = 'width:100%;height:100%;object-fit:cover;';
video.src = this.resolveUrl(item.media_url);
let handled = false;
const finish = () => {
if (handled || this.destroyed) return;
handled = true;
this.showItem(index + 1);
};
video.onended = finish;
video.onerror = () => { console.warn('[B2in] Video error'); finish(); };
// Progress: hide for videos (duration unknown upfront)
this.hideProgress();
incoming.appendChild(video);
video.play().then(() => {
if (video.duration && this.progress) {
this.showProgress(video.duration * 1000);
}
}).catch(e => {
// AbortError = play() wurde absichtlich unterbrochen – kein Fehler
if (e && e.name === 'AbortError') return;
finish();
});
// Timeout fallback
setTimeout(() => finish(), 120000);
} else {
// Image
const img = document.createElement('img');
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
let handled = false;
const finish = () => {
if (handled || this.destroyed) return;
handled = true;
clearTimeout(this.itemTimer);
this.showItem(index + 1);
};
// Broken image (e.g. deleted media, 404): skip after a short grace
// period instead of showing a black frame for the full duration.
// The delay also prevents a rapid loop when every image is broken.
img.onerror = () => {
console.warn(`[B2in] Image error: ${item.media_url}`);
this.hideProgress();
setTimeout(finish, 2000);
};
img.src = this.resolveUrl(item.media_url);
incoming.appendChild(img);
const duration = (item.duration_seconds || this.settings.default_image_duration || 10) * 1000;
this.showProgress(duration);
this.itemTimer = setTimeout(finish, duration);
}
// Crossfade
incoming.classList.add('active');
outgoing.classList.remove('active');
this.activeLayer = this.activeLayer === 'a' ? 'b' : 'a';
}
showProgress(duration) {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
void this.progress.offsetWidth;
this.progress.style.transition = `width ${duration}ms linear`;
this.progress.style.width = '100%';
}
hideProgress() {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
}
resolveUrl(url) {
return resolveDisplayUrl(url);
}
destroy() {
this.destroyed = true;
clearTimeout(this.itemTimer);
// Cleanup videos
this.layer.querySelectorAll('video').forEach(v => {
v.onended = null;
v.onerror = null;
v.pause();
v.removeAttribute('src');
v.load();
});
this.layer.remove();
}
}
// ============================================================
// OFFERS RENDERER
// Renders slides from CMS data as DOM, then calls onComplete
// ============================================================
class OffersRenderer {
// Design dimensions (slides are authored at this resolution)
static DESIGN_W = 1080;
static DESIGN_H = 1920;
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.onComplete = onComplete;
this.settings = data.settings || {};
this.slides = data.slides || [];
this.currentIndex = 0;
this.slideTimer = null;
this.destroyed = false;
this.slideElements = [];
this.slideArticles = [];
this._onResize = () => this.scaleSlides();
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer offers-layer active';
// Build DOM for each slide
this.slides.forEach((slide, i) => {
const el = this.buildSlide(slide);
layer.appendChild(el);
this.slideElements.push(el);
this.slideArticles.push(el.querySelector('.offer-slide'));
});
// Progress bar
const progressBar = document.createElement('div');
progressBar.className = 'offers-progress-bar';
progressBar.innerHTML = '<div class="offers-progress-fill"></div>';
layer.appendChild(progressBar);
this.container.appendChild(layer);
this.layer = layer;
this.progress = layer.querySelector('.offers-progress-fill');
// Scale slides to fit container
this.scaleSlides();
window.addEventListener('resize', this._onResize);
}
scaleSlides() {
const cw = this.container.clientWidth || OffersRenderer.DESIGN_W;
const ch = this.container.clientHeight || OffersRenderer.DESIGN_H;
const scale = Math.min(cw / OffersRenderer.DESIGN_W, ch / OffersRenderer.DESIGN_H);
const offsetX = Math.max(0, (cw - OffersRenderer.DESIGN_W * scale) / 2);
const offsetY = Math.max(0, (ch - OffersRenderer.DESIGN_H * scale) / 2);
this.slideArticles.forEach(article => {
if (article) {
article.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
}
});
}
buildSlide(slide) {
// Single dynamic detail layout: every block is toggleable. Older slides
// without explicit show_* flags fall back to "is there content?".
const has = v => v !== undefined && v !== null && v !== '';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
const contactText = slide.contact || this.settings.footer_claim || '';
const show = {
logo: slide.show_logo ?? true,
badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text),
eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow),
subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline),
bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0),
price: (slide.show_price ?? has(slide.price)) && has(slide.price),
disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer),
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
};
const showInfo = has(slide.title) || show.eyebrow || show.subline || show.bullets || show.price || show.disclaimer;
const showQrBox = show.qr || show.contact;
const showBottom = showInfo || showQrBox;
const infoBlockCount = [has(slide.title), show.eyebrow, show.subline, show.bullets, show.price, show.disclaimer]
.filter(Boolean).length;
const wrapper = document.createElement('div');
wrapper.className = 'offers-slide-container';
const article = document.createElement('article');
article.className = 'offer-slide';
if (!show.logo) article.classList.add('offer-slide--no-header');
if (!showBottom) article.classList.add('offer-slide--no-bottom');
// --- HEADER (Logo & Marke) – per slide, toggleable ---
if (show.logo) {
const header = document.createElement('header');
header.className = 'offer-header';
const brand = document.createElement('div');
brand.className = 'offer-brand';
const brandLogo = document.createElement('img');
brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png');
brandLogo.alt = 'Logo';
brandLogo.className = 'offer-brand-logo';
brand.appendChild(brandLogo);
if (has(slide.brand_text)) {
const brandText = document.createElement('span');
brandText.className = 'offer-brand-text';
brandText.textContent = slide.brand_text;
brand.appendChild(brandText);
}
header.appendChild(brand);
if (has(slide.brand_tagline)) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
}
// --- HERO ---
const hero = document.createElement('section');
hero.className = 'offer-hero';
if (slide.image_url) {
const imgUrl = this.resolveUrl(slide.image_url);
hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
}
if (show.badge) {
const badge = document.createElement('span');
badge.className = 'offer-hero-badge large';
badge.textContent = slide.badge_text;
hero.appendChild(badge);
}
article.appendChild(hero);
if (!showBottom) {
wrapper.appendChild(article);
return wrapper;
}
// --- BOTTOM: Info + QR ---
const bottom = document.createElement('section');
bottom.className = 'offer-bottom';
if (showInfo && !showQrBox) bottom.classList.add('offer-bottom--info-only');
if (!showInfo && showQrBox) bottom.classList.add('offer-bottom--qr-only');
if (showInfo && infoBlockCount <= 3 && !show.price && !show.disclaimer) bottom.classList.add('offer-bottom--compact');
// Info
if (showInfo) {
const info = document.createElement('div');
info.className = 'offer-info';
if (infoBlockCount <= 3 && !show.price && !show.disclaimer) info.classList.add('offer-info--compact');
if (infoBlockCount === 1 && has(slide.title)) info.classList.add('offer-info--title-only');
const infoContent = document.createElement('div');
infoContent.className = 'offer-info-content';
if (show.eyebrow) {
const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow;
infoContent.appendChild(eyebrow);
}
if (slide.title) {
const title = document.createElement('h1');
title.className = 'offer-title medium';
title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title);
}
if (show.subline) {
const subline = document.createElement('p');
subline.className = 'offer-subline';
subline.textContent = slide.subline;
infoContent.appendChild(subline);
}
if (show.bullets) {
const ul = document.createElement('ul');
ul.className = 'offer-bullets';
slide.bullets.forEach(text => {
if (!text) return;
const li = document.createElement('li');
li.innerHTML = `<span class="offer-bullet-dot"></span><span>${this.escapeHtml(text)}</span>`;
ul.appendChild(li);
});
infoContent.appendChild(ul);
}
info.appendChild(infoContent);
// Price block
if (show.price) {
const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block';
const priceRow = document.createElement('div');
priceRow.className = 'offer-price-row';
const price = document.createElement('span');
price.className = 'offer-price';
price.textContent = slide.price;
priceRow.appendChild(price);
if (slide.original_price) {
const note = document.createElement('div');
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price;
priceRow.appendChild(note);
}
if (slide.tag_text) {
const note = document.createElement('div');
note.className = 'offer-price-note';
const tag = document.createElement('span');
tag.className = 'offer-impulse-tag';
tag.textContent = slide.tag_text;
note.appendChild(tag);
priceRow.appendChild(note);
}
priceBlock.appendChild(priceRow);
info.appendChild(priceBlock);
}
// Disclaimer
if (show.disclaimer) {
const footer = document.createElement('div');
footer.className = 'offer-info-footer';
const disc = document.createElement('span');
disc.className = 'offer-disclaimer';
disc.textContent = slide.disclaimer;
footer.appendChild(disc);
info.appendChild(footer);
}
bottom.appendChild(info);
}
// QR / Contact box – only when at least one of the two is enabled.
if (showQrBox) {
const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box';
if (show.qr) {
const qrHeader = document.createElement('div');
qrHeader.className = 'offer-qr-header';
qrHeader.innerHTML = `
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
`;
qrBox.appendChild(qrHeader);
const qrWrapper = document.createElement('div');
qrWrapper.className = 'offer-qr-wrapper';
const qrImg = document.createElement('img');
setQrImage(qrImg, qrUrl, {
imageUrl: slide.qr_image_url || '',
size: 300,
margin: 8,
params: {
color: '000000',
bgcolor: 'ffffff',
},
});
qrWrapper.appendChild(qrImg);
qrBox.appendChild(qrWrapper);
}
if (show.contact) {
const contact = document.createElement('p');
contact.className = 'offer-qr-contact';
contact.innerHTML = contactText.replace(/\n/g, '<br>');
qrBox.appendChild(contact);
}
bottom.appendChild(qrBox);
}
article.appendChild(bottom);
wrapper.appendChild(article);
return wrapper;
}
start() {
if (this.slides.length === 0) {
this.onComplete();
return;
}
this.showSlide(0);
}
showSlide(index) {
if (this.destroyed) return;
if (index >= this.slides.length) {
this.onComplete();
return;
}
this.currentIndex = index;
const slide = this.slides[index];
const duration = slide.duration || 8000;
const transitionDuration = this.settings.transition?.duration || 600;
// Show active slide, hide others
this.slideElements.forEach((el, i) => {
el.style.transition = `opacity ${transitionDuration}ms ease`;
if (i === index) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
// Progress
this.showProgress(duration);
// Timer to next slide
this.slideTimer = setTimeout(() => {
if (!this.destroyed) this.showSlide(index + 1);
}, duration);
}
showProgress(duration) {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
void this.progress.offsetWidth;
this.progress.style.transition = `width ${duration}ms linear`;
this.progress.style.width = '100%';
}
resolveUrl(url) {
return resolveDisplayUrl(url);
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
destroy() {
this.destroyed = true;
clearTimeout(this.slideTimer);
window.removeEventListener('resize', this._onResize);
this.slideElements = [];
this.slideArticles = [];
this.layer.remove();
}
}
// ============================================================
// BOOT
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
new DisplayPlayer();
});
// Prevent right-click context menu (kiosk mode)
document.addEventListener('contextmenu', e => e.preventDefault());
</script>
</body>
</html>