Release v1.3

This commit is contained in:
2026-01-25 15:57:38 +01:00
parent 47aca9d64d
commit 644f453c8f
10 changed files with 370 additions and 5 deletions

View File

@@ -62,6 +62,13 @@ def create_app():
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)")) db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit() db.session.commit()
# Displays: per-display overlay toggle
if "show_overlay" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")
)
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()
@@ -70,6 +77,11 @@ def create_app():
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"))
db.session.commit() db.session.commit()
# Companies: optional overlay file path
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
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

@@ -22,11 +22,23 @@ def _ensure_schema_and_settings() -> None:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)")) db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
db.session.commit() db.session.commit()
if "transition" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit()
if "show_overlay" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
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"))
db.session.commit() db.session.commit()
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
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

@@ -16,6 +16,10 @@ class Company(db.Model):
# If NULL or <=0: unlimited. # If NULL or <=0: unlimited.
storage_max_bytes = db.Column(db.BigInteger, nullable=True) storage_max_bytes = db.Column(db.BigInteger, nullable=True)
# Optional per-company 16:9 PNG overlay (stored under /static/uploads/...)
# Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True)
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")
@@ -102,6 +106,9 @@ class Display(db.Model):
description = db.Column(db.String(200), nullable=True) description = db.Column(db.String(200), nullable=True)
# 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)
# If true, show the company's overlay PNG on top of the display content.
show_overlay = db.Column(db.Boolean, default=False, nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)

View File

@@ -395,6 +395,10 @@ def delete_company(company_id: int):
if it.item_type in ("image", "video"): if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, upload_folder) _try_delete_upload(it.file_path, upload_folder)
# 3b) Clean up uploaded overlay (if any)
if company.overlay_file_path:
_try_delete_upload(company.overlay_file_path, upload_folder)
# 4) Delete the company; cascades will delete users/displays/playlists/items. # 4) Delete the company; cascades will delete users/displays/playlists/items.
company_name = company.name company_name = company.name
db.session.delete(company) db.session.delete(company)

View File

@@ -6,7 +6,8 @@ import time
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
from ..extensions import db from ..extensions import db
from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
from ..uploads import is_valid_upload_relpath
bp = Blueprint("api", __name__, url_prefix="/api") bp = Blueprint("api", __name__, url_prefix="/api")
@@ -173,6 +174,13 @@ def display_playlist(token: str):
if not display: if not display:
abort(404) abort(404)
# 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)
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions. # Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
# Player sends a stable `sid` via querystring. # Player sends a stable `sid` via querystring.
sid = request.args.get("sid") sid = request.args.get("sid")
@@ -200,6 +208,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,
"playlists": [], "playlists": [],
"items": [], "items": [],
} }
@@ -263,6 +272,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,
"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,
} }

View File

@@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
# Overlay is a transparent PNG that sits on top of a display.
ALLOWED_OVERLAY_EXTENSIONS = {".png"}
# Keep overlay size reasonable; it will be stretched to fit anyway.
# (PNG overlays are typically small-ish; 10MB is generous.)
MAX_OVERLAY_BYTES = 10 * 1024 * 1024
# Videos should have a maximum upload size of 250MB # Videos should have a maximum upload size of 250MB
MAX_VIDEO_BYTES = 250 * 1024 * 1024 MAX_VIDEO_BYTES = 250 * 1024 * 1024
@@ -175,6 +182,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
# Ignore cleanup failures # Ignore cleanup failures
pass pass
def _save_overlay_png(
uploaded_file,
upload_root: str,
company_id: int | None,
) -> str:
"""Save a company overlay as PNG under the company's upload dir.
Returns relative file path under /static (uploads/<company_id>/overlay_<uuid>.png)
"""
unique = f"overlay_{uuid.uuid4().hex}.png"
company_dir = ensure_company_upload_dir(upload_root, company_id)
save_path = os.path.join(company_dir, unique)
# Validate file is a PNG and is 16:9-ish.
# Use magic bytes (signature) instead of relying on Pillow's img.format,
# which can be unreliable if the stream position isn't at 0.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
try:
sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8)
except Exception:
sig = b""
# PNG file signature: 89 50 4E 47 0D 0A 1A 0A
if sig != b"\x89PNG\r\n\x1a\n":
raise ValueError("not_png")
# Rewind before Pillow parses.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
img = Image.open(uploaded_file)
img = ImageOps.exif_transpose(img)
w, h = img.size
if not w or not h:
raise ValueError("invalid")
# Allow some tolerance (overlays may include extra transparent padding).
aspect = w / h
target = 16 / 9
if abs(aspect - target) > 0.15: # ~15% tolerance
raise ValueError("not_16_9")
# Ensure we preserve alpha; normalize mode.
if img.mode not in ("RGBA", "LA"):
# Convert to RGBA so transparency is supported consistently.
img = img.convert("RGBA")
img.save(save_path, format="PNG", optimize=True)
company_seg = str(int(company_id)) if company_id is not None else "0"
return f"uploads/{company_seg}/{unique}"
bp = Blueprint("company", __name__, url_prefix="/company") bp = Blueprint("company", __name__, url_prefix="/company")
@@ -276,10 +345,15 @@ def my_company():
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all() users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
overlay_url = None
if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template( return render_template(
"company/my_company.html", "company/my_company.html",
company=company, company=company,
users=users, users=users,
overlay_url=overlay_url,
stats={ stats={
"users": user_count, "users": user_count,
"displays": display_count, "displays": display_count,
@@ -295,6 +369,111 @@ def my_company():
) )
@bp.post("/my-company/overlay")
@login_required
def upload_company_overlay():
"""Upload/replace the per-company 16:9 PNG overlay."""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
f = request.files.get("overlay")
if not f or not f.filename:
flash("Overlay file is required", "danger")
return redirect(url_for("company.my_company"))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_OVERLAY_EXTENSIONS:
flash("Unsupported overlay type. Please upload a PNG file.", "danger")
return redirect(url_for("company.my_company"))
# Enforce size limit best-effort.
size = None
try:
size = getattr(f, "content_length", None)
if (size is None or size <= 0) and hasattr(f, "stream"):
pos = f.stream.tell()
f.stream.seek(0, os.SEEK_END)
size = f.stream.tell()
f.stream.seek(pos, os.SEEK_SET)
except Exception:
size = None
if size is not None and size > MAX_OVERLAY_BYTES:
flash("Overlay file too large. Maximum allowed size is 10MB.", "danger")
return redirect(url_for("company.my_company"))
# Enforce storage quota too (overlay is stored in the same uploads folder).
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
if usage.get("is_exceeded"):
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
old_path = company.overlay_file_path
try:
new_relpath = _save_overlay_png(f, upload_root, company.id)
except ValueError as e:
code = str(e)
if code == "not_png":
flash("Overlay must be a PNG file.", "danger")
elif code == "not_16_9":
flash("Overlay should be 16:9 (landscape).", "danger")
else:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
except Exception:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
# Post-save quota check (like images) because PNG size is unknown until saved.
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
try:
used_after = get_company_upload_bytes(upload_root, company.id)
except Exception:
used_after = None
if used_after is not None:
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
_try_delete_upload(new_relpath, upload_root)
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
company.overlay_file_path = new_relpath
db.session.commit()
# Clean up the old overlay file.
if old_path and old_path != new_relpath:
_try_delete_upload(old_path, upload_root)
flash("Overlay updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay/delete")
@login_required
def delete_company_overlay():
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
old_path = company.overlay_file_path
company.overlay_file_path = None
db.session.commit()
_try_delete_upload(old_path, upload_root)
flash("Overlay removed.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/invite") @bp.post("/my-company/invite")
@login_required @login_required
def invite_user(): def invite_user():
@@ -1004,6 +1183,26 @@ def update_display(display_id: int):
# Form POST implies full update # Form POST implies full update
display.transition = _normalize_transition(request.form.get("transition")) display.transition = _normalize_transition(request.form.get("transition"))
# Overlay toggle
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "show_overlay" in payload:
raw = payload.get("show_overlay")
# Accept common truthy representations.
if isinstance(raw, bool):
display.show_overlay = raw
elif raw in (1, 0):
display.show_overlay = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.show_overlay = s in {"1", "true", "yes", "on"}
else:
# Form POST implies full update
raw = request.form.get("show_overlay")
if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Playlist assignment # Playlist assignment
if request.is_json: if request.is_json:
if "playlist_id" in payload: if "playlist_id" in payload:
@@ -1044,6 +1243,7 @@ def update_display(display_id: int):
"name": display.name, "name": display.name,
"description": display.description, "description": display.description,
"transition": display.transition, "transition": display.transition,
"show_overlay": bool(display.show_overlay),
"assigned_playlist_id": display.assigned_playlist_id, "assigned_playlist_id": display.assigned_playlist_id,
}, },
} }

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, abort, render_template from flask import Blueprint, abort, render_template, url_for
from ..models import Display from ..models import Company, Display
from ..uploads import is_valid_upload_relpath
bp = Blueprint("display", __name__, url_prefix="/display") bp = Blueprint("display", __name__, url_prefix="/display")
@@ -10,4 +11,11 @@ def display_player(token: str):
display = Display.query.filter_by(token=token).first() display = Display.query.filter_by(token=token).first()
if not display: if not display:
abort(404) abort(404)
return render_template("display/player.html", display=display)
overlay_url = 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_url = url_for("static", filename=company.overlay_file_path)
return render_template("display/player.html", display=display, overlay_url=overlay_url)

View File

@@ -96,6 +96,7 @@
data-display-name="{{ d.name }}" data-display-name="{{ d.name }}"
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-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(',') }}"
> >
@@ -157,6 +158,12 @@
</select> </select>
<div class="form-text">Applied on the display when switching between playlist items.</div> <div class="form-text">Applied on the display when switching between playlist items.</div>
</div> </div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="editPlaylistsShowOverlayCheck" />
<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>
<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>
@@ -301,6 +308,7 @@
const plDescInputEl = document.getElementById('editPlaylistsDescInput'); const plDescInputEl = document.getElementById('editPlaylistsDescInput');
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');
let activePlDisplayId = null; let activePlDisplayId = null;
let activePlButton = null; let activePlButton = null;
@@ -365,6 +373,11 @@
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase(); const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none'; if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
if (plShowOverlayEl) {
const raw = (btn.dataset.currentShowOverlay || '').toLowerCase();
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
const selected = computeActiveIdsFromDataset(btn); const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected); renderPlaylistCheckboxes(selected);
if (plHintEl) { if (plHintEl) {
@@ -379,11 +392,12 @@
const ids = getSelectedPlaylistIdsFromModal(); const ids = getSelectedPlaylistIdsFromModal();
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;
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 }) postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay })
]); ]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids) const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
@@ -401,6 +415,11 @@
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition; const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
activePlButton.dataset.currentTransition = newTransition || 'none'; activePlButton.dataset.currentTransition = newTransition || 'none';
const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined'
? !!updatedDesc.show_overlay
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
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

@@ -90,6 +90,50 @@
</div> </div>
</div> </div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</div>
</div>
<div class="card card-elevated mt-4"> <div class="card card-elevated mt-4">
<div class="card-header"> <div class="card-header">
<h2 class="h5 mb-0">Users</h2> <h2 class="h5 mb-0">Users</h2>

View File

@@ -8,6 +8,17 @@
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; }
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 5;
object-fit: contain;
}
/* Slide transitions (applied by JS via classes) */ /* Slide transitions (applied by JS via classes) */
#stage .slide { #stage .slide {
position: absolute; position: absolute;
@@ -99,9 +110,13 @@
</div> </div>
</div> </div>
<div id="stage"></div> <div id="stage"></div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
<script> <script>
const token = "{{ display.token }}"; const token = "{{ display.token }}";
const stage = document.getElementById('stage'); const stage = document.getElementById('stage');
let overlayEl = document.getElementById('overlay');
const noticeEl = document.getElementById('notice'); const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle'); const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText'); const noticeTextEl = document.getElementById('noticeText');
@@ -164,6 +179,38 @@
stage.innerHTML = ''; stage.innerHTML = '';
} }
function setOverlaySrc(src) {
const val = (src || '').trim();
if (!val) {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
return;
}
if (!overlayEl) {
overlayEl = document.createElement('img');
overlayEl.id = 'overlay';
overlayEl.alt = 'Overlay';
document.body.appendChild(overlayEl);
}
// Cache-bust in preview mode so changes show up instantly.
if (isPreview) {
try {
const u = new URL(val, window.location.origin);
u.searchParams.set('_ts', String(Date.now()));
overlayEl.src = u.toString();
return;
} catch(e) {
// fallthrough
}
}
overlayEl.src = val;
}
// Initialize overlay from server-side render.
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
function setSlideContent(container, item) { function setSlideContent(container, item) {
if (item.type === 'image') { if (item.type === 'image') {
const el = document.createElement('img'); const el = document.createElement('img');
@@ -272,6 +319,7 @@
playlist = await fetchPlaylist(); playlist = await fetchPlaylist();
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
next(); next();
} catch (e) { } catch (e) {
clearStage(); clearStage();
@@ -300,6 +348,7 @@
const oldStr = JSON.stringify(playlist); const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist); const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist; playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
if (oldStr !== newStr) { if (oldStr !== newStr) {
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));