Release 1.6
This commit is contained in:
@@ -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()
|
||||
|
||||
26
app/cli.py
26
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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: <rss><channel><item><title>
|
||||
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."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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