b2in/public/_cabinet/display/index.html

1649 lines
52 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 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; }
.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 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();
// API
this.BASE_URL = this.detectBaseUrl();
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;
// 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() {
const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu') {
return 'https://portal.b2in.eu';
}
return window.location.origin;
}
// ========================================
// INIT
// ========================================
async init() {
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
try {
if (!this.displayId && !this.previewToken && !this.moduleId) {
await this.fetchOverview();
return;
}
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)`);
}
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 || []);
}
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');
}
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) {
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>