From c5aa8a515691bcc1684a85dc9da694348d73845b Mon Sep 17 00:00:00 2001 From: bramval Date: Sun, 25 Jan 2026 18:35:28 +0100 Subject: [PATCH] Release 1.6.1 --- README.md | 14 ++- app/__init__.py | 23 +++++ app/cli.py | 23 +++++ app/models.py | 10 ++ app/routes/api.py | 22 ++-- app/routes/company.py | 136 +++++++++++++----------- app/templates/company/dashboard.html | 143 ++------------------------ app/templates/company/my_company.html | 127 +++++++++++++++++++++++ 8 files changed, 291 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index d2ae3ce..e945bba 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,12 @@ The player keeps itself up-to-date automatically: Each display can optionally show a **bottom ticker tape** with scrolling news headlines. -Configure it as a company user via: +Configure RSS + styling as a company user via: -- **Dashboard → Displays → Configure display → Ticker tape** +- **My Company → Ticker tape (RSS)** -Options: +Company-level options: -- Enable/disable ticker entirely - RSS feed URL (public http/https) - Text color (picker) - Background color + opacity @@ -205,10 +204,14 @@ Options: - Font size - Speed +Per-display option: + +- Enable/disable ticker on that display (Dashboard → Displays → Configure display) + Implementation notes: - Headlines are fetched server-side via `GET /api/display//ticker` and cached briefly. -- The player applies styles and refreshes headlines periodically. +- The player reads the company ticker settings via `GET /api/display//playlist` and refreshes headlines periodically. ## SMTP / Forgot password @@ -286,5 +289,6 @@ If the reset email is not received: + diff --git a/app/__init__.py b/app/__init__.py index e786158..567cd0f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -128,6 +128,29 @@ def create_app(): db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)")) db.session.commit() + # Companies: ticker tape settings (RSS + styling) + if "ticker_rss_url" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)")) + db.session.commit() + if "ticker_color" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_color" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_opacity" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER")) + db.session.commit() + if "ticker_font_family" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)")) + db.session.commit() + if "ticker_font_size_px" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER")) + db.session.commit() + if "ticker_speed" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER")) + db.session.commit() + # AppSettings: create settings table if missing. # (PRAGMA returns empty if the table doesn't exist.) settings_cols = [ diff --git a/app/cli.py b/app/cli.py index c12063b..0563c28 100644 --- a/app/cli.py +++ b/app/cli.py @@ -65,6 +65,29 @@ def _ensure_schema_and_settings() -> None: db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)")) db.session.commit() + # Companies: ticker tape settings (RSS + styling) + if "ticker_rss_url" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)")) + db.session.commit() + if "ticker_color" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_color" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)")) + db.session.commit() + if "ticker_bg_opacity" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER")) + db.session.commit() + if "ticker_font_family" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)")) + db.session.commit() + if "ticker_font_size_px" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER")) + db.session.commit() + if "ticker_speed" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER")) + db.session.commit() + settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()] if settings_cols and "public_domain" not in settings_cols: db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) diff --git a/app/models.py b/app/models.py index 8a5436e..c311ae7 100644 --- a/app/models.py +++ b/app/models.py @@ -20,6 +20,16 @@ class Company(db.Model): # Example: uploads//overlay_.png overlay_file_path = db.Column(db.String(400), nullable=True) + # Optional per-company ticker tape (RSS headlines) settings. + # Displays can enable/disable the ticker individually. + ticker_rss_url = db.Column(db.String(1000), nullable=True) + ticker_color = db.Column(db.String(32), nullable=True) + ticker_bg_color = db.Column(db.String(32), nullable=True) + ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100 + ticker_font_family = db.Column(db.String(120), nullable=True) + ticker_font_size_px = db.Column(db.Integer, nullable=True) + ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 + users = db.relationship("User", back_populates="company", cascade="all, delete-orphan") displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan") playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan") diff --git a/app/routes/api.py b/app/routes/api.py index f6cb6e4..ca04a1d 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -303,10 +303,11 @@ def display_playlist(token: str): if not display: abort(404) + company = Company.query.filter_by(id=display.company_id).first() + # Optional overlay URL (per-company) when enabled on this display. overlay_src = None if display.show_overlay: - company = Company.query.filter_by(id=display.company_id).first() if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path): overlay_src = url_for("static", filename=company.overlay_file_path) @@ -317,15 +318,16 @@ def display_playlist(token: str): if not ok: return resp + # Ticker settings are configured per-company; displays can enable/disable individually. 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, + "rss_url": (company.ticker_rss_url if company else None), + "color": (company.ticker_color if company else None), + "bg_color": (company.ticker_bg_color if company else None), + "bg_opacity": (company.ticker_bg_opacity if company else None), + "font_family": (company.ticker_font_family if company else None), + "font_size_px": (company.ticker_font_size_px if company else None), + "speed": (company.ticker_speed if company else None), } # Determine active playlists. If display_playlist has any rows, use those. @@ -432,6 +434,8 @@ def display_ticker(token: str): if not display: abort(404) + company = Company.query.filter_by(id=display.company_id).first() + # Enforce concurrent session limit the same way as /playlist. sid = request.args.get("sid") ok, resp = _enforce_and_touch_display_session(display, sid) @@ -441,7 +445,7 @@ def display_ticker(token: str): if not display.ticker_enabled: return jsonify({"enabled": False, "headlines": []}) - rss_url = (display.ticker_rss_url or "").strip() + rss_url = ((company.ticker_rss_url if company else None) or "").strip() if not rss_url: return jsonify({"enabled": True, "headlines": []}) diff --git a/app/routes/company.py b/app/routes/company.py index d4fe4a1..5b16814 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -315,6 +315,44 @@ def _storage_limit_error_message(*, storage_max_human: str | None) -> str: return "Storage limit reached. Please delete items to free space." +def _normalize_css_color(val: str | None) -> str | None: + """Accept a limited set of CSS color inputs (primarily hex + a few keywords).""" + + 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 + + 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_font_family(val: str | None) -> str | None: + v = (val or "").strip() + if not v: + return None + v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "") + v = " ".join(v.split()) + return v[:120] if v else None + + +def _normalize_int(val, *, min_val: int, max_val: int) -> int | None: + if val in (None, ""): + return None + try: + n = int(val) + except (TypeError, ValueError): + return None + return min(max_val, max(min_val, n)) + + @bp.get("/my-company") @login_required def my_company(): @@ -376,6 +414,37 @@ def my_company(): ) +@bp.post("/my-company/ticker") +@login_required +def update_company_ticker_settings(): + """Update per-company ticker tape settings. + + Note: per-display enable/disable remains on the Display model. + """ + + company_user_required() + + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + rss_url = (request.form.get("ticker_rss_url") or "").strip() or None + if rss_url is not None: + rss_url = rss_url[:1000] + + company.ticker_rss_url = rss_url + company.ticker_color = _normalize_css_color(request.form.get("ticker_color")) + company.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color")) + company.ticker_bg_opacity = _normalize_int(request.form.get("ticker_bg_opacity"), min_val=0, max_val=100) + company.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family")) + company.ticker_font_size_px = _normalize_int(request.form.get("ticker_font_size_px"), min_val=10, max_val=200) + company.ticker_speed = _normalize_int(request.form.get("ticker_speed"), min_val=1, max_val=100) + + db.session.commit() + flash("Ticker settings updated.", "success") + return redirect(url_for("company.my_company")) + + @bp.post("/my-company/overlay") @login_required def upload_company_overlay(): @@ -1268,7 +1337,8 @@ 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 + # Ticker tape: per-display enable/disable only. + # RSS + styling is configured per-company (see /my-company). if request.is_json: if payload is None: return _json_error("Invalid JSON") @@ -1282,61 +1352,11 @@ def update_display(display_id: int): 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: @@ -1379,13 +1399,13 @@ def update_display(display_id: int): "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, + "ticker_rss_url": None, + "ticker_color": None, + "ticker_bg_color": None, + "ticker_bg_opacity": None, + "ticker_font_family": None, + "ticker_font_size_px": None, + "ticker_speed": None, "assigned_playlist_id": display.assigned_playlist_id, }, } diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html index 5c1836a..574423a 100644 --- a/app/templates/company/dashboard.html +++ b/app/templates/company/dashboard.html @@ -104,13 +104,6 @@ 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(',') }}" > @@ -179,61 +172,13 @@
If your company has an overlay uploaded, it will be displayed on top of the content.
-
-
-
-
-
Ticker tape
-
Scroll RSS headlines at the bottom of the display.
-
-
- - -
-
- -
- - -
Tip: use a public RSS/Atom feed. Headlines are fetched server-side.
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
75%
-
-
- - -
Slower ⟷ Faster
-
-
+
+ + +
+ RSS feed + styling is configured in My Company.

@@ -382,31 +327,9 @@ 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); @@ -477,14 +400,6 @@ 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); @@ -502,13 +417,6 @@ 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([ @@ -517,14 +425,7 @@ 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 + ticker_enabled: tickerEnabled }) ]); @@ -553,34 +454,6 @@ : 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/company/my_company.html b/app/templates/company/my_company.html index 4e32bc6..624b4ec 100644 --- a/app/templates/company/my_company.html +++ b/app/templates/company/my_company.html @@ -184,4 +184,131 @@ {% endif %}
+ +
+
+

Ticker tape (RSS)

+
+
+
+ Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard. +
+ +
+
+ + +
Leave empty to disable headlines (even if displays have ticker enabled).
+
+ +
+
+ + +
+
+ + +
+
+ + +
{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}%
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
Slower ⟷ Faster
+
+
+ +
+ +
+
+
+
+{% endblock %} + +{% block page_scripts %} + {% endblock %}