Files
signage/templates/player.html
2026-06-22 17:01:32 +02:00

573 lines
20 KiB
HTML
Executable File
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">
<title>CANCOM Simple Signage Player</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<style>
/* ─── Player-Grundlayout ───
Alle Medien (img/video/iframe) werden zentriert auf Vollbild angezeigt.
Standardmäßig ausgeblendet nur das aktive Element ist sichtbar. */
html, body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: black;
overflow: hidden;
}
img, video, #iframe {
width: 100vw;
height: 100vh;
background: black;
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
img, #iframe {
object-fit: contain;
border: 0;
}
video {
object-fit: contain;
}
/* ─── Newsticker (Laufband) ─── */
.newsticker-text {
flex: 1;
overflow: hidden;
position: relative;
height: 100%;
}
.newsticker-track {
display: inline-block;
white-space: nowrap;
position: absolute;
left: 100%;
top: 0;
height: 40px;
line-height: 40px;
animation: ticker-scroll 40s linear infinite;
}
.newsticker-track span {
padding-right: 4rem;
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-100vw); }
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════
OVERLAY / Custom-URL (iframe-Modus)
Wenn der Aktions-Button angeklickt wird, öffnet
sich dieser Overlay-Bereich mit Zurück-Button.
═══════════════════════════════════════════════════ -->
<div id="overlay" style="display:none;position:fixed;inset:0;z-index:99999;background:#000;">
<style>
/* Zurück-Button-Position: 9 Positionen, dynamisch per openOverlay(pos) gesetzt */
#overlay-back.overlay-back-top-left { top: 16px; left: 16px; }
#overlay-back.overlay-back-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
#overlay-back.overlay-back-top-right { top: 16px; right: 16px; }
#overlay-back.overlay-back-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
#overlay-back.overlay-back-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
#overlay-back.overlay-back-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
#overlay-back.overlay-back-bottom-left { bottom: 16px; left: 16px; }
#overlay-back.overlay-back-bottom-center { bottom: 16px; left: 50%; transform: translateX(-50%); }
#overlay-back.overlay-back-bottom-right { bottom: 16px; right: 16px; }
</style>
<button id="overlay-back" onclick="closeOverlay()"
style="position:absolute;z-index:10;
padding:8px 20px;background:#fff;color:#000;
border:none;border-radius:20px;font-size:1rem;font-weight:600;
cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
← Zurück
</button>
<iframe id="overlay-iframe" style="width:100%;height:100%;border:none;display:block;"></iframe>
</div>
<!-- ─── Custom-URL-Button (Position: 9 Positionen oben/mitte/unten × links/mitte/rechts)
Bottom-Positionen weichen automatisch 16px über dem Newsticker-Balken aus ─── -->
{% if custom_url and custom_url_enabled %}
<style>
/* Einheitliches Button-Styling: Weiß, abgerundet, fetter Text (Custom-URL + Voice-Agent) */
.btn-pos-{{ screen }} {
position: fixed;
z-index: 9999;
padding: 10px 20px;
background: #fff;
color: #000;
border: none;
border-radius: 20px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.btn-pos-{{ screen }}-top-left { top: 16px; left: 16px; }
.btn-pos-{{ screen }}-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
.btn-pos-{{ screen }}-top-right { top: 16px; right: 16px; }
.btn-pos-{{ screen }}-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
.btn-pos-{{ screen }}-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
.btn-pos-{{ screen }}-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
.btn-pos-{{ screen }}-bottom-left { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; left: 16px; }
.btn-pos-{{ screen }}-bottom-center { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; left: 50%; transform: translateX(-50%); }
.btn-pos-{{ screen }}-bottom-right { bottom: {% if newsticker_enabled %}56px{% else %}16px{% endif %}; right: 16px; }
</style>
{% if custom_url_target == "redirect" %}
<a href="{{ custom_url }}"
class="btn-pos-{{ screen }} btn-pos-{{ screen }}-{{ custom_url_position|default('top-left') }}">
{{ custom_url_label or "Info" }}
</a>
{% else %}
<button onclick='openOverlay({{ custom_url | tojson }}, "{{ custom_url_position|default('top-left') }}")'
class="btn-pos-{{ screen }} btn-pos-{{ screen }}-{{ custom_url_position|default('top-left') }}">
{{ custom_url_label or "Info" }}
</button>
{% endif %}
{% endif %}
<!-- ═══════════════════════════════════════════════════
Voice-Agent-Button (fix positioniert, 9 Positionen)
Typewriter-Tagline, optionales DVA-Bild, animierte
Balken, Verhalten overlay/redirect. Bottom-Positionen
weichen dem Newsticker aus.
═══════════════════════════════════════════════════ -->
{% if voice_agent_url and voice_agent_enabled %}
<style>
/* Voice-Agent-Wrap: Fixpositionierter Container, Column-Layout für Bild + Button */
.voice-wrap-{{ screen }} {
position: fixed;
z-index: 9998;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
line-height: 1.3;
min-width: 160px;
}
/* Typewriter-Tagline: Mehrsprachig, Buchstabe für Buchstabe, 0,8 s Fade-Out */
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-tagline-{{ screen }} {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #888;
height: 1.2em;
line-height: 1.2em;
white-space: nowrap;
transition: opacity 0.8s ease;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-label-{{ screen }} {
display: block;
font-size: 1.05rem;
font-weight: 600;
color: #000;
}
.voice-pos-{{ screen }} .voice-text-{{ screen }} .voice-tagline-{{ screen }} .cursor {
display: inline-block;
width: 2px;
height: 1em;
background: #DA002D;
margin-left: 2px;
animation: voice-blink-{{ screen }} 0.6s step-end infinite;
vertical-align: text-bottom;
}
@keyframes voice-blink-{{ screen }} {
50% { opacity: 0; }
}
/* Optionales DVA-Bild (dva.png) oberhalb des Buttons, Beta-Feature */
.voice-wrap-{{ screen }} .voice-img-{{ screen }} {
width: 200px;
aspect-ratio: 648 / 596;
margin-bottom: 6px;
background: url('/static/dva.png') no-repeat center/contain;
flex-shrink: 0;
}
/* Voice-Agent-Button: Weiß, 20 px Border-Radius, fett einheitlich mit Aktions-Button */
.voice-pos-{{ screen }} {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 20px;
gap: 12px;
min-width: 280px;
height: 56px;
background: #fff;
color: #000;
border: none;
border-radius: 20px;
font-size: 1.05rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.voice-pos-{{ screen }}:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.voice-pos-{{ screen }} .voice-icon {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: auto;
flex-shrink: 0;
}
/* Animierte Balken (5 Stück): Welle-Effekt mit abgestuften Verzögerungen */
.voice-pos-{{ screen }} .voice-icon .bar {
width: 3px;
border-radius: 2px;
background: #DA002D;
transform-origin: center bottom;
animation: voice-bar-{{ screen }} 0.6s ease-in-out infinite alternate;
}
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(1) { height: 10px; animation-delay: 0.0s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(2) { height: 16px; animation-delay: 0.2s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(3) { height: 22px; animation-delay: 0.4s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(4) { height: 14px; animation-delay: 0.1s; }
.voice-pos-{{ screen }} .voice-icon .bar:nth-child(5) { height: 8px; animation-delay: 0.3s; }
@keyframes voice-bar-{{ screen }} {
0% { transform: scaleY(0.4); }
100% { transform: scaleY(1.2); }
}
/* Voice-Agent-Positionen: 9 Positionen (oben/mitte/unten × links/mitte/rechts), Bottom weicht Newsticker aus */
.voice-wrap-{{ screen }}-top-left { top: 16px; left: 16px; }
.voice-wrap-{{ screen }}-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
.voice-wrap-{{ screen }}-top-right { top: 16px; right: 16px; }
.voice-wrap-{{ screen }}-middle-left { top: 50%; left: 16px; transform: translateY(-50%); }
.voice-wrap-{{ screen }}-middle-center { top: 50%; left: 50%; transform: translate(-50%,-50%); }
.voice-wrap-{{ screen }}-middle-right { top: 50%; right: 16px; transform: translateY(-50%); }
.voice-wrap-{{ screen }}-bottom-left { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); left: 16px; }
.voice-wrap-{{ screen }}-bottom-center { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); left: 50%; transform: translateX(-50%); }
.voice-wrap-{{ screen }}-bottom-right { bottom: calc({% if newsticker_enabled %}56px{% else %}16px{% endif %}); right: 16px; }
</style>
<!-- Voice-Agent-Wrap: Fixpositionierter Container mit Button + optionalem Bild -->
<div class="voice-wrap-{{ screen }} voice-wrap-{{ screen }}-{{ voice_agent_position|default('top-left') }}">
{% if voice_agent_show_image %}<span class="voice-img-{{ screen }}"></span>{% endif %}
{% if voice_agent_target == "redirect" %}
<a href="{{ voice_agent_url }}"
class="voice-pos-{{ screen }}">
<span class="voice-icon">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span class="voice-text-{{ screen }}">
<span class="voice-tagline-{{ screen }}" id="voice-tagline-{{ screen }}"></span>
<span class="voice-label-{{ screen }}">{{ voice_agent_label or "Digitaler Assistent" }}</span>
</span>
</a>
{% else %}
<button onclick='openOverlay({{ voice_agent_url | tojson }}, "{{ voice_agent_position|default('top-left') }}")'
class="voice-pos-{{ screen }}">
<span class="voice-icon">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span class="voice-text-{{ screen }}">
<span class="voice-tagline-{{ screen }}" id="voice-tagline-{{ screen }}"></span>
<span class="voice-label-{{ screen }}">{{ voice_agent_label or "Digitaler Assistent" }}</span>
</span>
</button>
{% endif %}
</div>
{% endif %}
<!-- ─── Medien-Elemente (eines ist jeweils sichtbar) ─── -->
<img id="image">
<video id="video" muted autoplay playsinline></video>
<iframe id="iframe"></iframe>
<!-- ─── Newsticker (optional, Laufband unten) ─── -->
{% if newsticker_enabled %}
<div id="newsticker-bar" style="position:fixed;left:0;right:0;bottom:0;height:40px;z-index:10000;background:#DA002D;color:#fff;display:flex;align-items:center;overflow:hidden;">
<div id="newsticker-text" class="newsticker-text">
<div class="newsticker-track">
<span>{{ newsticker_text }}</span>
</div>
</div>
<div id="newsticker-clock" style="padding:0 18px 0 24px;font-size:1.1em;font-family:monospace;min-width:90px;text-align:right;"></div>
</div>
{% endif %}
<script>
// ═════════════════════════════════════════════════════
// Player-JavaScript
// ═════════════════════════════════════════════════════
let playerTimer = null;
// ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
function openOverlay(url, pos) {
if (playerTimer) clearTimeout(playerTimer);
document.getElementById("overlay").style.display = "block";
document.getElementById("overlay-iframe").src = url;
var backBtn = document.getElementById("overlay-back");
backBtn.className = "overlay-back-" + (pos || "top-left");
}
function closeOverlay() {
document.getElementById("overlay").style.display = "none";
document.getElementById("overlay-iframe").src = "";
playNext();
}
// ─── Newsticker-Uhr (aktuelle Uhrzeit) ───
function updateClock() {
const el = document.getElementById('newsticker-clock');
if (!el) return;
const now = new Date();
el.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
setInterval(updateClock, 1000);
updateClock();
// ─── Vom Server übergebene Daten ───
const normalFiles = {{ normal_files | tojson }};
const prioFiles = {{ prio_files | tojson }};
const interval = {{ interval }} * 1000;
let stayOnFirst = {{ "true" if stay_on_first else "false" }};
const screen = "{{ screen }}";
const site = "{{ site }}";
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
// ─── Player-Status ───
let normalIndex = 0;
let prioIndex = 0;
let mode = "normal"; // "normal" | "prio"
const img = document.getElementById("image");
const vid = document.getElementById("video");
const iframe = document.getElementById("iframe");
// ─── Hilfsfunktionen ───
function normalizeItem(item) {
if (typeof item === "string") {
const lower = item.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return { kind: "url", url: item, zoom: 1.0 };
}
return { kind: "file", name: item };
}
if (item.kind === "url" && !("zoom" in item)) {
item.zoom = 1.0;
}
if (!("enabled" in item)) {
item.enabled = true;
}
return item;
}
function isVideo(item) {
return item.kind === "file" && item.name.toLowerCase().endsWith(".mp4");
}
function isImage(item) {
return item.kind === "file" && /\.(jpg|jpeg|png)$/i.test(item.name);
}
function isHtml(item) {
return item.kind === "file" && /\.(html?|htm)$/i.test(item.name);
}
// ─── Nächstes Element aus der Playlist holen ───
// Ablauf: Normal-Playlist → Priority → Normal → …
function getNextItem() {
const normalizedNormal = normalFiles.map(normalizeItem);
const normalizedPrio = prioFiles.map(normalizeItem);
const activeNormal = normalizedNormal.filter(item => item.enabled !== false);
const activePrio = normalizedPrio.filter(item => item.enabled !== false);
// Nur Priority vorhanden
if (activeNormal.length === 0 && activePrio.length > 0) {
return { item: activePrio[prioIndex++ % activePrio.length], isPrio: true };
}
// Nur normale Playlist vorhanden
if (activePrio.length === 0 && activeNormal.length > 0) {
return { item: activeNormal[normalIndex++ % activeNormal.length], isPrio: false };
}
// Normal-Phase
if (mode === "normal") {
const item = activeNormal[normalIndex++];
if (normalIndex >= activeNormal.length) {
normalIndex = 0;
if (activePrio.length > 0) mode = "prio";
}
return { item, isPrio: false };
}
// Priority-Phase
if (mode === "prio") {
const item = activePrio[prioIndex++];
if (prioIndex >= activePrio.length) {
prioIndex = 0;
mode = "normal";
}
return { item, isPrio: true };
}
return null;
}
// ─── Aktuelles Medium abspielen ───
function playNext() {
const entry = getNextItem();
if (!entry) return;
const item = entry.item;
const basePath = entry.isPrio ? "priority" : screen;
// Alle Elemente ausblenden
img.style.display = "none";
vid.style.display = "none";
iframe.style.display = "none";
vid.pause();
vid.src = "";
iframe.src = "";
iframe.style.transform = "scale(1)";
// URL im iframe anzeigen (mit Zoom-Unterstützung)
if (item.kind === "url") {
iframe.style.display = "block";
iframe.src = item.url;
const zoom = item.zoom || 1.0;
if (zoom !== 1.0) {
const scale = 1 / zoom;
iframe.style.width = `${100 * scale}vw`;
iframe.style.height = `${100 * scale}vh`;
iframe.style.transform = `translate(-50%, -50%) scale(${zoom})`;
} else {
iframe.style.width = `100vw`;
iframe.style.height = `100vh`;
iframe.style.transform = `translate(-50%, -50%)`;
}
if (!stayOnFirst) playerTimer = setTimeout(playNext, interval);
return;
}
// Datei aus dem Medienverzeichnis
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`;
if (isVideo(item)) {
vid.style.display = "block";
vid.src = src;
vid.onended = stayOnFirst ? null : playNext;
vid.play();
} else if (isHtml(item)) {
iframe.style.display = "block";
iframe.src = src;
iframe.style.width = `100vw`;
iframe.style.height = `100vh`;
iframe.style.transform = `translate(-50%, -50%)`;
if (!stayOnFirst) playerTimer = setTimeout(playNext, interval);
} else {
// Bild (jpg/png)
img.style.display = "block";
img.src = src;
if (!stayOnFirst) playerTimer = setTimeout(playNext, interval);
}
}
// ─── Auto-Reload bei Playlist-Änderungen + dynamisches stayOnFirst ───
let lastHash = null;
async function checkForUpdates() {
try {
const res = await fetch(`/playlist/${site}/${screen}/hash`, { cache: "no-store" });
const data = await res.json();
const hash = data.hash;
const serverStayOnFirst = data.stay_on_first;
// Dynamisch stayOnFirst vom Server übernehmen
const wasStopped = stayOnFirst === true;
stayOnFirst = serverStayOnFirst;
// Wenn stayOnFirst gerade ausgeschaltet wurde → sofort weiterlaufen
if (wasStopped && !stayOnFirst) {
if (playerTimer) clearTimeout(playerTimer);
playNext();
}
// Hash-Änderung → Seite komplett neu laden
if (lastHash && lastHash !== hash) {
location.reload(true);
}
lastHash = hash;
} catch (e) {
console.warn("Playlist-Check fehlgeschlagen", e);
}
}
// ─── Voice-Agent-Tagline Typewriter ───
// Wechselt mehrsprachige Sätze, Buchstabe für Buchstabe, 2,5 s Pause, 0,8 s Fade-Out
(function() {
const el = document.getElementById('voice-tagline-{{ screen }}');
if (!el) return;
const phrases = [
"Always ready to help",
"How can I assist you?",
"Questions? Just ask.",
"Wie kann ich helfen?",
"Ich bin für Sie da",
"Sprechen Sie mich einfach an",
"Comment puis-je vous aider?",
"¿En qué puedo ayudarle?",
"Come posso aiutarti?",
"I'm here for you",
"Let me know what you need"
];
let idx = 0, charIdx = 0, fading = false;
function type() {
if (fading) {
el.style.opacity = '0';
fading = false;
setTimeout(function() {
el.textContent = '';
el.style.opacity = '1';
setTimeout(type, 200);
}, 800);
return;
}
const phrase = phrases[idx % phrases.length];
if (charIdx < phrase.length) {
el.textContent = phrase.slice(0, charIdx + 1);
charIdx++;
setTimeout(type, 50 + Math.random() * 30);
} else {
charIdx = 0;
idx++;
fading = true;
setTimeout(type, 2500);
}
}
type();
})();
// ─── Start ───
playNext();
setInterval(checkForUpdates, 5000);
</script>
</body>
</html>