Files
Fossign/app/templates/display/player.html

178 lines
6.2 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; }
/* removed bottom-left status text */
</style>
</head>
<body>
<div id="stage"></div>
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
function setNotice(_text) { /* intentionally no-op: notice UI removed */ }
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;
let es = null;
let esRetryMs = 1000;
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) {
setNotice('No playlist assigned.');
clearStage();
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
clearStage();
setNotice(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();
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
// keep retrying; if a slot frees up the display will start automatically.
}
// Open live event stream: when server signals a change, reload playlist immediately.
connectEvents();
// Fallback refresh (in case SSE is blocked by a proxy/network): every 5 minutes.
setInterval(async () => {
try {
playlist = await fetchPlaylist();
if (!stage.firstChild) {
idx = 0;
next();
}
} catch(e) {
clearStage();
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
}
}, 300000);
}
function connectEvents() {
if (isPreview) return; // preview shouldn't consume a slot / keep a long-lived connection
try { if (es) es.close(); } catch(e) { /* ignore */ }
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
es = new EventSource(`/api/display/${token}/events${qs}`);
es.addEventListener('changed', async () => {
try {
const newPlaylist = await fetchPlaylist();
// If content changed, restart from the beginning.
const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist;
if (oldStr !== newStr) {
idx = 0;
next();
}
esRetryMs = 1000; // reset backoff on success
} catch(e) {
// leave current playback; we'll retry via reconnect handler
}
});
es.onerror = () => {
try { es.close(); } catch(e) { /* ignore */ }
es = null;
// Exponential backoff up to 30s
const wait = esRetryMs;
esRetryMs = Math.min(30000, Math.floor(esRetryMs * 1.7));
setTimeout(connectEvents, wait);
};
}
start();
</script>
</body>
</html>