Compare commits

...

2 Commits

Author SHA1 Message Date
56760e380d Release v1.3 2026-01-25 15:57:38 +01:00
47aca9d64d Update playlist detail UI (priority/schedule/add-item) 2026-01-25 13:50:22 +01:00
11 changed files with 615 additions and 98 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.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 = [

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.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)"))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
}

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_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,
},
}

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")
@@ -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)

View File

@@ -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();

View File

@@ -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>

View File

@@ -319,46 +319,33 @@
<input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
{# Step 1: pick type, then show relevant inputs #}
<div id="step-type" class="step active">
<div class="text-muted small mb-2">Select a slide type.</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">
<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>
<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>
<div class="mb-3">
<label class="form-label">Type</label>
<div class="btn-group w-100" role="group" aria-label="Item 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>
<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>
<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 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>
{# Step 2 (only for video): choose upload vs YouTube #}
<div id="step-video-source" class="step">
<div class="text-muted small mb-2">Choose how you want to add the video.</div>
<div class="btn-group w-100" role="group" aria-label="Video source">
<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">
<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>
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="video-source-youtube">YouTube</label>
</div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
<style>
@@ -393,8 +380,33 @@
}
</style>
<div id="step-select" class="step active">
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div>
<div id="step-input" class="step">
<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 #}
<div id="section-image" class="item-type-section">
@@ -433,7 +445,7 @@
</div>
</div>
{# YouTube section #}
{# YouTube section (also used as video source) #}
<div id="section-youtube" class="item-type-section d-none">
<div class="mb-2">
<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>
{# Video section #}
{# Video upload section #}
<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><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div>
@@ -636,53 +648,87 @@
const sectionYoutube = document.getElementById('section-youtube');
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 backBtn = document.getElementById('add-item-back');
const errorEl = document.getElementById('add-item-error');
const stepInputHint = document.getElementById('step-input-hint');
function setError(msg) {
if (!errorEl) return;
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) {
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');
const isCrop = which === 'crop';
backBtn.disabled = !isCrop;
// Back is enabled for all steps except the first.
backBtn.disabled = (which === 'type');
// For image: allow Add only in crop step (so we always crop if image)
if (typeHidden.value === 'image') {
submitBtn.disabled = !isCrop;
// Enable Next for the initial steps.
if (which === 'type' || which === 'video-source') {
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) {
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
typeHidden.value = t;
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') {
destroyCropper();
showStep('select');
backBtn.disabled = true;
// Visible section is decided by type + (video source)
const vs = (t === 'video') ? videoSource() : null;
const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
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.
if (t === 'image') {
submitBtn.disabled = true;
backBtn.disabled = true;
}
// Reset cropper when leaving image.
if (effectiveType !== 'image') destroyCropper();
}
function currentCropMode() {
@@ -705,10 +751,39 @@
}
}
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
document.getElementById('type-image')?.addEventListener('change', () => {
setType('image');
// Stay on type step; user clicks Next.
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
@@ -754,7 +829,7 @@
cropStatus.textContent = '';
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');
// Wait for image to be ready
@@ -991,15 +1066,7 @@
}
// Reset modal state + close
form.reset();
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();
resetModalState();
modal?.hide();
}
@@ -1098,32 +1165,87 @@
if (t === 'webpage') {
// Keep preview behavior
schedulePreview();
} else {
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
return;
}
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
}
// Set initial state
setType('image');
if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select');
syncEnabledInputs();
updateCropHint();
function resetModalState() {
setError('');
try { form.reset(); } catch (e) {}
destroyCropper();
// 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
openBtn?.addEventListener('click', () => {
// Always start from the beginning.
resetModalState();
modal?.show();
});
// Back button: only relevant for image crop step
// Back button: stepwise navigation
backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') {
showStep('select');
if (currentStep === 'crop') {
// Going back from crop returns to input for image
showStep('input');
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'));
// 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);
});
// Add button
submitBtn?.addEventListener('click', async () => {
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();
} catch (err) {
console.warn(err);

View File

@@ -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));