Release 1.6

This commit is contained in:
2026-01-25 18:00:12 +01:00
parent 860679d119
commit 9fd3f03b87
8 changed files with 813 additions and 8 deletions

View File

@@ -69,13 +69,19 @@
<div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100">
<div class="display-preview">
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
<div
class="display-preview-scale"
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
>
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe>
</div>
</div>
<div class="card-body d-flex flex-column gap-2">
@@ -97,6 +103,14 @@
data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}"
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
data-current-ticker-enabled="{{ '1' if d.ticker_enabled else '0' }}"
data-current-ticker-rss-url="{{ d.ticker_rss_url or '' }}"
data-current-ticker-color="{{ d.ticker_color or '' }}"
data-current-ticker-bg-color="{{ d.ticker_bg_color or '' }}"
data-current-ticker-bg-opacity="{{ d.ticker_bg_opacity or '' }}"
data-current-ticker-font-family="{{ d.ticker_font_family or '' }}"
data-current-ticker-font-size-px="{{ d.ticker_font_size_px or '' }}"
data-current-ticker-speed="{{ d.ticker_speed or '' }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
>
@@ -164,6 +178,64 @@
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
</div>
<div class="card" style="border: 1px solid rgba(0,0,0,0.10);">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold">Ticker tape</div>
<div class="text-muted small">Scroll RSS headlines at the bottom of the display.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
<label class="form-check-label" for="editTickerEnabled">Enabled</label>
</div>
</div>
<div class="mt-3">
<label class="form-label" for="editTickerRssUrl">RSS feed URL</label>
<input class="form-control" id="editTickerRssUrl" type="url" placeholder="https://example.com/feed.xml" />
<div class="form-text">Tip: use a public RSS/Atom feed. Headlines are fetched server-side.</div>
</div>
<div class="row g-2 mt-2">
<div class="col-12 col-md-5">
<label class="form-label" for="editTickerColor">Text color</label>
<input class="form-control form-control-color" id="editTickerColor" type="color" value="#ffffff" title="Choose text color" />
</div>
<div class="col-12 col-md-5">
<label class="form-label" for="editTickerFontSize">Font size (px)</label>
<input class="form-control" id="editTickerFontSize" type="number" min="10" max="200" step="1" placeholder="28" />
</div>
<div class="col-12 col-md-7">
<label class="form-label" for="editTickerFontFamily">Font</label>
<select class="form-select" id="editTickerFontFamily">
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif">System (default)</option>
<option value="Arial, Helvetica, sans-serif">Arial</option>
<option value="Segoe UI, Arial, sans-serif">Segoe UI</option>
<option value="Roboto, Arial, sans-serif">Roboto</option>
<option value="Georgia, serif">Georgia</option>
<option value="Times New Roman, Times, serif">Times New Roman</option>
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace">Monospace</option>
</select>
</div>
<div class="col-12 col-md-7">
<label class="form-label" for="editTickerBgColor">Background color</label>
<input class="form-control form-control-color" id="editTickerBgColor" type="color" value="#000000" title="Choose background color" />
</div>
<div class="col-12 col-md-5">
<label class="form-label" for="editTickerBgOpacity">Background opacity</label>
<input class="form-range" id="editTickerBgOpacity" type="range" min="0" max="100" step="1" />
<div class="form-text"><span id="editTickerBgOpacityLabel">75</span>%</div>
</div>
<div class="col-12">
<label class="form-label" for="editTickerSpeed">Speed</label>
<input class="form-range" id="editTickerSpeed" type="range" min="1" max="100" step="1" />
<div class="form-text">Slower ⟷ Faster</div>
</div>
</div>
</div>
</div>
<hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
@@ -309,9 +381,32 @@
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
const tickerEnabledEl = document.getElementById('editTickerEnabled');
const tickerRssUrlEl = document.getElementById('editTickerRssUrl');
const tickerColorEl = document.getElementById('editTickerColor');
const tickerBgColorEl = document.getElementById('editTickerBgColor');
const tickerBgOpacityEl = document.getElementById('editTickerBgOpacity');
const tickerBgOpacityLabelEl = document.getElementById('editTickerBgOpacityLabel');
const tickerFontFamilyEl = document.getElementById('editTickerFontFamily');
const tickerFontSizeEl = document.getElementById('editTickerFontSize');
const tickerSpeedEl = document.getElementById('editTickerSpeed');
let activePlDisplayId = null;
let activePlButton = null;
function setRangeValue(rangeEl, labelEl, value, fallback) {
if (!rangeEl) return;
const n = parseInt(value || '', 10);
const v = Number.isFinite(n) ? n : fallback;
rangeEl.value = String(v);
if (labelEl) labelEl.textContent = String(v);
}
function onOpacityInput() {
if (!tickerBgOpacityEl || !tickerBgOpacityLabelEl) return;
tickerBgOpacityLabelEl.textContent = String(tickerBgOpacityEl.value || '0');
}
if (tickerBgOpacityEl) tickerBgOpacityEl.addEventListener('input', onOpacityInput);
function updatePlDescCount() {
if (!plDescInputEl || !plDescCountEl) return;
plDescCountEl.textContent = String((plDescInputEl.value || '').length);
@@ -378,6 +473,19 @@
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
if (tickerEnabledEl) {
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
if (tickerRssUrlEl) tickerRssUrlEl.value = btn.dataset.currentTickerRssUrl || '';
if (tickerColorEl) tickerColorEl.value = btn.dataset.currentTickerColor || '#ffffff';
if (tickerBgColorEl) tickerBgColorEl.value = btn.dataset.currentTickerBgColor || '#000000';
setRangeValue(tickerBgOpacityEl, tickerBgOpacityLabelEl, btn.dataset.currentTickerBgOpacity, 75);
onOpacityInput();
if (tickerFontFamilyEl) tickerFontFamilyEl.value = btn.dataset.currentTickerFontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
if (tickerFontSizeEl) tickerFontSizeEl.value = btn.dataset.currentTickerFontSizePx || '';
setRangeValue(tickerSpeedEl, null, btn.dataset.currentTickerSpeed, 25);
const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
@@ -393,11 +501,31 @@
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
const tickerRssUrl = tickerRssUrlEl ? (tickerRssUrlEl.value || '').trim() : '';
const tickerColor = tickerColorEl ? (tickerColorEl.value || '').trim() : '';
const tickerBgColor = tickerBgColorEl ? (tickerBgColorEl.value || '').trim() : '';
const tickerBgOpacity = tickerBgOpacityEl ? (tickerBgOpacityEl.value || '').trim() : '';
const tickerFontFamily = tickerFontFamilyEl ? (tickerFontFamilyEl.value || '').trim() : '';
const tickerFontSizePx = tickerFontSizeEl ? (tickerFontSizeEl.value || '').trim() : '';
const tickerSpeed = tickerSpeedEl ? (tickerSpeedEl.value || '').trim() : '';
plSaveBtn.disabled = true;
try {
const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay })
postDisplayUpdate(activePlDisplayId, {
description: desc,
transition,
show_overlay: showOverlay,
ticker_enabled: tickerEnabled,
ticker_rss_url: tickerRssUrl,
ticker_color: tickerColor,
ticker_bg_color: tickerBgColor,
ticker_bg_opacity: tickerBgOpacity,
ticker_font_family: tickerFontFamily,
ticker_font_size_px: tickerFontSizePx,
ticker_speed: tickerSpeed
})
]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
@@ -420,6 +548,39 @@
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
? !!updatedDesc.ticker_enabled
: tickerEnabled;
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0';
activePlButton.dataset.currentTickerRssUrl = (updatedDesc && typeof updatedDesc.ticker_rss_url === 'string')
? (updatedDesc.ticker_rss_url || '')
: tickerRssUrl;
activePlButton.dataset.currentTickerColor = (updatedDesc && typeof updatedDesc.ticker_color === 'string')
? (updatedDesc.ticker_color || '')
: tickerColor;
activePlButton.dataset.currentTickerBgColor = (updatedDesc && typeof updatedDesc.ticker_bg_color === 'string')
? (updatedDesc.ticker_bg_color || '')
: tickerBgColor;
activePlButton.dataset.currentTickerBgOpacity = (updatedDesc && (typeof updatedDesc.ticker_bg_opacity === 'number' || typeof updatedDesc.ticker_bg_opacity === 'string'))
? String(updatedDesc.ticker_bg_opacity || '')
: String(tickerBgOpacity || '');
activePlButton.dataset.currentTickerFontFamily = (updatedDesc && typeof updatedDesc.ticker_font_family === 'string')
? (updatedDesc.ticker_font_family || '')
: tickerFontFamily;
activePlButton.dataset.currentTickerFontSizePx = (updatedDesc && (typeof updatedDesc.ticker_font_size_px === 'number' || typeof updatedDesc.ticker_font_size_px === 'string'))
? String(updatedDesc.ticker_font_size_px || '')
: String(tickerFontSizePx || '');
activePlButton.dataset.currentTickerSpeed = (updatedDesc && (typeof updatedDesc.ticker_speed === 'number' || typeof updatedDesc.ticker_speed === 'string'))
? String(updatedDesc.ticker_speed || '')
: String(tickerSpeed || '');
showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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));