630 lines
22 KiB
HTML
630 lines
22 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
async function refreshTickerOnce() {
|
|
if (!tickerConfig || !tickerConfig.enabled) {
|
|
if (tickerEl) tickerEl.style.display = 'none';
|
|
document.body.classList.remove('has-ticker');
|
|
return;
|
|
}
|
|
// No URL: keep hidden.
|
|
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
|
|
if (tickerEl) tickerEl.style.display = 'none';
|
|
document.body.classList.remove('has-ticker');
|
|
return;
|
|
}
|
|
try {
|
|
const data = await fetchTickerHeadlines();
|
|
if (!data || !data.enabled) {
|
|
if (tickerEl) tickerEl.style.display = 'none';
|
|
document.body.classList.remove('has-ticker');
|
|
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 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();
|
|
startTickerPolling();
|
|
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 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) {
|
|
// If RSS URL changed, refetch. Otherwise just rerender to apply speed/style immediately.
|
|
if (oldRssUrl !== newRssUrl) {
|
|
await refreshTickerOnce();
|
|
} else {
|
|
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>
|