Files
openslide/app/templates/display/player.html
2026-01-28 13:33:18 +01:00

653 lines
23 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; }
/* When ticker is shown, keep content from being visually covered.
(We pad the stage; video/img/iframe inside will keep aspect.) */
body.has-ticker #stage {
bottom: var(--ticker-height, 54px);
}
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 5;
object-fit: contain;
}
/* Slide transitions (applied by JS via classes) */
#stage .slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#stage .slide.enter {
opacity: 0;
transform: translateX(16px);
}
#stage.transition-none .slide.enter {
opacity: 1;
transform: none;
}
#stage.transition-fade .slide {
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.enter {
opacity: 0;
transform: none;
}
#stage.transition-fade .slide.enter.active {
opacity: 1;
}
#stage.transition-fade .slide.exit {
opacity: 1;
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.exit.active {
opacity: 0;
}
#stage.transition-slide .slide {
transition: transform 420ms ease, opacity 420ms ease;
}
#stage.transition-slide .slide.enter {
opacity: 0;
transform: translateX(48px);
}
#stage.transition-slide .slide.enter.active {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit.active {
opacity: 0;
transform: translateX(-48px);
}
#notice {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
color: #fff;
background: rgba(0, 0, 0, 0.86);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
z-index: 10;
text-align: center;
}
#notice .box {
max-width: 720px;
}
#notice .title {
font-size: 28px;
font-weight: 700;
margin: 0 0 10px;
}
#notice .msg {
font-size: 18px;
line-height: 1.4;
margin: 0;
opacity: 0.95;
}
/* Ticker tape */
#ticker {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--ticker-height, 54px);
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
display: none;
align-items: center;
overflow: hidden;
z-index: 6; /* above stage, below notice */
pointer-events: none;
}
#ticker .track {
display: inline-flex;
align-items: center;
white-space: nowrap;
will-change: transform;
animation: ticker-scroll linear infinite;
animation-duration: var(--ticker-duration, 60s);
transform: translateX(0);
}
#ticker .item {
padding: 0 26px;
}
#ticker .sep {
opacity: 0.65;
}
@keyframes ticker-scroll {
/* We duplicate the content twice, so shifting -50% effectively loops. */
0% { transform: translateX(0); }
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
}
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
/* removed bottom-left status text */
</style>
</head>
<body>
<div id="notice" role="alert" aria-live="assertive">
<div class="box">
<p class="title" id="noticeTitle">Notice</p>
<p class="msg" id="noticeText"></p>
</div>
</div>
<div id="stage"></div>
<div id="ticker" aria-hidden="true">
<div class="track" id="tickerTrack"></div>
</div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
let overlayEl = document.getElementById('overlay');
const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText');
function setNotice(text, { title } = {}) {
const t = (text || '').trim();
if (!t) {
noticeEl.style.display = 'none';
noticeTextEl.textContent = '';
return;
}
noticeTitleEl.textContent = title || 'Notice';
noticeTextEl.textContent = t;
noticeEl.style.display = 'flex';
}
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;
// Ticker DOM
const tickerEl = document.getElementById('ticker');
const tickerTrackEl = document.getElementById('tickerTrack');
let tickerConfig = null;
let tickerInterval = null;
let tickerLastHeadlines = [];
function getTickerPollSeconds() {
// Refresh headlines on a long interval.
// Default: 12 hours (twice per day).
// Override via ?ticker_poll=seconds.
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
}
const ANIM_MS = 420;
function getTransitionMode(pl) {
const v = (pl && pl.transition ? String(pl.transition) : 'none').toLowerCase();
return (v === 'fade' || v === 'slide' || v === 'none') ? v : 'none';
}
function applyTransitionClass(mode) {
stage.classList.remove('transition-none', 'transition-fade', 'transition-slide');
stage.classList.add(`transition-${mode}`);
}
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();
}
async function fetchTickerHeadlines() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/ticker${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 safeCss(val) {
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
}
function applyTickerStyle(cfg) {
if (!tickerEl) return;
const color = safeCss(cfg && cfg.color);
const bgColor = safeCss(cfg && cfg.bg_color);
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
const fontFamily = safeCss(cfg && cfg.font_family);
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
// Height is slightly larger than font size.
const height = Math.max(36, Math.min(120, fontSize + 26));
tickerEl.style.color = color || '#ffffff';
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
tickerEl.style.fontSize = `${fontSize}px`;
tickerEl.style.setProperty('--ticker-height', `${height}px`);
// Background color + opacity
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
}
function toRgba(hexColor, opacityPercent) {
const s = (hexColor || '').toString().trim().toLowerCase();
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
// Accept #rgb or #rrggbb. Fallback to black.
let r = 0, g = 0, b = 0;
if (s.startsWith('#')) {
const h = s.slice(1);
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (h.length === 6) {
r = parseInt(h.slice(0,2), 16);
g = parseInt(h.slice(2,4), 16);
b = parseInt(h.slice(4,6), 16);
}
}
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function computeTickerDurationPx(copyWidthPx) {
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
// Speed slider (1..100): higher => faster.
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
// Map speed to pixels/second. (tuned to be readable on signage)
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
const seconds = w / pxPerSecond;
return Math.max(12, Math.min(600, seconds));
}
function buildTickerCopyHtml(list) {
// No trailing separator at the end.
return list.map((t, i) => {
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
}).join('');
}
function setTickerHeadlines(headlines) {
if (!tickerEl || !tickerTrackEl) return;
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
if (!list.length) {
tickerEl.style.display = 'none';
tickerTrackEl.innerHTML = '';
document.body.classList.remove('has-ticker');
return;
}
tickerLastHeadlines = list.slice();
// Show first so measurements work.
tickerEl.style.display = 'flex';
document.body.classList.add('has-ticker');
// Build one copy.
const oneCopyHtml = buildTickerCopyHtml(list);
tickerTrackEl.innerHTML = oneCopyHtml;
// Ensure we repeat enough so there is never an empty gap, even when the
// total headline width is smaller than the viewport.
requestAnimationFrame(() => {
try {
const viewportW = tickerEl.clientWidth || 1;
const copyW = tickerTrackEl.scrollWidth || 1;
// Want at least 2x viewport width in total track content.
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
const shiftPercent = 100 / repeats;
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
} catch (e) {
// fallback: 2 copies
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
tickerEl.style.setProperty('--ticker-shift', '50%');
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
}
});
}
function escapeHtml(s) {
return (s || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function refreshTickerOnce() {
// Disabled or missing URL: hide and clear immediately.
if (!tickerConfig || !tickerConfig.enabled) {
setTickerHeadlines([]);
return;
}
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
setTickerHeadlines([]);
return;
}
try {
const data = await fetchTickerHeadlines();
if (!data || !data.enabled) {
setTickerHeadlines([]);
return;
}
setTickerHeadlines(data.headlines || []);
} catch (e) {
// Soft-fail: keep old headlines if any.
}
}
function rerenderTickerFromCache() {
if (!tickerLastHeadlines || !tickerLastHeadlines.length) return;
setTickerHeadlines(tickerLastHeadlines);
}
function startTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
tickerInterval = setInterval(refreshTickerOnce, getTickerPollSeconds() * 1000);
}
function stopTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
}
function clearStage() {
if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = '';
}
function setOverlaySrc(src) {
const val = (src || '').trim();
if (!val) {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
return;
}
if (!overlayEl) {
overlayEl = document.createElement('img');
overlayEl.id = 'overlay';
overlayEl.alt = 'Overlay';
document.body.appendChild(overlayEl);
}
// Cache-bust in preview mode so changes show up instantly.
if (isPreview) {
try {
const u = new URL(val, window.location.origin);
u.searchParams.set('_ts', String(Date.now()));
overlayEl.src = u.toString();
return;
} catch(e) {
// fallthrough
}
}
overlayEl.src = val;
}
// Initialize overlay from server-side render.
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
function setSlideContent(container, item) {
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
container.appendChild(el);
} 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;
container.appendChild(el);
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
container.appendChild(el);
} 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`;
container.appendChild(el);
}
}
function showItemWithTransition(item) {
const mode = getTransitionMode(playlist);
applyTransitionClass(mode);
// Create new slide container.
const slide = document.createElement('div');
slide.className = 'slide enter';
setSlideContent(slide, item);
// Determine previous slide (if any).
const prev = stage.querySelector('.slide');
// First render: no animation needed.
if (!prev || mode === 'none') {
stage.innerHTML = '';
slide.classList.remove('enter');
stage.appendChild(slide);
return;
}
// Transition: keep both on stage and animate.
stage.appendChild(slide);
// Trigger transition.
// In some browsers the style changes can get coalesced into a single paint (no animation),
// especially on fast/fullscreen pages. We force a layout read before activating.
requestAnimationFrame(() => {
prev.classList.add('exit');
// Force reflow so the browser commits initial (enter) styles.
// eslint-disable-next-line no-unused-expressions
slide.offsetHeight;
slide.classList.add('active');
prev.classList.add('active');
});
// Cleanup after animation.
window.setTimeout(() => {
try {
if (prev && prev.parentNode === stage) stage.removeChild(prev);
slide.classList.remove('enter');
} catch(e) { /* ignore */ }
}, ANIM_MS + 50);
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
setNotice('No playlists assigned.');
clearStage();
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
// Clear any active timers (but keep DOM for transition).
if (timer) { clearTimeout(timer); timer = null; }
setNotice('');
showItemWithTransition(item);
if (item.type === 'image') {
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') {
// next() is called on video end.
} else if (item.type === 'webpage') {
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'youtube') {
// 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;
applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
applyTickerStyle(tickerConfig);
await refreshTickerOnce();
if (tickerConfig && tickerConfig.enabled) {
startTickerPolling();
} else {
stopTickerPolling();
}
next();
} catch (e) {
clearStage();
if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
// keep retrying; if a slot frees up the display will start automatically.
}
// Poll for updates instead of SSE.
// This scales better for 100s of displays because it avoids long-lived HTTP
// connections (which otherwise tie up gunicorn sync workers).
// Default: check every 20s (can be overridden via ?poll=seconds).
const pollParam = parseInt(new URLSearchParams(window.location.search).get('poll') || '', 10);
const pollSeconds = Number.isFinite(pollParam) && pollParam > 0 ? pollParam : 20;
setInterval(async () => {
try {
const newPlaylist = await fetchPlaylist();
// Restart if something changed.
const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
// Apply ticker settings (and refresh if settings changed)
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
const oldTickerStr = JSON.stringify(tickerConfig);
const newTickerStr = JSON.stringify(newTickerCfg);
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
tickerConfig = newTickerCfg;
applyTickerStyle(tickerConfig);
if (oldTickerStr !== newTickerStr) {
// Ensure enable/disable toggles are applied immediately (no reload required).
if (oldEnabled !== newEnabled) {
if (!newEnabled) {
// Hide and stop polling.
tickerLastHeadlines = [];
stopTickerPolling();
setTickerHeadlines([]);
} else {
// Re-enable: fetch now and restart headline polling.
await refreshTickerOnce();
startTickerPolling();
}
} else if (oldRssUrl !== newRssUrl) {
// RSS URL changed: refetch now.
await refreshTickerOnce();
} else {
// Style/speed change only: rerender from cache to apply instantly.
if (newEnabled) rerenderTickerFromCache();
}
}
if (oldStr !== newStr) {
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
next();
}
// If player is blank (e.g. after a temporary error), kick it.
if (!stage.firstChild) {
idx = 0;
next();
}
} catch(e) {
clearStage();
if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
}
}, pollSeconds * 1000);
}
start();
</script>
</body>
</html>