diff --git a/README.md b/README.md index 9ceef86..d2ae3ce 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,29 @@ The player keeps itself up-to-date automatically: - It listens to `GET /api/display//events` (Server-Sent Events) and reloads the playlist immediately when it changes. - It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE. +## Ticker tape (RSS headlines) + +Each display can optionally show a **bottom ticker tape** with scrolling news headlines. + +Configure it as a company user via: + +- **Dashboard → Displays → Configure display → Ticker tape** + +Options: + +- Enable/disable ticker entirely +- RSS feed URL (public http/https) +- Text color (picker) +- Background color + opacity +- Font (dropdown) +- Font size +- Speed + +Implementation notes: + +- Headlines are fetched server-side via `GET /api/display//ticker` and cached briefly. +- The player applies styles and refreshes headlines periodically. + ## SMTP / Forgot password This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. @@ -260,6 +283,8 @@ If the reset email is not received: + + diff --git a/app/__init__.py b/app/__init__.py index 8d43137..e786158 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -87,6 +87,34 @@ def create_app(): ) db.session.commit() + # Displays: optional ticker tape (RSS headlines) + if "ticker_enabled" not in display_cols: + db.session.execute( + db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0") + ) + db.session.commit() + if "ticker_rss_url" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)")) + db.session.commit() + if "ticker_color" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_color" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_opacity" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER")) + db.session.commit() + if "ticker_font_family" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)")) + db.session.commit() + if "ticker_font_size_px" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER")) + db.session.commit() + if "ticker_speed" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER")) + db.session.commit() + # Companies: optional per-company storage quota company_cols = [ r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall() diff --git a/app/cli.py b/app/cli.py index bcf7ff9..c12063b 100644 --- a/app/cli.py +++ b/app/cli.py @@ -30,6 +30,32 @@ def _ensure_schema_and_settings() -> None: db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")) db.session.commit() + # Optional ticker tape (RSS headlines) + if "ticker_enabled" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")) + db.session.commit() + if "ticker_rss_url" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)")) + db.session.commit() + if "ticker_color" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_color" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_opacity" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER")) + db.session.commit() + if "ticker_font_family" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)")) + db.session.commit() + if "ticker_font_size_px" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER")) + db.session.commit() + if "ticker_speed" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER")) + db.session.commit() + company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()] if "storage_max_bytes" not in company_cols: db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) diff --git a/app/models.py b/app/models.py index 18cb8fd..8a5436e 100644 --- a/app/models.py +++ b/app/models.py @@ -107,6 +107,17 @@ class Display(db.Model): # Transition animation between slides: none|fade|slide transition = db.Column(db.String(20), nullable=True) + # Optional ticker tape (RSS headlines) rendered on the display. + # Note: for this small project we avoid a JSON config blob; we store a few explicit columns. + ticker_enabled = db.Column(db.Boolean, default=False, nullable=False) + ticker_rss_url = db.Column(db.String(1000), nullable=True) + ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff" + ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity + ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100 + ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family + ticker_font_size_px = db.Column(db.Integer, nullable=True) # px + ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster + # If true, show the company's overlay PNG on top of the display content. show_overlay = db.Column(db.Boolean, default=False, nullable=False) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) diff --git a/app/routes/api.py b/app/routes/api.py index 650fbb0..f6cb6e4 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2,6 +2,10 @@ from datetime import datetime, timedelta import hashlib import json import time +import re +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from xml.etree import ElementTree as ET from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for @@ -15,6 +19,10 @@ bp = Blueprint("api", __name__, url_prefix="/api") MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3 SESSION_TTL_SECONDS = 90 +# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds) +TICKER_CACHE_TTL_SECONDS = 120 +_TICKER_CACHE: dict[str, dict] = {} + def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool: """Return True if playlist is active based on its optional schedule window.""" @@ -168,6 +176,127 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]: return None, hashlib.sha1(raw.encode("utf-8")).hexdigest() +def _is_http_url_allowed(url: str) -> bool: + """Basic SSRF hardening: only allow http(s) and disallow obvious local targets.""" + + try: + u = urlparse(url) + except Exception: + return False + + if u.scheme not in {"http", "https"}: + return False + + host = (u.hostname or "").strip().lower() + if not host: + return False + + # Block localhost and common local domains. + if host in {"localhost", "127.0.0.1", "::1"}: + return False + + # Block RFC1918-ish and link-local targets when host is an IP. + # Note: this is best-effort; proper SSRF protection would require DNS resolution too. + if re.match(r"^\d+\.\d+\.\d+\.\d+$", host): + parts = [int(x) for x in host.split(".")] + if parts[0] == 10: + return False + if parts[0] == 127: + return False + if parts[0] == 169 and parts[1] == 254: + return False + if parts[0] == 192 and parts[1] == 168: + return False + if parts[0] == 172 and 16 <= parts[1] <= 31: + return False + + return True + + +def _strip_text(s: str) -> str: + s = (s or "").strip() + s = re.sub(r"\s+", " ", s) + return s + + +def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]: + """Fetch RSS/Atom titles from a feed URL. + + We intentionally avoid adding dependencies (feedparser) for this project. + This implementation is tolerant enough for typical RSS2/Atom feeds. + """ + + req = Request( + url, + headers={ + "User-Agent": "SignageTicker/1.0 (+https://example.invalid)", + "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*", + }, + method="GET", + ) + + with urlopen(req, timeout=8) as resp: + # Basic size cap (avoid reading huge responses into memory) + raw = resp.read(2_000_000) # 2MB + + try: + root = ET.fromstring(raw) + except Exception: + return [] + + titles: list[str] = [] + + # RSS2: + for el in root.findall(".//item/title"): + t = _strip_text("".join(el.itertext())) + if t: + titles.append(t) + + # Atom: <feed><entry><title> + if not titles: + for el in root.findall(".//{*}entry/{*}title"): + t = _strip_text("".join(el.itertext())) + if t: + titles.append(t) + + # Some feeds may have <channel><title> etc; we only want entry titles. + # Deduplicate while preserving order. + deduped: list[str] = [] + seen = set() + for t in titles: + if t in seen: + continue + seen.add(t) + deduped.append(t) + if len(deduped) >= limit: + break + + return deduped + + +def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]: + """Return (titles, from_cache).""" + + now = time.time() + key = (url or "").strip() + if not key: + return [], True + + entry = _TICKER_CACHE.get(key) + if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS: + return (entry.get("titles") or []), True + + titles: list[str] = [] + try: + if _is_http_url_allowed(key): + titles = _fetch_rss_titles(key) + except Exception: + titles = [] + + _TICKER_CACHE[key] = {"ts": now, "titles": titles} + return titles, False + + @bp.get("/display/<token>/playlist") def display_playlist(token: str): display = Display.query.filter_by(token=token).first() @@ -188,6 +317,17 @@ def display_playlist(token: str): if not ok: return resp + ticker_cfg = { + "enabled": bool(display.ticker_enabled), + "rss_url": display.ticker_rss_url, + "color": display.ticker_color, + "bg_color": display.ticker_bg_color, + "bg_opacity": display.ticker_bg_opacity, + "font_family": display.ticker_font_family, + "font_size_px": display.ticker_font_size_px, + "speed": display.ticker_speed, + } + # Determine active playlists. If display_playlist has any rows, use those. # Otherwise fall back to the legacy assigned_playlist_id. mapped_ids = [ @@ -209,6 +349,7 @@ def display_playlist(token: str): "display": display.name, "transition": display.transition or "none", "overlay_src": overlay_src, + "ticker": ticker_cfg, "playlists": [], "items": [], } @@ -273,12 +414,48 @@ def display_playlist(token: str): "display": display.name, "transition": display.transition or "none", "overlay_src": overlay_src, + "ticker": ticker_cfg, "playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists], "items": items, } ) +@bp.get("/display/<token>/ticker") +def display_ticker(token: str): + """Return ticker headlines for a display. + + We keep it separate from /playlist so the player can refresh headlines on its own interval. + """ + + display = Display.query.filter_by(token=token).first() + if not display: + abort(404) + + # Enforce concurrent session limit the same way as /playlist. + sid = request.args.get("sid") + ok, resp = _enforce_and_touch_display_session(display, sid) + if not ok: + return resp + + if not display.ticker_enabled: + return jsonify({"enabled": False, "headlines": []}) + + rss_url = (display.ticker_rss_url or "").strip() + if not rss_url: + return jsonify({"enabled": True, "headlines": []}) + + titles, from_cache = _get_ticker_titles_cached(rss_url) + return jsonify( + { + "enabled": True, + "rss_url": rss_url, + "headlines": titles, + "cached": bool(from_cache), + } + ) + + @bp.get("/display/<token>/events") def display_events(token: str): """Server-Sent Events stream to notify the player when its playlist changes.""" diff --git a/app/routes/company.py b/app/routes/company.py index 8d417b7..d4fe4a1 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -1161,6 +1161,64 @@ def update_display(display_id: int): return None return v + def _normalize_css_color(val: str | None) -> str | None: + """Accept a limited set of CSS color inputs (primarily hex + a few keywords). + + This is used to avoid storing arbitrary CSS strings while still being user friendly. + """ + + v = (val or "").strip() + if not v: + return None + + low = v.lower() + if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}: + return low + + # Hex colors: #RGB, #RRGGBB, #RRGGBBAA + if low.startswith("#"): + h = low[1:] + if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h): + return "#" + h + return None + + def _normalize_percent(val) -> int | None: + if val in (None, ""): + return None + try: + n = int(val) + except (TypeError, ValueError): + return None + return min(100, max(0, n)) + + def _normalize_speed(val) -> int | None: + if val in (None, ""): + return None + try: + n = int(val) + except (TypeError, ValueError): + return None + return min(100, max(1, n)) + + def _normalize_font_family(val: str | None) -> str | None: + v = (val or "").strip() + if not v: + return None + # keep it short and avoid quotes/newlines that could be abused in CSS. + v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "") + v = " ".join(v.split()) + return v[:120] if v else None + + def _normalize_font_size_px(val) -> int | None: + if val in (None, ""): + return None + try: + n = int(val) + except (TypeError, ValueError): + return None + # reasonable bounds for signage displays + return min(200, max(10, n)) + # Inputs from either form or JSON payload = request.get_json(silent=True) if request.is_json else None @@ -1210,6 +1268,75 @@ def update_display(display_id: int): if raw is not None: display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"} + # Ticker tape settings + if request.is_json: + if payload is None: + return _json_error("Invalid JSON") + + if "ticker_enabled" in payload: + raw = payload.get("ticker_enabled") + if isinstance(raw, bool): + display.ticker_enabled = raw + elif raw in (1, 0): + display.ticker_enabled = bool(raw) + else: + s = ("" if raw is None else str(raw)).strip().lower() + display.ticker_enabled = s in {"1", "true", "yes", "on"} + + if "ticker_rss_url" in payload: + u = (payload.get("ticker_rss_url") or "").strip() or None + # Keep within column limit and avoid whitespace-only. + if u is not None: + u = u[:1000] + display.ticker_rss_url = u + + if "ticker_color" in payload: + display.ticker_color = _normalize_css_color(payload.get("ticker_color")) + + if "ticker_bg_color" in payload: + display.ticker_bg_color = _normalize_css_color(payload.get("ticker_bg_color")) + + if "ticker_bg_opacity" in payload: + display.ticker_bg_opacity = _normalize_percent(payload.get("ticker_bg_opacity")) + + if "ticker_font_family" in payload: + display.ticker_font_family = _normalize_font_family(payload.get("ticker_font_family")) + + if "ticker_font_size_px" in payload: + display.ticker_font_size_px = _normalize_font_size_px(payload.get("ticker_font_size_px")) + + if "ticker_speed" in payload: + display.ticker_speed = _normalize_speed(payload.get("ticker_speed")) + else: + # Form POST implies full update + raw = request.form.get("ticker_enabled") + if raw is not None: + display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"} + + if "ticker_rss_url" in request.form: + u = (request.form.get("ticker_rss_url") or "").strip() or None + if u is not None: + u = u[:1000] + display.ticker_rss_url = u + + if "ticker_color" in request.form: + display.ticker_color = _normalize_css_color(request.form.get("ticker_color")) + + if "ticker_bg_color" in request.form: + display.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color")) + + if "ticker_bg_opacity" in request.form: + display.ticker_bg_opacity = _normalize_percent(request.form.get("ticker_bg_opacity")) + + if "ticker_font_family" in request.form: + display.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family")) + + if "ticker_font_size_px" in request.form: + display.ticker_font_size_px = _normalize_font_size_px(request.form.get("ticker_font_size_px")) + + if "ticker_speed" in request.form: + display.ticker_speed = _normalize_speed(request.form.get("ticker_speed")) + # Playlist assignment if request.is_json: if "playlist_id" in payload: @@ -1251,6 +1378,14 @@ def update_display(display_id: int): "description": display.description, "transition": display.transition, "show_overlay": bool(display.show_overlay), + "ticker_enabled": bool(display.ticker_enabled), + "ticker_rss_url": display.ticker_rss_url, + "ticker_color": display.ticker_color, + "ticker_bg_color": display.ticker_bg_color, + "ticker_bg_opacity": display.ticker_bg_opacity, + "ticker_font_family": display.ticker_font_family, + "ticker_font_size_px": display.ticker_font_size_px, + "ticker_speed": display.ticker_speed, "assigned_playlist_id": display.assigned_playlist_id, }, } diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html index 89e851a..5c1836a 100644 --- a/app/templates/company/dashboard.html +++ b/app/templates/company/dashboard.html @@ -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(); diff --git a/app/templates/display/player.html b/app/templates/display/player.html index 902af31..d810f2f 100644 --- a/app/templates/display/player.html +++ b/app/templates/display/player.html @@ -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));