Compare commits
2 Commits
f4b7fb62f5
...
v1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 56760e380d | |||
| 47aca9d64d |
@@ -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 = [
|
||||||
|
|||||||
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.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)"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -319,46 +319,33 @@
|
|||||||
<input type="hidden" name="item_type" id="item_type" value="image" />
|
<input type="hidden" name="item_type" id="item_type" value="image" />
|
||||||
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
||||||
|
|
||||||
<div class="mb-2">
|
{# Step 1: pick type, then show relevant inputs #}
|
||||||
<label class="form-label">Title (optional)</label>
|
<div id="step-type" class="step active">
|
||||||
<input class="form-control" name="title" />
|
<div class="text-muted small mb-2">Select a slide type.</div>
|
||||||
</div>
|
<div class="mb-2">
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Slide type">
|
||||||
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="type-image">Image</label>
|
||||||
|
|
||||||
<div class="mb-2" id="duration-group">
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
|
||||||
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
|
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
|
||||||
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
|
||||||
<label class="form-label">Type</label>
|
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
||||||
<div class="btn-group w-100" role="group" aria-label="Item type">
|
</div>
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
|
|
||||||
<label class="btn btn-outline-primary" for="type-image">Image</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-youtube" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="type-youtube">YouTube</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" id="crop-mode-group">
|
{# Step 2 (only for video): choose upload vs YouTube #}
|
||||||
<label class="form-label">Image crop</label>
|
<div id="step-video-source" class="step">
|
||||||
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
<div class="text-muted small mb-2">Choose how you want to add the video.</div>
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
<div class="btn-group w-100" role="group" aria-label="Video source">
|
||||||
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-upload" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="video-source-upload">Upload video</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-youtube" autocomplete="off">
|
||||||
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
<label class="btn btn-outline-primary" for="video-source-youtube">YouTube</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -393,8 +380,33 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="step-select" class="step active">
|
<div id="step-input" class="step">
|
||||||
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, you’ll crop it next.</div>
|
<div class="text-muted small mb-2" id="step-input-hint">Fill in the details for the new slide.</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Title (optional)</label>
|
||||||
|
<input class="form-control" name="title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2" id="duration-group">
|
||||||
|
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
|
||||||
|
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crop-mode-group">
|
||||||
|
<label class="form-label">Image crop</label>
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Image section #}
|
{# Image section #}
|
||||||
<div id="section-image" class="item-type-section">
|
<div id="section-image" class="item-type-section">
|
||||||
@@ -433,7 +445,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# YouTube section #}
|
{# YouTube section (also used as video source) #}
|
||||||
<div id="section-youtube" class="item-type-section d-none">
|
<div id="section-youtube" class="item-type-section d-none">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">YouTube URL</label>
|
<label class="form-label">YouTube URL</label>
|
||||||
@@ -445,9 +457,9 @@
|
|||||||
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
|
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Video section #}
|
{# Video upload section #}
|
||||||
<div id="section-video" class="item-type-section d-none">
|
<div id="section-video" class="item-type-section d-none">
|
||||||
<label class="form-label">Video</label>
|
<label class="form-label">Video upload</label>
|
||||||
<div id="video-dropzone" class="dropzone mb-2">
|
<div id="video-dropzone" class="dropzone mb-2">
|
||||||
<div><strong>Drag & drop</strong> a video here</div>
|
<div><strong>Drag & drop</strong> a video here</div>
|
||||||
<div class="text-muted small">or click to select a file</div>
|
<div class="text-muted small">or click to select a file</div>
|
||||||
@@ -636,53 +648,87 @@
|
|||||||
const sectionYoutube = document.getElementById('section-youtube');
|
const sectionYoutube = document.getElementById('section-youtube');
|
||||||
const sectionVideo = document.getElementById('section-video');
|
const sectionVideo = document.getElementById('section-video');
|
||||||
|
|
||||||
const stepSelect = document.getElementById('step-select');
|
const stepType = document.getElementById('step-type');
|
||||||
|
const stepVideoSource = document.getElementById('step-video-source');
|
||||||
|
const stepInput = document.getElementById('step-input');
|
||||||
const stepCrop = document.getElementById('step-crop');
|
const stepCrop = document.getElementById('step-crop');
|
||||||
const backBtn = document.getElementById('add-item-back');
|
const backBtn = document.getElementById('add-item-back');
|
||||||
const errorEl = document.getElementById('add-item-error');
|
const errorEl = document.getElementById('add-item-error');
|
||||||
|
const stepInputHint = document.getElementById('step-input-hint');
|
||||||
|
|
||||||
function setError(msg) {
|
function setError(msg) {
|
||||||
if (!errorEl) return;
|
if (!errorEl) return;
|
||||||
errorEl.textContent = (msg || '').trim();
|
errorEl.textContent = (msg || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentStep = 'type';
|
||||||
|
|
||||||
|
function updatePrimaryButton() {
|
||||||
|
// Primary button acts as Next in early steps, and Add in the final steps.
|
||||||
|
if (!submitBtn) return;
|
||||||
|
const isNextStep = (currentStep === 'type' || currentStep === 'video-source');
|
||||||
|
submitBtn.textContent = isNextStep ? 'Next' : 'Add';
|
||||||
|
}
|
||||||
|
|
||||||
function showStep(which) {
|
function showStep(which) {
|
||||||
stepSelect?.classList.toggle('active', which === 'select');
|
currentStep = which;
|
||||||
|
stepType?.classList.toggle('active', which === 'type');
|
||||||
|
stepVideoSource?.classList.toggle('active', which === 'video-source');
|
||||||
|
stepInput?.classList.toggle('active', which === 'input');
|
||||||
stepCrop?.classList.toggle('active', which === 'crop');
|
stepCrop?.classList.toggle('active', which === 'crop');
|
||||||
|
|
||||||
const isCrop = which === 'crop';
|
// Back is enabled for all steps except the first.
|
||||||
backBtn.disabled = !isCrop;
|
backBtn.disabled = (which === 'type');
|
||||||
|
|
||||||
// For image: allow Add only in crop step (so we always crop if image)
|
// Enable Next for the initial steps.
|
||||||
if (typeHidden.value === 'image') {
|
if (which === 'type' || which === 'video-source') {
|
||||||
submitBtn.disabled = !isCrop;
|
submitBtn.disabled = false;
|
||||||
|
updatePrimaryButton();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For input/crop steps: image requires crop step before enabling Add.
|
||||||
|
if (typeHidden.value === 'image') {
|
||||||
|
submitBtn.disabled = (which !== 'crop');
|
||||||
|
} else {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePrimaryButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSource() {
|
||||||
|
return document.getElementById('video-source-youtube')?.checked ? 'youtube' : 'upload';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setType(t) {
|
function setType(t) {
|
||||||
|
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
|
||||||
typeHidden.value = t;
|
typeHidden.value = t;
|
||||||
setError('');
|
setError('');
|
||||||
sectionImage.classList.toggle('d-none', t !== 'image');
|
|
||||||
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
|
||||||
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
|
||||||
sectionVideo.classList.toggle('d-none', t !== 'video');
|
|
||||||
// duration applies to image/webpage/youtube. Video plays until ended.
|
|
||||||
durationGroup.classList.toggle('d-none', t === 'video');
|
|
||||||
cropModeGroup?.classList.toggle('d-none', t !== 'image');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.title = '';
|
|
||||||
|
|
||||||
if (t !== 'image') {
|
// Visible section is decided by type + (video source)
|
||||||
destroyCropper();
|
const vs = (t === 'video') ? videoSource() : null;
|
||||||
showStep('select');
|
const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
|
||||||
backBtn.disabled = true;
|
typeHidden.value = effectiveType;
|
||||||
|
|
||||||
|
sectionImage.classList.toggle('d-none', effectiveType !== 'image');
|
||||||
|
sectionWebpage.classList.toggle('d-none', effectiveType !== 'webpage');
|
||||||
|
sectionYoutube.classList.toggle('d-none', effectiveType !== 'youtube');
|
||||||
|
sectionVideo.classList.toggle('d-none', effectiveType !== 'video');
|
||||||
|
|
||||||
|
// duration applies to image/webpage/youtube. Video upload plays until ended.
|
||||||
|
durationGroup.classList.toggle('d-none', effectiveType === 'video');
|
||||||
|
cropModeGroup?.classList.toggle('d-none', effectiveType !== 'image');
|
||||||
|
|
||||||
|
if (stepInputHint) {
|
||||||
|
if (effectiveType === 'image') stepInputHint.textContent = 'Select an image. After selecting, you\'ll crop it.';
|
||||||
|
else if (effectiveType === 'webpage') stepInputHint.textContent = 'Enter a webpage URL.';
|
||||||
|
else if (effectiveType === 'youtube') stepInputHint.textContent = 'Paste a YouTube URL.';
|
||||||
|
else stepInputHint.textContent = 'Upload a video file.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// For images we enforce crop step before allowing submit.
|
// Reset cropper when leaving image.
|
||||||
if (t === 'image') {
|
if (effectiveType !== 'image') destroyCropper();
|
||||||
submitBtn.disabled = true;
|
|
||||||
backBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCropMode() {
|
function currentCropMode() {
|
||||||
@@ -705,10 +751,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
document.getElementById('type-image')?.addEventListener('change', () => {
|
||||||
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
setType('image');
|
||||||
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
// Stay on type step; user clicks Next.
|
||||||
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
|
showStep('type');
|
||||||
|
});
|
||||||
|
document.getElementById('type-webpage')?.addEventListener('change', () => {
|
||||||
|
setType('webpage');
|
||||||
|
// Stay on type step; user clicks Next.
|
||||||
|
showStep('type');
|
||||||
|
});
|
||||||
|
document.getElementById('type-video')?.addEventListener('change', () => {
|
||||||
|
// We show an intermediate step for video so user chooses upload vs YouTube.
|
||||||
|
// Keep item_type unset until that choice is made.
|
||||||
|
setError('');
|
||||||
|
destroyCropper();
|
||||||
|
// Hide crop/duration while selecting source (they depend on source).
|
||||||
|
cropModeGroup?.classList.add('d-none');
|
||||||
|
durationGroup?.classList.add('d-none');
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('video-source-upload')?.addEventListener('change', () => {
|
||||||
|
// effective type becomes "video"
|
||||||
|
setType('video');
|
||||||
|
// Stay on source step; user clicks Next.
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
document.getElementById('video-source-youtube')?.addEventListener('change', () => {
|
||||||
|
// effective type becomes "youtube"
|
||||||
|
setType('video');
|
||||||
|
// Stay on source step; user clicks Next.
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Image: drag/drop + crop
|
// Image: drag/drop + crop
|
||||||
@@ -754,7 +829,7 @@
|
|||||||
cropStatus.textContent = '';
|
cropStatus.textContent = '';
|
||||||
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
|
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
|
||||||
|
|
||||||
// Move to crop step after image selection (requested behavior)
|
// Move to crop step after image selection
|
||||||
showStep('crop');
|
showStep('crop');
|
||||||
|
|
||||||
// Wait for image to be ready
|
// Wait for image to be ready
|
||||||
@@ -991,15 +1066,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset modal state + close
|
// Reset modal state + close
|
||||||
form.reset();
|
resetModalState();
|
||||||
typeHidden.value = 'image';
|
|
||||||
document.getElementById('type-image')?.click();
|
|
||||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
|
||||||
document.getElementById('crop-16-9')?.click();
|
|
||||||
destroyCropper();
|
|
||||||
showStep('select');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
resetVideoProgress();
|
|
||||||
modal?.hide();
|
modal?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,32 +1165,87 @@
|
|||||||
if (t === 'webpage') {
|
if (t === 'webpage') {
|
||||||
// Keep preview behavior
|
// Keep preview behavior
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
} else {
|
return;
|
||||||
// Hide webpage preview if not active
|
|
||||||
preview?.classList.add('d-none');
|
|
||||||
if (iframe) iframe.src = 'about:blank';
|
|
||||||
if (openLink) openLink.href = '#';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide webpage preview if not active
|
||||||
|
preview?.classList.add('d-none');
|
||||||
|
if (iframe) iframe.src = 'about:blank';
|
||||||
|
if (openLink) openLink.href = '#';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial state
|
function resetModalState() {
|
||||||
setType('image');
|
setError('');
|
||||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
try { form.reset(); } catch (e) {}
|
||||||
showStep('select');
|
destroyCropper();
|
||||||
syncEnabledInputs();
|
|
||||||
updateCropHint();
|
// Default selections (without triggering change handlers)
|
||||||
|
const typeImage = document.getElementById('type-image');
|
||||||
|
const typeWebpage = document.getElementById('type-webpage');
|
||||||
|
const typeVideo = document.getElementById('type-video');
|
||||||
|
if (typeImage) typeImage.checked = true;
|
||||||
|
if (typeWebpage) typeWebpage.checked = false;
|
||||||
|
if (typeVideo) typeVideo.checked = false;
|
||||||
|
|
||||||
|
const vsUpload = document.getElementById('video-source-upload');
|
||||||
|
const vsYoutube = document.getElementById('video-source-youtube');
|
||||||
|
if (vsUpload) vsUpload.checked = true;
|
||||||
|
if (vsYoutube) vsYoutube.checked = false;
|
||||||
|
|
||||||
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
|
document.getElementById('crop-16-9')?.click();
|
||||||
|
|
||||||
|
// Set UI for default type, but start at type selection step
|
||||||
|
setType('image');
|
||||||
|
showStep('type');
|
||||||
|
syncEnabledInputs();
|
||||||
|
updateCropHint();
|
||||||
|
|
||||||
|
// Also reset video upload progress UI if present
|
||||||
|
try {
|
||||||
|
const bar = document.getElementById('video-upload-progress-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = '0%';
|
||||||
|
bar.setAttribute('aria-valuenow', '0');
|
||||||
|
}
|
||||||
|
document.getElementById('video-upload-progress-text') && (document.getElementById('video-upload-progress-text').textContent = 'Uploading…');
|
||||||
|
document.getElementById('video-upload-progress')?.classList.add('d-none');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize modal state once on page load
|
||||||
|
resetModalState();
|
||||||
|
|
||||||
// Modal open
|
// Modal open
|
||||||
openBtn?.addEventListener('click', () => {
|
openBtn?.addEventListener('click', () => {
|
||||||
|
// Always start from the beginning.
|
||||||
|
resetModalState();
|
||||||
modal?.show();
|
modal?.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back button: only relevant for image crop step
|
// Back button: stepwise navigation
|
||||||
backBtn?.addEventListener('click', () => {
|
backBtn?.addEventListener('click', () => {
|
||||||
if (typeHidden.value === 'image') {
|
if (currentStep === 'crop') {
|
||||||
showStep('select');
|
// Going back from crop returns to input for image
|
||||||
|
showStep('input');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
backBtn.disabled = true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'input') {
|
||||||
|
// If top-level selected is video, go back to video source selection.
|
||||||
|
const isTopLevelVideo = document.getElementById('type-video')?.checked;
|
||||||
|
if (isTopLevelVideo) {
|
||||||
|
showStep('video-source');
|
||||||
|
} else {
|
||||||
|
showStep('type');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'video-source') {
|
||||||
|
showStep('type');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1165,13 +1287,43 @@
|
|||||||
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
||||||
|
|
||||||
// Whenever type changes, keep enabled inputs in sync
|
// Whenever type changes, keep enabled inputs in sync
|
||||||
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
['type-image','type-webpage','type-video','video-source-upload','video-source-youtube'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add button
|
// Add button
|
||||||
submitBtn?.addEventListener('click', async () => {
|
submitBtn?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
// Multi-step behavior:
|
||||||
|
// - Type step: Next -> (video ? source step : input step)
|
||||||
|
// - Video source step: Next -> input step
|
||||||
|
// - Input/crop: Add -> submit
|
||||||
|
if (currentStep === 'type') {
|
||||||
|
const isVideo = document.getElementById('type-video')?.checked;
|
||||||
|
const isWebpage = document.getElementById('type-webpage')?.checked;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
// Hide crop/duration while selecting source.
|
||||||
|
cropModeGroup?.classList.add('d-none');
|
||||||
|
durationGroup?.classList.add('d-none');
|
||||||
|
showStep('video-source');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(isWebpage ? 'webpage' : 'image');
|
||||||
|
showStep('input');
|
||||||
|
syncEnabledInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'video-source') {
|
||||||
|
// Apply the chosen source (upload vs YouTube) and continue.
|
||||||
|
setType('video');
|
||||||
|
showStep('input');
|
||||||
|
syncEnabledInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await submitViaAjax();
|
await submitViaAjax();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user