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

@@ -187,6 +187,29 @@ The player keeps itself up-to-date automatically:
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes. - It listens to `GET /api/display/<token>/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. - 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/<token>/ticker` and cached briefly.
- The player applies styles and refreshes headlines periodically.
## SMTP / Forgot password ## SMTP / Forgot password
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. 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:

View File

@@ -87,6 +87,34 @@ def create_app():
) )
db.session.commit() 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 # Companies: optional per-company storage quota
company_cols = [ company_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall() r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()

View File

@@ -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.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit() 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()] 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: if "storage_max_bytes" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))

View File

@@ -107,6 +107,17 @@ class Display(db.Model):
# Transition animation between slides: none|fade|slide # Transition animation between slides: none|fade|slide
transition = db.Column(db.String(20), nullable=True) 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. # If true, show the company's overlay PNG on top of the display content.
show_overlay = db.Column(db.Boolean, default=False, nullable=False) 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) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)

View File

@@ -2,6 +2,10 @@ from datetime import datetime, timedelta
import hashlib import hashlib
import json import json
import time 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 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 MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90 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: def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window.""" """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() 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") @bp.get("/display/<token>/playlist")
def display_playlist(token: str): def display_playlist(token: str):
display = Display.query.filter_by(token=token).first() display = Display.query.filter_by(token=token).first()
@@ -188,6 +317,17 @@ def display_playlist(token: str):
if not ok: if not ok:
return resp 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. # Determine active playlists. If display_playlist has any rows, use those.
# Otherwise fall back to the legacy assigned_playlist_id. # Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [ mapped_ids = [
@@ -209,6 +349,7 @@ def display_playlist(token: str):
"display": display.name, "display": display.name,
"transition": display.transition or "none", "transition": display.transition or "none",
"overlay_src": overlay_src, "overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [], "playlists": [],
"items": [], "items": [],
} }
@@ -273,12 +414,48 @@ def display_playlist(token: str):
"display": display.name, "display": display.name,
"transition": display.transition or "none", "transition": display.transition or "none",
"overlay_src": overlay_src, "overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists], "playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"items": items, "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") @bp.get("/display/<token>/events")
def display_events(token: str): def display_events(token: str):
"""Server-Sent Events stream to notify the player when its playlist changes.""" """Server-Sent Events stream to notify the player when its playlist changes."""

View File

@@ -1161,6 +1161,64 @@ def update_display(display_id: int):
return None return None
return v 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 # Inputs from either form or JSON
payload = request.get_json(silent=True) if request.is_json else None 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: if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"} 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 # Playlist assignment
if request.is_json: if request.is_json:
if "playlist_id" in payload: if "playlist_id" in payload:
@@ -1251,6 +1378,14 @@ def update_display(display_id: int):
"description": display.description, "description": display.description,
"transition": display.transition, "transition": display.transition,
"show_overlay": bool(display.show_overlay), "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, "assigned_playlist_id": display.assigned_playlist_id,
}, },
} }

View File

@@ -69,14 +69,20 @@
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100"> <div class="card display-gallery-card h-100">
<div class="display-preview"> <div class="display-preview">
<div
class="display-preview-scale"
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
>
<iframe <iframe
title="Preview — {{ d.name }}" title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}" data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1" src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy" loading="lazy"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe> ></iframe>
</div> </div>
</div>
<div class="card-body d-flex flex-column gap-2"> <div class="card-body d-flex flex-column gap-2">
<div> <div>
@@ -97,6 +103,14 @@
data-current-desc="{{ d.description or '' }}" data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}" data-current-transition="{{ d.transition or 'none' }}"
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}" 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-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}" 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> <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 class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
</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" /> <hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div> <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> <div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
@@ -309,9 +381,32 @@
const plDescCountEl = document.getElementById('editPlaylistsDescCount'); const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect'); const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck'); 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 activePlDisplayId = null;
let activePlButton = 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() { function updatePlDescCount() {
if (!plDescInputEl || !plDescCountEl) return; if (!plDescInputEl || !plDescCountEl) return;
plDescCountEl.textContent = String((plDescInputEl.value || '').length); plDescCountEl.textContent = String((plDescInputEl.value || '').length);
@@ -378,6 +473,19 @@
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; 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); const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected); renderPlaylistCheckboxes(selected);
if (plHintEl) { if (plHintEl) {
@@ -393,11 +501,31 @@
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : ''; const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none'; const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false; 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; plSaveBtn.disabled = true;
try { try {
const [updatedPlaylists, updatedDesc] = await Promise.all([ const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids), 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) const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
@@ -420,6 +548,39 @@
: showOverlay; : showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0'; 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'); showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId); refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide(); if (plModal) plModal.hide();

View File

@@ -8,6 +8,12 @@
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; } html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; } #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) */ /* Optional company overlay (transparent PNG) */
#overlay { #overlay {
position: fixed; position: fixed;
@@ -98,6 +104,41 @@
margin: 0; margin: 0;
opacity: 0.95; 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; } img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
/* removed bottom-left status text */ /* removed bottom-left status text */
</style> </style>
@@ -110,6 +151,9 @@
</div> </div>
</div> </div>
<div id="stage"></div> <div id="stage"></div>
<div id="ticker" aria-hidden="true">
<div class="track" id="tickerTrack"></div>
</div>
{% if overlay_url %} {% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" /> <img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %} {% endif %}
@@ -152,6 +196,13 @@
let idx = 0; let idx = 0;
let timer = null; 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; const ANIM_MS = 420;
function getTransitionMode(pl) { function getTransitionMode(pl) {
@@ -174,6 +225,176 @@
return await res.json(); 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() { function clearStage() {
if (timer) { clearTimeout(timer); timer = null; } if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = ''; stage.innerHTML = '';
@@ -320,6 +541,10 @@
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src); setOverlaySrc(playlist && playlist.overlay_src);
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
applyTickerStyle(tickerConfig);
await refreshTickerOnce();
startTickerPolling();
next(); next();
} catch (e) { } catch (e) {
clearStage(); clearStage();
@@ -349,6 +574,23 @@
const newStr = JSON.stringify(newPlaylist); const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist; playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src); 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) { if (oldStr !== newStr) {
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));