Release 1.6.1

This commit is contained in:
2026-01-25 18:35:28 +01:00
parent 9fd3f03b87
commit c5aa8a5156
8 changed files with 291 additions and 207 deletions

View File

@@ -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. 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) - RSS feed URL (public http/https)
- Text color (picker) - Text color (picker)
- Background color + opacity - Background color + opacity
@@ -205,10 +204,14 @@ Options:
- Font size - Font size
- Speed - Speed
Per-display option:
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
Implementation notes: Implementation notes:
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached briefly. - Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached briefly.
- The player applies styles and refreshes headlines periodically. - The player reads the company ticker settings via `GET /api/display/<token>/playlist` and refreshes headlines periodically.
## SMTP / Forgot password ## SMTP / Forgot password
@@ -286,5 +289,6 @@ If the reset email is not received:

View File

@@ -128,6 +128,29 @@ def create_app():
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)")) db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit() 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. # AppSettings: create settings table if missing.
# (PRAGMA returns empty if the table doesn't exist.) # (PRAGMA returns empty if the table doesn't exist.)
settings_cols = [ settings_cols = [

View File

@@ -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.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit() 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()] 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: 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)")) db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))

View File

@@ -20,6 +20,16 @@ class Company(db.Model):
# Example: uploads/<company_id>/overlay_<uuid>.png # Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True) 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") users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
displays = db.relationship("Display", 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") playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")

View File

@@ -303,10 +303,11 @@ def display_playlist(token: str):
if not display: if not display:
abort(404) abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Optional overlay URL (per-company) when enabled on this display. # Optional overlay URL (per-company) when enabled on this display.
overlay_src = None overlay_src = None
if display.show_overlay: 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): 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) overlay_src = url_for("static", filename=company.overlay_file_path)
@@ -317,15 +318,16 @@ def display_playlist(token: str):
if not ok: if not ok:
return resp return resp
# Ticker settings are configured per-company; displays can enable/disable individually.
ticker_cfg = { ticker_cfg = {
"enabled": bool(display.ticker_enabled), "enabled": bool(display.ticker_enabled),
"rss_url": display.ticker_rss_url, "rss_url": (company.ticker_rss_url if company else None),
"color": display.ticker_color, "color": (company.ticker_color if company else None),
"bg_color": display.ticker_bg_color, "bg_color": (company.ticker_bg_color if company else None),
"bg_opacity": display.ticker_bg_opacity, "bg_opacity": (company.ticker_bg_opacity if company else None),
"font_family": display.ticker_font_family, "font_family": (company.ticker_font_family if company else None),
"font_size_px": display.ticker_font_size_px, "font_size_px": (company.ticker_font_size_px if company else None),
"speed": display.ticker_speed, "speed": (company.ticker_speed if company else None),
} }
# Determine active playlists. If display_playlist has any rows, use those. # Determine active playlists. If display_playlist has any rows, use those.
@@ -432,6 +434,8 @@ def display_ticker(token: str):
if not display: if not display:
abort(404) abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Enforce concurrent session limit the same way as /playlist. # Enforce concurrent session limit the same way as /playlist.
sid = request.args.get("sid") sid = request.args.get("sid")
ok, resp = _enforce_and_touch_display_session(display, sid) ok, resp = _enforce_and_touch_display_session(display, sid)
@@ -441,7 +445,7 @@ def display_ticker(token: str):
if not display.ticker_enabled: if not display.ticker_enabled:
return jsonify({"enabled": False, "headlines": []}) 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: if not rss_url:
return jsonify({"enabled": True, "headlines": []}) return jsonify({"enabled": True, "headlines": []})

View File

@@ -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." 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") @bp.get("/my-company")
@login_required @login_required
def my_company(): 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") @bp.post("/my-company/overlay")
@login_required @login_required
def upload_company_overlay(): def upload_company_overlay():
@@ -1268,7 +1337,8 @@ 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 # Ticker tape: per-display enable/disable only.
# RSS + styling is configured per-company (see /my-company).
if request.is_json: if request.is_json:
if payload is None: if payload is None:
return _json_error("Invalid JSON") return _json_error("Invalid JSON")
@@ -1282,61 +1352,11 @@ def update_display(display_id: int):
else: else:
s = ("" if raw is None else str(raw)).strip().lower() s = ("" if raw is None else str(raw)).strip().lower()
display.ticker_enabled = s in {"1", "true", "yes", "on"} 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: else:
# Form POST implies full update
raw = request.form.get("ticker_enabled") raw = request.form.get("ticker_enabled")
if raw is not None: if raw is not None:
display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"} 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:
@@ -1379,13 +1399,13 @@ def update_display(display_id: int):
"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_enabled": bool(display.ticker_enabled),
"ticker_rss_url": display.ticker_rss_url, "ticker_rss_url": None,
"ticker_color": display.ticker_color, "ticker_color": None,
"ticker_bg_color": display.ticker_bg_color, "ticker_bg_color": None,
"ticker_bg_opacity": display.ticker_bg_opacity, "ticker_bg_opacity": None,
"ticker_font_family": display.ticker_font_family, "ticker_font_family": None,
"ticker_font_size_px": display.ticker_font_size_px, "ticker_font_size_px": None,
"ticker_speed": display.ticker_speed, "ticker_speed": None,
"assigned_playlist_id": display.assigned_playlist_id, "assigned_playlist_id": display.assigned_playlist_id,
}, },
} }

View File

@@ -104,13 +104,6 @@
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-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(',') }}"
> >
@@ -179,61 +172,13 @@
<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="form-check form-switch mb-2">
<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" /> <input class="form-check-input" type="checkbox" id="editTickerEnabled" />
<label class="form-check-label" for="editTickerEnabled">Enabled</label> <label class="form-check-label" for="editTickerEnabled">
</div> Enable ticker tape on this display
</div> </label>
<div class="form-text">
<div class="mt-3"> RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
<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>
</div> </div>
<hr class="my-3" /> <hr class="my-3" />
@@ -382,31 +327,9 @@
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 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);
@@ -477,14 +400,6 @@
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase(); const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; 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);
@@ -502,13 +417,6 @@
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 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([
@@ -517,14 +425,7 @@
description: desc, description: desc,
transition, transition,
show_overlay: showOverlay, show_overlay: showOverlay,
ticker_enabled: tickerEnabled, 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
}) })
]); ]);
@@ -553,34 +454,6 @@
: tickerEnabled; : tickerEnabled;
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0'; 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

@@ -184,4 +184,131 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Ticker tape (RSS)</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
</div>
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
<div>
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
<input
class="form-control"
id="companyTickerRssUrl"
name="ticker_rss_url"
type="url"
value="{{ company.ticker_rss_url or '' }}"
placeholder="https://example.com/feed.xml"
/>
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerColor">Text color</label>
<input
class="form-control form-control-color"
id="companyTickerColor"
name="ticker_color"
type="color"
value="{{ company.ticker_color or '#ffffff' }}"
title="Choose text color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgColor">Background color</label>
<input
class="form-control form-control-color"
id="companyTickerBgColor"
name="ticker_bg_color"
type="color"
value="{{ company.ticker_bg_color or '#000000' }}"
title="Choose background color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
<input
class="form-range"
id="companyTickerBgOpacity"
name="ticker_bg_opacity"
type="range"
min="0"
max="100"
step="1"
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
/>
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-6">
<label class="form-label" for="companyTickerFontFamily">Font</label>
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
<input
class="form-control"
id="companyTickerFontSize"
name="ticker_font_size_px"
type="number"
min="10"
max="200"
step="1"
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
/>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerSpeed">Speed</label>
<input
class="form-range"
id="companyTickerSpeed"
name="ticker_speed"
type="range"
min="1"
max="100"
step="1"
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
/>
<div class="form-text">Slower ⟷ Faster</div>
</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save ticker settings</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
const opacityEl = document.getElementById('companyTickerBgOpacity');
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
function syncOpacity() {
if (!opacityEl || !opacityLabelEl) return;
opacityLabelEl.textContent = String(opacityEl.value || '0');
}
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
syncOpacity();
})();
</script>
{% endblock %} {% endblock %}