Release v1.3
This commit is contained in:
@@ -62,6 +62,13 @@ def create_app():
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
||||
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
|
||||
company_cols = [
|
||||
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.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.
|
||||
# (PRAGMA returns empty if the table doesn't exist.)
|
||||
settings_cols = [
|
||||
|
||||
12
app/cli.py
12
app/cli.py
@@ -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.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()]
|
||||
if "storage_max_bytes" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||
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()]
|
||||
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)"))
|
||||
|
||||
@@ -16,6 +16,10 @@ class Company(db.Model):
|
||||
# If NULL or <=0: unlimited.
|
||||
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")
|
||||
displays = db.relationship("Display", 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)
|
||||
# Transition animation between slides: none|fade|slide
|
||||
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)
|
||||
|
||||
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
||||
|
||||
@@ -395,6 +395,10 @@ def delete_company(company_id: int):
|
||||
if it.item_type in ("image", "video"):
|
||||
_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.
|
||||
company_name = company.name
|
||||
db.session.delete(company)
|
||||
|
||||
@@ -6,7 +6,8 @@ import time
|
||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||
|
||||
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")
|
||||
|
||||
@@ -173,6 +174,13 @@ def display_playlist(token: str):
|
||||
if not display:
|
||||
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.
|
||||
# Player sends a stable `sid` via querystring.
|
||||
sid = request.args.get("sid")
|
||||
@@ -200,6 +208,7 @@ def display_playlist(token: str):
|
||||
{
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"overlay_src": overlay_src,
|
||||
"playlists": [],
|
||||
"items": [],
|
||||
}
|
||||
@@ -263,6 +272,7 @@ def display_playlist(token: str):
|
||||
{
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"overlay_src": overlay_src,
|
||||
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
||||
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
|
||||
MAX_VIDEO_BYTES = 250 * 1024 * 1024
|
||||
|
||||
@@ -175,6 +182,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
|
||||
# Ignore cleanup failures
|
||||
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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
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(
|
||||
"company/my_company.html",
|
||||
company=company,
|
||||
users=users,
|
||||
overlay_url=overlay_url,
|
||||
stats={
|
||||
"users": user_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")
|
||||
@login_required
|
||||
def invite_user():
|
||||
@@ -1004,6 +1183,26 @@ def update_display(display_id: int):
|
||||
# Form POST implies full update
|
||||
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
|
||||
if request.is_json:
|
||||
if "playlist_id" in payload:
|
||||
@@ -1044,6 +1243,7 @@ def update_display(display_id: int):
|
||||
"name": display.name,
|
||||
"description": display.description,
|
||||
"transition": display.transition,
|
||||
"show_overlay": bool(display.show_overlay),
|
||||
"assigned_playlist_id": display.assigned_playlist_id,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,4 +11,11 @@ def display_player(token: str):
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
if not display:
|
||||
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)
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
data-display-name="{{ d.name }}"
|
||||
data-current-desc="{{ d.description or '' }}"
|
||||
data-current-transition="{{ d.transition or 'none' }}"
|
||||
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
|
||||
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
|
||||
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
|
||||
>
|
||||
@@ -157,6 +158,12 @@
|
||||
</select>
|
||||
<div class="form-text">Applied on the display when switching between playlist items.</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" />
|
||||
<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>
|
||||
@@ -301,6 +308,7 @@
|
||||
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
|
||||
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
|
||||
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
|
||||
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
|
||||
let activePlDisplayId = null;
|
||||
let activePlButton = null;
|
||||
|
||||
@@ -365,6 +373,11 @@
|
||||
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
|
||||
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);
|
||||
renderPlaylistCheckboxes(selected);
|
||||
if (plHintEl) {
|
||||
@@ -379,11 +392,12 @@
|
||||
const ids = getSelectedPlaylistIdsFromModal();
|
||||
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
|
||||
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
|
||||
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
|
||||
plSaveBtn.disabled = true;
|
||||
try {
|
||||
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
||||
postDisplayPlaylists(activePlDisplayId, ids),
|
||||
postDisplayUpdate(activePlDisplayId, { description: desc, transition })
|
||||
postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay })
|
||||
]);
|
||||
|
||||
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
|
||||
@@ -401,6 +415,11 @@
|
||||
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
|
||||
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');
|
||||
refreshPreviewIframe(activePlDisplayId);
|
||||
if (plModal) plModal.hide();
|
||||
|
||||
@@ -90,6 +90,50 @@
|
||||
</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-header">
|
||||
<h2 class="h5 mb-0">Users</h2>
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||
#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) */
|
||||
#stage .slide {
|
||||
position: absolute;
|
||||
@@ -99,9 +110,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="stage"></div>
|
||||
{% if overlay_url %}
|
||||
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
|
||||
{% endif %}
|
||||
<script>
|
||||
const token = "{{ display.token }}";
|
||||
const stage = document.getElementById('stage');
|
||||
let overlayEl = document.getElementById('overlay');
|
||||
const noticeEl = document.getElementById('notice');
|
||||
const noticeTitleEl = document.getElementById('noticeTitle');
|
||||
const noticeTextEl = document.getElementById('noticeText');
|
||||
@@ -164,6 +179,38 @@
|
||||
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) {
|
||||
if (item.type === 'image') {
|
||||
const el = document.createElement('img');
|
||||
@@ -272,6 +319,7 @@
|
||||
playlist = await fetchPlaylist();
|
||||
idx = 0;
|
||||
applyTransitionClass(getTransitionMode(playlist));
|
||||
setOverlaySrc(playlist && playlist.overlay_src);
|
||||
next();
|
||||
} catch (e) {
|
||||
clearStage();
|
||||
@@ -300,6 +348,7 @@
|
||||
const oldStr = JSON.stringify(playlist);
|
||||
const newStr = JSON.stringify(newPlaylist);
|
||||
playlist = newPlaylist;
|
||||
setOverlaySrc(playlist && playlist.overlay_src);
|
||||
if (oldStr !== newStr) {
|
||||
idx = 0;
|
||||
applyTransitionClass(getTransitionMode(playlist));
|
||||
|
||||
Reference in New Issue
Block a user