134 lines
4.8 KiB
HTML
134 lines
4.8 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{{ display.name }}</title>
|
|
<style>
|
|
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
|
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
|
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
|
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="stage"></div>
|
|
<div class="notice" id="notice"></div>
|
|
<script>
|
|
const token = "{{ display.token }}";
|
|
const stage = document.getElementById('stage');
|
|
const notice = document.getElementById('notice');
|
|
|
|
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
|
|
|
// Stable session id per browser (used to enforce max concurrent viewers per display token)
|
|
const SID_KEY = `display_sid_${token}`;
|
|
function getSid() {
|
|
let sid = null;
|
|
try { sid = localStorage.getItem(SID_KEY); } catch(e) { /* ignore */ }
|
|
if (!sid) {
|
|
sid = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(16).slice(2) + Date.now().toString(16));
|
|
try { localStorage.setItem(SID_KEY, sid); } catch(e) { /* ignore */ }
|
|
}
|
|
return sid;
|
|
}
|
|
|
|
const sid = isPreview ? null : getSid();
|
|
|
|
let playlist = null;
|
|
let idx = 0;
|
|
let timer = null;
|
|
|
|
async function fetchPlaylist() {
|
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
|
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
|
if (res.status === 429) {
|
|
const data = await res.json().catch(() => null);
|
|
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
|
|
}
|
|
return await res.json();
|
|
}
|
|
|
|
function clearStage() {
|
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
stage.innerHTML = '';
|
|
}
|
|
|
|
function next() {
|
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
|
notice.textContent = 'No playlist assigned.';
|
|
clearStage();
|
|
return;
|
|
}
|
|
|
|
const item = playlist.items[idx % playlist.items.length];
|
|
idx = (idx + 1) % playlist.items.length;
|
|
|
|
clearStage();
|
|
notice.textContent = playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display;
|
|
|
|
if (item.type === 'image') {
|
|
const el = document.createElement('img');
|
|
el.src = item.src;
|
|
stage.appendChild(el);
|
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
|
} else if (item.type === 'video') {
|
|
const el = document.createElement('video');
|
|
el.src = item.src;
|
|
el.autoplay = true;
|
|
el.muted = true;
|
|
el.playsInline = true;
|
|
el.onended = next;
|
|
stage.appendChild(el);
|
|
} else if (item.type === 'webpage') {
|
|
const el = document.createElement('iframe');
|
|
el.src = item.url;
|
|
stage.appendChild(el);
|
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
|
} else if (item.type === 'youtube') {
|
|
const el = document.createElement('iframe');
|
|
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
|
|
// Add common playback params client-side.
|
|
const u = item.url || '';
|
|
const sep = u.includes('?') ? '&' : '?';
|
|
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
|
|
stage.appendChild(el);
|
|
|
|
// YouTube iframes don't reliably emit an "ended" event without the JS API.
|
|
// We keep it simple: play for the configured duration (default 30s).
|
|
timer = setTimeout(next, (item.duration || 30) * 1000);
|
|
} else {
|
|
timer = setTimeout(next, 5000);
|
|
}
|
|
}
|
|
|
|
async function start() {
|
|
try {
|
|
playlist = await fetchPlaylist();
|
|
idx = 0;
|
|
next();
|
|
} catch (e) {
|
|
clearStage();
|
|
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
|
// keep retrying; if a slot frees up the display will start automatically.
|
|
}
|
|
// refresh playlist every 60s
|
|
setInterval(async () => {
|
|
try {
|
|
playlist = await fetchPlaylist();
|
|
if (!stage.firstChild) {
|
|
idx = 0;
|
|
next();
|
|
}
|
|
} catch(e) {
|
|
clearStage();
|
|
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
start();
|
|
</script>
|
|
</body>
|
|
</html>
|