Release 1.6
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
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;
|
||||
@@ -98,6 +104,41 @@
|
||||
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>
|
||||
@@ -110,6 +151,9 @@
|
||||
</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 %}
|
||||
@@ -152,6 +196,13 @@
|
||||
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 = [];
|
||||
|
||||
const ANIM_MS = 420;
|
||||
|
||||
function getTransitionMode(pl) {
|
||||
@@ -174,6 +225,176 @@
|
||||
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;
|
||||
}
|
||||
// Refresh every 2 minutes; server caches too.
|
||||
tickerInterval = setInterval(refreshTickerOnce, 120 * 1000);
|
||||
}
|
||||
|
||||
function clearStage() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
stage.innerHTML = '';
|
||||
@@ -320,6 +541,10 @@
|
||||
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();
|
||||
@@ -349,6 +574,23 @@
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user