1531 lines
48 KiB
HTML
1531 lines
48 KiB
HTML
<!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 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-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); }
|
||
|
||
/* ========================================
|
||
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 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;
|
||
}
|
||
|
||
/* 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-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;
|
||
}
|
||
|
||
/* 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; }
|
||
</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>
|
||
|
||
<script>
|
||
function escapeHtml(value) {
|
||
const div = document.createElement('div');
|
||
div.textContent = value ?? '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function normalizeQrUrl(url) {
|
||
if (!url) return '';
|
||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||
return `https://${url}`;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
if (!this.displayId && !this.previewToken && !this.moduleId) {
|
||
this.showError('Keine Display-ID oder Vorschau angegeben. URL: /display/index.html?id=1');
|
||
return;
|
||
}
|
||
|
||
// API
|
||
this.BASE_URL = this.detectBaseUrl();
|
||
this.API_CONFIG = this.detectConfigUrl();
|
||
this.API_CHECK = this.detectCheckUrl();
|
||
|
||
// 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;
|
||
|
||
// 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.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}`;
|
||
}
|
||
return `Display #${this.displayId}`;
|
||
}
|
||
|
||
detectBaseUrl() {
|
||
const hostname = window.location.hostname;
|
||
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
|
||
return 'https://b2in.eu';
|
||
}
|
||
return window.location.origin;
|
||
}
|
||
|
||
// ========================================
|
||
// INIT
|
||
// ========================================
|
||
|
||
async init() {
|
||
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
|
||
|
||
try {
|
||
await this.fetchConfig();
|
||
|
||
if (this.playlist.length === 0) {
|
||
this.showError('Keine Versionen zugewiesen');
|
||
this.scheduleRetry();
|
||
return;
|
||
}
|
||
|
||
this.hideLoading();
|
||
this.startPlaylist();
|
||
} catch (error) {
|
||
console.error('[Display] Init failed:', error);
|
||
this.showError('Konfiguration konnte nicht geladen werden');
|
||
this.scheduleRetry();
|
||
}
|
||
|
||
this.startPolling();
|
||
this.recordFileVersion();
|
||
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)`);
|
||
}
|
||
|
||
startPolling() {
|
||
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] Change detected, reloading...');
|
||
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);
|
||
|
||
setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL);
|
||
}
|
||
|
||
async recordFileVersion() {
|
||
try {
|
||
const response = await fetch(window.location.href, {
|
||
method: 'HEAD',
|
||
cache: 'no-store',
|
||
});
|
||
this.fileVersion = response.headers.get('ETag') || response.headers.get('Last-Modified');
|
||
} catch (e) {
|
||
// Ignorieren – wird beim nächsten Check erneut versucht
|
||
}
|
||
}
|
||
|
||
async checkFileVersion() {
|
||
try {
|
||
const response = await fetch(window.location.href, {
|
||
method: 'HEAD',
|
||
cache: 'no-store',
|
||
});
|
||
const version = response.headers.get('ETag') || response.headers.get('Last-Modified');
|
||
|
||
if (this.fileVersion && version && version !== this.fileVersion) {
|
||
console.log('[Display] 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// ========================================
|
||
// UI HELPERS
|
||
// ========================================
|
||
|
||
hideLoading() {
|
||
this.loadingOverlay.classList.add('hidden');
|
||
}
|
||
|
||
showError(msg) {
|
||
this.loadingOverlay.classList.add('hidden');
|
||
this.errorOverlay.classList.remove('hidden');
|
||
this.errorMessage.textContent = msg;
|
||
}
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// 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) {
|
||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=10&data=${encodeURIComponent(content.url)}`;
|
||
qrContainer.style.display = 'flex';
|
||
} else {
|
||
qrContainer.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
resolveAssetUrl(src) {
|
||
if (!src) return '';
|
||
if (src.startsWith('http')) return src;
|
||
if (src.startsWith('/')) return src;
|
||
if (src.startsWith('../')) return src;
|
||
// Assets relative to _cabinet
|
||
return `../${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';
|
||
layer.setAttribute('data-theme', this.theme);
|
||
|
||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../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');
|
||
|
||
layer.innerHTML = `
|
||
<header class="b2in-header">
|
||
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
|
||
<span class="b2in-claim">${escapeHtml(headerClaim)}</span>
|
||
</header>
|
||
<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>
|
||
<footer class="b2in-footer">
|
||
<div>
|
||
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
|
||
${footerNameHtml}
|
||
</div>
|
||
<div class="b2in-footer-qr">
|
||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
|
||
</div>
|
||
</footer>
|
||
<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');
|
||
}
|
||
|
||
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;';
|
||
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(() => {
|
||
if (!this.destroyed) this.showItem(index + 1);
|
||
}, 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) {
|
||
if (!url) return '';
|
||
if (url.startsWith('http')) return url;
|
||
if (url.startsWith('/')) return url;
|
||
if (url.startsWith('../')) return url;
|
||
return `../${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) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'offers-slide-container';
|
||
|
||
const article = document.createElement('article');
|
||
article.className = 'offer-slide';
|
||
|
||
// --- HEADER ---
|
||
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(this.settings.logo_url || '../logo-cabinet-300.png');
|
||
brandLogo.alt = 'CABINET';
|
||
brandLogo.className = 'offer-brand-logo';
|
||
brand.appendChild(brandLogo);
|
||
|
||
if (slide.show_brand_text) {
|
||
const brandText = document.createElement('span');
|
||
brandText.className = 'offer-brand-text';
|
||
brandText.textContent = this.settings.brand_text || 'Bielefeld';
|
||
brand.appendChild(brandText);
|
||
}
|
||
|
||
header.appendChild(brand);
|
||
|
||
if (slide.show_brand_text && 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 (slide.badge_text) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'offer-hero-badge large';
|
||
badge.textContent = slide.badge_text;
|
||
hero.appendChild(badge);
|
||
}
|
||
|
||
article.appendChild(hero);
|
||
|
||
// --- BOTTOM: Info + QR ---
|
||
const bottom = document.createElement('section');
|
||
bottom.className = 'offer-bottom';
|
||
|
||
// Info
|
||
const info = document.createElement('div');
|
||
info.className = 'offer-info';
|
||
|
||
const infoContent = document.createElement('div');
|
||
infoContent.className = 'offer-info-content';
|
||
|
||
if (slide.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');
|
||
const titleSize = (slide.type === 'product-details') ? 'medium' : 'large';
|
||
title.className = `offer-title ${titleSize}`;
|
||
title.innerHTML = slide.title.replace(/\n/g, '<br>');
|
||
infoContent.appendChild(title);
|
||
}
|
||
|
||
// Subline (product-impulse)
|
||
if (slide.subline) {
|
||
const subline = document.createElement('p');
|
||
subline.className = 'offer-subline';
|
||
subline.textContent = slide.subline;
|
||
infoContent.appendChild(subline);
|
||
}
|
||
|
||
// Bullets (product-details)
|
||
if (slide.bullets && slide.bullets.length > 0) {
|
||
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 (product-hero, product-impulse)
|
||
if (slide.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 = '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 (intro)
|
||
if (slide.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 Box
|
||
const qrBox = document.createElement('aside');
|
||
qrBox.className = 'offer-qr-box';
|
||
|
||
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 qrUrl = slide.qr_url || this.settings.footer_url || '';
|
||
if (qrUrl) {
|
||
const qrImg = document.createElement('img');
|
||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
|
||
qrImg.alt = 'QR Code';
|
||
qrWrapper.appendChild(qrImg);
|
||
}
|
||
qrBox.appendChild(qrWrapper);
|
||
|
||
const contactText = slide.contact || this.settings.footer_claim || '';
|
||
if (contactText) {
|
||
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) {
|
||
if (!url) return '';
|
||
if (url.startsWith('http')) return url;
|
||
if (url.startsWith('/')) return url;
|
||
if (url.startsWith('../')) return url;
|
||
return `../${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>
|