Initial import
This commit is contained in:
@@ -57,6 +57,11 @@ def create_app():
|
|||||||
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()
|
||||||
|
|
||||||
|
# Displays: optional transition between slides (none|fade|slide)
|
||||||
|
if "transition" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
||||||
|
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()
|
||||||
@@ -128,6 +133,22 @@ def create_app():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Playlists: schedule + priority flags
|
||||||
|
playlist_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(playlist)")).fetchall()
|
||||||
|
]
|
||||||
|
if "schedule_start" not in playlist_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_start DATETIME"))
|
||||||
|
db.session.commit()
|
||||||
|
if "schedule_end" not in playlist_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_end DATETIME"))
|
||||||
|
db.session.commit()
|
||||||
|
if "is_priority" not in playlist_cols:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("ALTER TABLE playlist ADD COLUMN is_priority BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ class Playlist(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
|
||||||
|
# Optional schedule window in UTC.
|
||||||
|
# - If both are NULL: playlist is always active.
|
||||||
|
# - If start is set: playlist is active from start onward.
|
||||||
|
# - If end is set: playlist is active until end.
|
||||||
|
schedule_start = db.Column(db.DateTime, nullable=True)
|
||||||
|
schedule_end = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# If true, this playlist's items take precedence over non-priority playlists
|
||||||
|
# when multiple playlists are assigned to a display.
|
||||||
|
is_priority = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
company = db.relationship("Company", back_populates="playlists")
|
company = db.relationship("Company", back_populates="playlists")
|
||||||
@@ -88,6 +100,8 @@ class Display(db.Model):
|
|||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
# Optional short description (e.g. "entrance", "office")
|
# Optional short description (e.g. "entrance", "office")
|
||||||
description = db.Column(db.String(200), nullable=True)
|
description = db.Column(db.String(200), nullable=True)
|
||||||
|
# Transition animation between slides: none|fade|slide
|
||||||
|
transition = db.Column(db.String(20), nullable=True)
|
||||||
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)
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
|||||||
SESSION_TTL_SECONDS = 90
|
SESSION_TTL_SECONDS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
||||||
|
"""Return True if playlist is active based on its optional schedule window."""
|
||||||
|
|
||||||
|
if p.schedule_start and now_utc < p.schedule_start:
|
||||||
|
return False
|
||||||
|
if p.schedule_end and now_utc > p.schedule_end:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _enforce_and_touch_display_session(display: Display, sid: str | None):
|
def _enforce_and_touch_display_session(display: Display, sid: str | None):
|
||||||
"""Enforce concurrent display viewer limit and touch last_seen.
|
"""Enforce concurrent display viewer limit and touch last_seen.
|
||||||
|
|
||||||
@@ -103,6 +113,18 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
|||||||
raw = "no-playlist"
|
raw = "no-playlist"
|
||||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
# Apply scheduling + priority rule so a schedule change triggers a player refresh.
|
||||||
|
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||||
|
if any(p.is_priority for p in scheduled):
|
||||||
|
scheduled = [p for p in scheduled if p.is_priority]
|
||||||
|
active_ids = [x for x in active_ids if any(p.id == x for p in scheduled)]
|
||||||
|
|
||||||
|
if not active_ids:
|
||||||
|
raw = "no-active-playlist"
|
||||||
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# Pull items in a stable order so reordering affects signature.
|
# Pull items in a stable order so reordering affects signature.
|
||||||
if use_mapping:
|
if use_mapping:
|
||||||
items = (
|
items = (
|
||||||
@@ -174,11 +196,30 @@ def display_playlist(token: str):
|
|||||||
use_mapping = False
|
use_mapping = False
|
||||||
|
|
||||||
if not active_ids:
|
if not active_ids:
|
||||||
return jsonify({"display": display.name, "playlists": [], "items": []})
|
return jsonify(
|
||||||
|
{
|
||||||
|
"display": display.name,
|
||||||
|
"transition": display.transition or "none",
|
||||||
|
"playlists": [],
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||||
pl_by_id = {p.id: p for p in playlists}
|
|
||||||
ordered_playlists = [pl_by_id[x] for x in active_ids if x in pl_by_id]
|
# Filter playlists by schedule
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||||
|
|
||||||
|
# Priority rule:
|
||||||
|
# If any active (scheduled) playlist is marked priority, only play priority playlists.
|
||||||
|
any_priority = any(p.is_priority for p in scheduled)
|
||||||
|
if any_priority:
|
||||||
|
scheduled = [p for p in scheduled if p.is_priority]
|
||||||
|
|
||||||
|
pl_by_id = {p.id: p for p in scheduled}
|
||||||
|
scheduled_ids = [x for x in active_ids if x in pl_by_id]
|
||||||
|
ordered_playlists = [pl_by_id[x] for x in scheduled_ids]
|
||||||
|
|
||||||
# Merge items across active playlists.
|
# Merge items across active playlists.
|
||||||
if use_mapping:
|
if use_mapping:
|
||||||
@@ -186,17 +227,21 @@ def display_playlist(token: str):
|
|||||||
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
|
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
|
||||||
.filter(
|
.filter(
|
||||||
DisplayPlaylist.display_id == display.id,
|
DisplayPlaylist.display_id == display.id,
|
||||||
PlaylistItem.playlist_id.in_(active_ids),
|
PlaylistItem.playlist_id.in_(scheduled_ids),
|
||||||
)
|
)
|
||||||
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
merged = (
|
# single-playlist fallback; apply schedule filter too.
|
||||||
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
|
if scheduled_ids:
|
||||||
.order_by(PlaylistItem.position.asc())
|
merged = (
|
||||||
.all()
|
PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
|
||||||
)
|
.order_by(PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
merged = []
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for item in merged:
|
for item in merged:
|
||||||
@@ -217,6 +262,7 @@ def display_playlist(token: str):
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"display": display.name,
|
"display": display.name,
|
||||||
|
"transition": display.transition or "none",
|
||||||
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,39 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
|
|||||||
bp = Blueprint("company", __name__, url_prefix="/company")
|
bp = Blueprint("company", __name__, url_prefix="/company")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_schedule_local_to_utc(*, date_str: str | None, time_str: str | None) -> datetime | None:
|
||||||
|
"""Parse local date+time form inputs into a naive UTC datetime.
|
||||||
|
|
||||||
|
Inputs come from <input type="date"> and <input type="time">.
|
||||||
|
We interpret them as *local* time of the server.
|
||||||
|
|
||||||
|
Note: this project currently does not store per-company timezone; in most deployments
|
||||||
|
server timezone matches users. If you need per-company timezone later, we can extend
|
||||||
|
this function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
d = (date_str or "").strip()
|
||||||
|
t = (time_str or "").strip()
|
||||||
|
if not d and not t:
|
||||||
|
return None
|
||||||
|
if not d or not t:
|
||||||
|
# Require both parts for clarity
|
||||||
|
raise ValueError("Both date and time are required")
|
||||||
|
|
||||||
|
# Basic parsing: YYYY-MM-DD and HH:MM
|
||||||
|
try:
|
||||||
|
year, month, day = [int(x) for x in d.split("-")]
|
||||||
|
hh, mm = [int(x) for x in t.split(":")[:2]]
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid date/time")
|
||||||
|
|
||||||
|
# Interpret as local time, convert to UTC naive
|
||||||
|
local_dt = datetime(year, month, day, hh, mm)
|
||||||
|
# local_dt.timestamp() uses local timezone when naive.
|
||||||
|
utc_ts = local_dt.timestamp()
|
||||||
|
return datetime.utcfromtimestamp(utc_ts)
|
||||||
|
|
||||||
|
|
||||||
def company_user_required():
|
def company_user_required():
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
abort(403)
|
abort(403)
|
||||||
@@ -347,6 +380,7 @@ def dashboard():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"company/dashboard.html",
|
"company/dashboard.html",
|
||||||
playlists=playlists,
|
playlists=playlists,
|
||||||
|
now_utc=datetime.utcnow(),
|
||||||
playlists_json=playlists_json,
|
playlists_json=playlists_json,
|
||||||
displays=displays,
|
displays=displays,
|
||||||
)
|
)
|
||||||
@@ -374,7 +408,7 @@ def playlist_detail(playlist_id: int):
|
|||||||
playlist = db.session.get(Playlist, playlist_id)
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
if not playlist or playlist.company_id != current_user.company_id:
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template("company/playlist_detail.html", playlist=playlist)
|
return render_template("company/playlist_detail.html", playlist=playlist, now_utc=datetime.utcnow())
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/playlists/<int:playlist_id>")
|
@bp.post("/playlists/<int:playlist_id>")
|
||||||
@@ -405,6 +439,89 @@ def update_playlist(playlist_id: int):
|
|||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/schedule")
|
||||||
|
@login_required
|
||||||
|
def update_playlist_schedule(playlist_id: int):
|
||||||
|
"""Update playlist schedule window + priority flag."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = _parse_schedule_local_to_utc(
|
||||||
|
date_str=request.form.get("schedule_start_date"),
|
||||||
|
time_str=request.form.get("schedule_start_time"),
|
||||||
|
)
|
||||||
|
end = _parse_schedule_local_to_utc(
|
||||||
|
date_str=request.form.get("schedule_end_date"),
|
||||||
|
time_str=request.form.get("schedule_end_time"),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(str(e), "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
if start and end and end < start:
|
||||||
|
flash("End must be after start", "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
playlist.schedule_start = start
|
||||||
|
playlist.schedule_end = end
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Schedule updated", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/schedule/delete")
|
||||||
|
@login_required
|
||||||
|
def clear_playlist_schedule(playlist_id: int):
|
||||||
|
"""Clear schedule for a playlist (sets start/end to NULL)."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
playlist.schedule_start = None
|
||||||
|
playlist.schedule_end = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("Schedule removed", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/priority")
|
||||||
|
@login_required
|
||||||
|
def update_playlist_priority(playlist_id: int):
|
||||||
|
"""Update playlist priority flag."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
wants_json = (
|
||||||
|
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
|
||||||
|
or ("application/json" in (request.headers.get("Accept") or ""))
|
||||||
|
or request.is_json
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accept both form and JSON payloads.
|
||||||
|
raw = request.form.get("is_priority")
|
||||||
|
if raw is None and request.is_json:
|
||||||
|
raw = (request.get_json(silent=True) or {}).get("is_priority")
|
||||||
|
|
||||||
|
playlist.is_priority = bool((raw or "").strip())
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({"ok": True, "is_priority": bool(playlist.is_priority)})
|
||||||
|
|
||||||
|
flash("Priority updated", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/playlists/<int:playlist_id>/delete")
|
@bp.post("/playlists/<int:playlist_id>/delete")
|
||||||
@login_required
|
@login_required
|
||||||
def delete_playlist(playlist_id: int):
|
def delete_playlist(playlist_id: int):
|
||||||
@@ -850,6 +967,14 @@ def update_display(display_id: int):
|
|||||||
def _json_error(message: str, status: int = 400):
|
def _json_error(message: str, status: int = 400):
|
||||||
return jsonify({"ok": False, "error": message}), status
|
return jsonify({"ok": False, "error": message}), status
|
||||||
|
|
||||||
|
def _normalize_transition(val: str | None) -> str | None:
|
||||||
|
v = (val or "").strip().lower()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if v not in {"none", "fade", "slide"}:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
# Inputs from either form or JSON
|
# Inputs from either form or JSON
|
||||||
payload = request.get_json(silent=True) if request.is_json else None
|
payload = request.get_json(silent=True) if request.is_json else None
|
||||||
|
|
||||||
@@ -869,6 +994,16 @@ def update_display(display_id: int):
|
|||||||
desc = desc[:200]
|
desc = desc[:200]
|
||||||
display.description = desc
|
display.description = desc
|
||||||
|
|
||||||
|
# Slide transition
|
||||||
|
if request.is_json:
|
||||||
|
if payload is None:
|
||||||
|
return _json_error("Invalid JSON")
|
||||||
|
if "transition" in payload:
|
||||||
|
display.transition = _normalize_transition(payload.get("transition"))
|
||||||
|
else:
|
||||||
|
# Form POST implies full update
|
||||||
|
display.transition = _normalize_transition(request.form.get("transition"))
|
||||||
|
|
||||||
# Playlist assignment
|
# Playlist assignment
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
if "playlist_id" in payload:
|
if "playlist_id" in payload:
|
||||||
@@ -908,6 +1043,7 @@ def update_display(display_id: int):
|
|||||||
"id": display.id,
|
"id": display.id,
|
||||||
"name": display.name,
|
"name": display.name,
|
||||||
"description": display.description,
|
"description": display.description,
|
||||||
|
"transition": display.transition,
|
||||||
"assigned_playlist_id": display.assigned_playlist_id,
|
"assigned_playlist_id": display.assigned_playlist_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,3 +290,20 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
|
|||||||
.toast {
|
.toast {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small status dot used on company dashboard next to the schedule icon */
|
||||||
|
.schedule-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status-dot.active {
|
||||||
|
background: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status-dot.inactive {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,22 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for p in playlists %}
|
{% for p in playlists %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ p.name }}</strong></td>
|
<td>
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
{# Indicators: schedule + priority #}
|
||||||
|
{% set has_schedule = (p.schedule_start is not none) or (p.schedule_end is not none) %}
|
||||||
|
{% if has_schedule %}
|
||||||
|
{% set is_active = (not p.schedule_start or p.schedule_start <= now_utc) and (not p.schedule_end or now_utc <= p.schedule_end) %}
|
||||||
|
<span class="ms-2" title="Scheduled playlist" style="font-weight:700;">📅</span>
|
||||||
|
<span
|
||||||
|
class="ms-1 schedule-status-dot {{ 'active' if is_active else 'inactive' }}"
|
||||||
|
title="{{ 'Schedule active' if is_active else 'Schedule inactive' }}"
|
||||||
|
></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.is_priority %}
|
||||||
|
<span class="ms-1" title="Priority playlist" style="color:#dc3545; font-weight:700;">❗</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-end">{{ p.items|length }}</td>
|
<td class="text-end">{{ p.items|length }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-inline-flex gap-2">
|
<div class="d-inline-flex gap-2">
|
||||||
@@ -79,27 +94,17 @@
|
|||||||
class="btn btn-ink btn-sm js-edit-playlists"
|
class="btn btn-ink btn-sm js-edit-playlists"
|
||||||
data-display-id="{{ d.id }}"
|
data-display-id="{{ d.id }}"
|
||||||
data-display-name="{{ d.name }}"
|
data-display-name="{{ d.name }}"
|
||||||
|
data-current-desc="{{ d.description or '' }}"
|
||||||
|
data-current-transition="{{ d.transition or 'none' }}"
|
||||||
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(',') }}"
|
||||||
>
|
>
|
||||||
Select playlists
|
Configure display
|
||||||
</button>
|
</button>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}">—</span>
|
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ink btn-sm js-edit-desc"
|
|
||||||
data-display-id="{{ d.id }}"
|
|
||||||
data-display-name="{{ d.name }}"
|
|
||||||
data-current-desc="{{ d.description or '' }}"
|
|
||||||
>
|
|
||||||
Edit description
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,36 +127,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit description modal -->
|
|
||||||
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="editDescModalTitle">Edit description</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="form-label" for="editDescInput">Description</label>
|
|
||||||
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea>
|
|
||||||
<div class="form-text"><span id="editDescCount">0</span>/200</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-brand" id="editDescSaveBtn">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit playlists modal -->
|
<!-- Edit playlists modal -->
|
||||||
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-scrollable">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="editPlaylistsModalTitle">Select playlists</h5>
|
<h5 class="modal-title" id="editPlaylistsModalTitle">Configure display</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="editPlaylistsDescInput">Description</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="editPlaylistsDescInput"
|
||||||
|
maxlength="200"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Optional description (max 200 chars)"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-text"><span id="editPlaylistsDescCount">0</span>/200</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="editPlaylistsTransitionSelect">Slide transition</label>
|
||||||
|
<select class="form-select" id="editPlaylistsTransitionSelect">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Applied on the display when switching between playlist items.</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 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>
|
||||||
<div class="form-text mt-2" id="editPlaylistsHint"></div>
|
<div class="form-text mt-2" id="editPlaylistsHint"></div>
|
||||||
@@ -285,66 +291,6 @@
|
|||||||
return data.display;
|
return data.display;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description modal
|
|
||||||
const modalEl = document.getElementById('editDescModal');
|
|
||||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
|
||||||
const titleEl = document.getElementById('editDescModalTitle');
|
|
||||||
const inputEl = document.getElementById('editDescInput');
|
|
||||||
const countEl = document.getElementById('editDescCount');
|
|
||||||
const saveBtn = document.getElementById('editDescSaveBtn');
|
|
||||||
|
|
||||||
let activeDisplayId = null;
|
|
||||||
|
|
||||||
function updateCount() {
|
|
||||||
if (!inputEl || !countEl) return;
|
|
||||||
countEl.textContent = String((inputEl.value || '').length);
|
|
||||||
}
|
|
||||||
if (inputEl) inputEl.addEventListener('input', updateCount);
|
|
||||||
|
|
||||||
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
activeDisplayId = btn.dataset.displayId;
|
|
||||||
const displayName = btn.dataset.displayName || 'Display';
|
|
||||||
const currentDesc = btn.dataset.currentDesc || '';
|
|
||||||
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
|
|
||||||
if (inputEl) inputEl.value = currentDesc;
|
|
||||||
updateCount();
|
|
||||||
if (modal) modal.show();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function saveDescription() {
|
|
||||||
if (!activeDisplayId || !inputEl) return;
|
|
||||||
const desc = (inputEl.value || '').trim();
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
const updated = await postDisplayUpdate(activeDisplayId, { description: desc });
|
|
||||||
// Update visible description
|
|
||||||
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`);
|
|
||||||
if (descEl) descEl.textContent = updated.description ? updated.description : '—';
|
|
||||||
// Update button's stored value
|
|
||||||
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`);
|
|
||||||
if (btn) btn.dataset.currentDesc = updated.description || '';
|
|
||||||
showToast('Description saved', 'text-bg-success');
|
|
||||||
if (modal) modal.hide();
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.addEventListener('click', saveDescription);
|
|
||||||
}
|
|
||||||
if (modalEl) {
|
|
||||||
modalEl.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
saveDescription();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playlists modal
|
// Playlists modal
|
||||||
const plModalEl = document.getElementById('editPlaylistsModal');
|
const plModalEl = document.getElementById('editPlaylistsModal');
|
||||||
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
|
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
|
||||||
@@ -352,9 +298,18 @@
|
|||||||
const plListEl = document.getElementById('editPlaylistsList');
|
const plListEl = document.getElementById('editPlaylistsList');
|
||||||
const plHintEl = document.getElementById('editPlaylistsHint');
|
const plHintEl = document.getElementById('editPlaylistsHint');
|
||||||
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
|
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
|
||||||
|
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
|
||||||
|
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
|
||||||
|
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
|
||||||
let activePlDisplayId = null;
|
let activePlDisplayId = null;
|
||||||
let activePlButton = null;
|
let activePlButton = null;
|
||||||
|
|
||||||
|
function updatePlDescCount() {
|
||||||
|
if (!plDescInputEl || !plDescCountEl) return;
|
||||||
|
plDescCountEl.textContent = String((plDescInputEl.value || '').length);
|
||||||
|
}
|
||||||
|
if (plDescInputEl) plDescInputEl.addEventListener('input', updatePlDescCount);
|
||||||
|
|
||||||
function renderPlaylistCheckboxes(selectedIds) {
|
function renderPlaylistCheckboxes(selectedIds) {
|
||||||
if (!plListEl) return;
|
if (!plListEl) return;
|
||||||
plListEl.innerHTML = '';
|
plListEl.innerHTML = '';
|
||||||
@@ -401,7 +356,15 @@
|
|||||||
activePlDisplayId = btn.dataset.displayId;
|
activePlDisplayId = btn.dataset.displayId;
|
||||||
activePlButton = btn;
|
activePlButton = btn;
|
||||||
const displayName = btn.dataset.displayName || 'Display';
|
const displayName = btn.dataset.displayName || 'Display';
|
||||||
if (plTitleEl) plTitleEl.textContent = `Select playlists — ${displayName}`;
|
if (plTitleEl) plTitleEl.textContent = `Configure display — ${displayName}`;
|
||||||
|
|
||||||
|
const currentDesc = btn.dataset.currentDesc || '';
|
||||||
|
if (plDescInputEl) plDescInputEl.value = currentDesc;
|
||||||
|
updatePlDescCount();
|
||||||
|
|
||||||
|
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
|
||||||
|
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
|
||||||
|
|
||||||
const selected = computeActiveIdsFromDataset(btn);
|
const selected = computeActiveIdsFromDataset(btn);
|
||||||
renderPlaylistCheckboxes(selected);
|
renderPlaylistCheckboxes(selected);
|
||||||
if (plHintEl) {
|
if (plHintEl) {
|
||||||
@@ -414,13 +377,31 @@
|
|||||||
async function savePlaylists() {
|
async function savePlaylists() {
|
||||||
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
|
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
|
||||||
const ids = getSelectedPlaylistIdsFromModal();
|
const ids = getSelectedPlaylistIdsFromModal();
|
||||||
|
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
|
||||||
|
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
|
||||||
plSaveBtn.disabled = true;
|
plSaveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const updated = await postDisplayPlaylists(activePlDisplayId, ids);
|
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
||||||
const newIds = (updated && updated.active_playlist_ids) ? updated.active_playlist_ids : ids;
|
postDisplayPlaylists(activePlDisplayId, ids),
|
||||||
|
postDisplayUpdate(activePlDisplayId, { description: desc, transition })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
|
||||||
|
? updatedPlaylists.active_playlist_ids
|
||||||
|
: ids;
|
||||||
setActiveIdsOnButton(activePlButton, newIds);
|
setActiveIdsOnButton(activePlButton, newIds);
|
||||||
refreshActivePlaylistSummary(activePlDisplayId, newIds);
|
refreshActivePlaylistSummary(activePlDisplayId, newIds);
|
||||||
showToast('Playlists saved', 'text-bg-success');
|
|
||||||
|
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activePlDisplayId}"]`);
|
||||||
|
const newDesc = updatedDesc && typeof updatedDesc.description === 'string' ? updatedDesc.description : desc;
|
||||||
|
if (descEl) descEl.textContent = newDesc ? newDesc : '—';
|
||||||
|
activePlButton.dataset.currentDesc = newDesc || '';
|
||||||
|
|
||||||
|
// Keep button dataset in sync so reopening modal shows correct value.
|
||||||
|
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
|
||||||
|
activePlButton.dataset.currentTransition = newTransition || 'none';
|
||||||
|
|
||||||
|
showToast('Display updated', 'text-bg-success');
|
||||||
refreshPreviewIframe(activePlDisplayId);
|
refreshPreviewIframe(activePlDisplayId);
|
||||||
if (plModal) plModal.hide();
|
if (plModal) plModal.hide();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -61,6 +61,12 @@
|
|||||||
/* Modal step visibility */
|
/* Modal step visibility */
|
||||||
.step { display: none; }
|
.step { display: none; }
|
||||||
.step.active { display: block; }
|
.step.active { display: block; }
|
||||||
|
|
||||||
|
/* Tiny status pills/icons */
|
||||||
|
.priority-pill { color: #dc3545; font-weight: 700; }
|
||||||
|
.schedule-pill { font-weight: 700; }
|
||||||
|
.schedule-pill.active { color: #198754; }
|
||||||
|
.schedule-pill.inactive { color: #dc3545; }
|
||||||
</style>
|
</style>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
||||||
@@ -80,6 +86,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Priority + schedule indicators #}
|
||||||
|
{% set has_schedule = (playlist.schedule_start is not none) or (playlist.schedule_end is not none) %}
|
||||||
|
{% set schedule_active = (not playlist.schedule_start or playlist.schedule_start <= now_utc) and (not playlist.schedule_end or now_utc <= playlist.schedule_end) %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mt-2">
|
||||||
|
<div class="small">
|
||||||
|
{% if playlist.is_priority %}
|
||||||
|
<span class="me-2 priority-pill" title="Priority playlist">❗ Priority</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted me-2">Not priority</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_schedule %}
|
||||||
|
<span class="me-2" title="Scheduled">
|
||||||
|
<span class="schedule-pill {{ 'active' if schedule_active else 'inactive' }}">📅 Scheduled</span>
|
||||||
|
<span class="text-muted">(<span id="scheduleSummary">…</span>)</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not scheduled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{# Priority toggle: auto-saves (no Save button) #}
|
||||||
|
<form
|
||||||
|
id="priorityForm"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('company.update_playlist_priority', playlist_id=playlist.id) }}"
|
||||||
|
class="d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="form-check mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" value="1" id="priorityMain" name="is_priority" {% if playlist.is_priority %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="priorityMain">Priority playlist</label>
|
||||||
|
</div>
|
||||||
|
<span class="small text-muted" id="prioritySaveStatus" aria-live="polite"></span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Schedule button moved to where Save button used to be #}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#playlistScheduleModal"
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Schedule Modal #}
|
||||||
|
<div class="modal fade" id="playlistScheduleModal" tabindex="-1" aria-labelledby="playlistScheduleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="playlistScheduleModalLabel">Schedule</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="playlistScheduleForm" method="post" action="{{ url_for('company.update_playlist_schedule', playlist_id=playlist.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info py-2 mb-3" role="note">
|
||||||
|
Scheduling uses your browser's local time. Empty values mean “always active”.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_start_date">Start date</label>
|
||||||
|
<input class="form-control" type="date" id="schedule_start_date" name="schedule_start_date" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_start_time">Start time</label>
|
||||||
|
<input class="form-control" type="time" id="schedule_start_time" name="schedule_start_time" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_end_date">End date</label>
|
||||||
|
<input class="form-control" type="date" id="schedule_end_date" name="schedule_end_date" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_end_time">End time</label>
|
||||||
|
<input class="form-control" type="time" id="schedule_end_time" name="schedule_end_time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# JS uses these to populate initial values #}
|
||||||
|
<input type="hidden" id="schedule_start_iso" value="{{ playlist.schedule_start.isoformat() if playlist.schedule_start else '' }}" />
|
||||||
|
<input type="hidden" id="schedule_end_iso" value="{{ playlist.schedule_end.isoformat() if playlist.schedule_end else '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{% if has_schedule %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-outline-danger me-auto"
|
||||||
|
formaction="{{ url_for('company.clear_playlist_schedule', playlist_id=playlist.id) }}"
|
||||||
|
formmethod="post"
|
||||||
|
onclick="return confirm('Remove schedule? This playlist will become always active.');"
|
||||||
|
>
|
||||||
|
Delete schedule
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-brand">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Rename Playlist Modal #}
|
{# Rename Playlist Modal #}
|
||||||
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
|
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -341,6 +454,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
|
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
|
||||||
<div class="text-muted small" id="video-select-status"></div>
|
<div class="text-muted small" id="video-select-status"></div>
|
||||||
|
|
||||||
|
{# Upload progress (for large videos) #}
|
||||||
|
<div id="video-upload-progress" class="d-none mt-2" aria-live="polite">
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div
|
||||||
|
id="video-upload-progress-bar"
|
||||||
|
class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1" id="video-upload-progress-text">Uploading…</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -357,6 +486,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<div class="small text-danger me-auto" id="add-item-error" aria-live="polite"></div>
|
||||||
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
|
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
|
||||||
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
|
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +499,120 @@
|
|||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
(function() {
|
(function() {
|
||||||
|
// -------------------------
|
||||||
|
// Priority toggle: auto-save
|
||||||
|
// -------------------------
|
||||||
|
const priorityForm = document.getElementById('priorityForm');
|
||||||
|
const priorityCb = document.getElementById('priorityMain');
|
||||||
|
const priorityStatus = document.getElementById('prioritySaveStatus');
|
||||||
|
let priorityReqId = 0;
|
||||||
|
|
||||||
|
async function savePriority() {
|
||||||
|
if (!priorityForm || !priorityCb) return;
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
// Mirror server behavior: send "1" when checked, send empty when unchecked.
|
||||||
|
if (priorityCb.checked) body.set('is_priority', '1');
|
||||||
|
|
||||||
|
const reqId = ++priorityReqId;
|
||||||
|
const res = await fetch(priorityForm.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reqId !== priorityReqId) return; // newer request in flight
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityStatus) {
|
||||||
|
priorityStatus.textContent = 'Saved';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (priorityStatus.textContent === 'Saved') priorityStatus.textContent = '';
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent accidental full-page POST when user hits Enter inside the form.
|
||||||
|
priorityForm?.addEventListener('submit', (e) => e.preventDefault());
|
||||||
|
priorityCb?.addEventListener('change', () => {
|
||||||
|
savePriority().catch((err) => {
|
||||||
|
console.warn('Failed to save priority', err);
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Schedule modal: populate existing UTC timestamps into local date/time inputs
|
||||||
|
// -------------------------
|
||||||
|
const schedModalEl = document.getElementById('playlistScheduleModal');
|
||||||
|
if (schedModalEl) {
|
||||||
|
const startIso = document.getElementById('schedule_start_iso')?.value || '';
|
||||||
|
const endIso = document.getElementById('schedule_end_iso')?.value || '';
|
||||||
|
|
||||||
|
const scheduleSummary = document.getElementById('scheduleSummary');
|
||||||
|
|
||||||
|
const startDate = document.getElementById('schedule_start_date');
|
||||||
|
const startTime = document.getElementById('schedule_start_time');
|
||||||
|
const endDate = document.getElementById('schedule_end_date');
|
||||||
|
const endTime = document.getElementById('schedule_end_time');
|
||||||
|
|
||||||
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function toLocalDateStr(d) { return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
|
||||||
|
function toLocalTimeStr(d) { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; }
|
||||||
|
|
||||||
|
function toSummary() {
|
||||||
|
const parts = [];
|
||||||
|
if (startIso) {
|
||||||
|
const d = new Date(startIso);
|
||||||
|
if (!isNaN(d.getTime())) parts.push(`from ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
|
||||||
|
}
|
||||||
|
if (endIso) {
|
||||||
|
const d = new Date(endIso);
|
||||||
|
if (!isNaN(d.getTime())) parts.push(`until ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill() {
|
||||||
|
if (startIso) {
|
||||||
|
const d = new Date(startIso);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
if (startDate) startDate.value = toLocalDateStr(d);
|
||||||
|
if (startTime) startTime.value = toLocalTimeStr(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (endIso) {
|
||||||
|
const d = new Date(endIso);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
if (endDate) endDate.value = toLocalDateStr(d);
|
||||||
|
if (endTime) endTime.value = toLocalTimeStr(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
schedModalEl.addEventListener('shown.bs.modal', fill);
|
||||||
|
|
||||||
|
// Populate summary immediately on page load
|
||||||
|
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
|
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Add-item modal + steps
|
// Add-item modal + steps
|
||||||
@@ -395,6 +639,12 @@
|
|||||||
const stepSelect = document.getElementById('step-select');
|
const stepSelect = document.getElementById('step-select');
|
||||||
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');
|
||||||
|
|
||||||
|
function setError(msg) {
|
||||||
|
if (!errorEl) return;
|
||||||
|
errorEl.textContent = (msg || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function showStep(which) {
|
function showStep(which) {
|
||||||
stepSelect?.classList.toggle('active', which === 'select');
|
stepSelect?.classList.toggle('active', which === 'select');
|
||||||
@@ -411,6 +661,7 @@
|
|||||||
|
|
||||||
function setType(t) {
|
function setType(t) {
|
||||||
typeHidden.value = t;
|
typeHidden.value = t;
|
||||||
|
setError('');
|
||||||
sectionImage.classList.toggle('d-none', t !== 'image');
|
sectionImage.classList.toggle('d-none', t !== 'image');
|
||||||
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
||||||
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
||||||
@@ -570,6 +821,7 @@
|
|||||||
async function submitViaAjax() {
|
async function submitViaAjax() {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
cropStatus.textContent = '';
|
cropStatus.textContent = '';
|
||||||
|
setError('');
|
||||||
|
|
||||||
// If image, replace file with cropped version before sending.
|
// If image, replace file with cropped version before sending.
|
||||||
if (typeHidden.value === 'image') {
|
if (typeHidden.value === 'image') {
|
||||||
@@ -604,30 +856,125 @@
|
|||||||
|
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
|
||||||
const res = await fetch(form.action, {
|
// For upload progress we need XHR (fetch does not provide upload progress reliably).
|
||||||
method: 'POST',
|
const useXhrProgress = (typeHidden.value === 'video');
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: fd
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
function setVideoProgressVisible(visible) {
|
||||||
let errText = 'Failed to add item.';
|
const wrap = document.getElementById('video-upload-progress');
|
||||||
try {
|
wrap?.classList.toggle('d-none', !visible);
|
||||||
const j = await res.json();
|
}
|
||||||
if (j && j.error) errText = j.error;
|
|
||||||
} catch (e) {}
|
function setVideoProgress(percent, text) {
|
||||||
|
const bar = document.getElementById('video-upload-progress-bar');
|
||||||
|
const txt = document.getElementById('video-upload-progress-text');
|
||||||
|
const p = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = `${p}%`;
|
||||||
|
bar.setAttribute('aria-valuenow', String(p));
|
||||||
|
}
|
||||||
|
if (txt) txt.textContent = text || `${p}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVideoProgress() {
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
setVideoProgressVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resOk = false;
|
||||||
|
let data = null;
|
||||||
|
let errorText = null;
|
||||||
|
|
||||||
|
if (useXhrProgress) {
|
||||||
|
// Show progress UI immediately for video uploads.
|
||||||
|
setVideoProgressVisible(true);
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const xhrPromise = new Promise((resolve) => {
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (!e || !e.lengthComputable) {
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
|
||||||
|
setVideoProgress(pct, `Uploading… ${Math.round(pct)}%`);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
errorText = 'Upload failed (network error).';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(fd);
|
||||||
|
await xhrPromise;
|
||||||
|
|
||||||
|
// When upload is done, server may still process the file. Give a hint.
|
||||||
|
setVideoProgress(100, 'Processing…');
|
||||||
|
|
||||||
|
const status = xhr.status;
|
||||||
|
const text = xhr.responseText || '';
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch (e) {}
|
||||||
|
|
||||||
|
resOk = (status >= 200 && status < 300);
|
||||||
|
data = json;
|
||||||
|
|
||||||
|
if (!resOk) {
|
||||||
|
// Prefer any earlier error (e.g. xhr.onerror network failure)
|
||||||
|
errorText = errorText || ((json && json.error) ? json.error : `Failed to add item (HTTP ${status}).`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
|
||||||
|
resOk = res.ok;
|
||||||
|
if (!resOk) {
|
||||||
|
let errText = 'Failed to add item.';
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
if (j && j.error) errText = j.error;
|
||||||
|
} catch (e) {}
|
||||||
|
errorText = errText;
|
||||||
|
} else {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling shared between fetch/XHR paths
|
||||||
|
if (!resOk) {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
cropStatus.textContent = errText;
|
setError(errorText || 'Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For XHR path we may not have parsed JSON (bad response)
|
||||||
|
if (!data) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
setError('Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
cropStatus.textContent = data.error || 'Failed to add item.';
|
setError(data.error || 'Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +999,7 @@
|
|||||||
destroyCropper();
|
destroyCropper();
|
||||||
showStep('select');
|
showStep('select');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
resetVideoProgress();
|
||||||
modal?.hide();
|
modal?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,59 @@
|
|||||||
<style>
|
<style>
|
||||||
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; }
|
||||||
|
|
||||||
|
/* Slide transitions (applied by JS via classes) */
|
||||||
|
#stage .slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#stage .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
#stage.transition-none .slide.enter {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide {
|
||||||
|
transition: opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.enter.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.exit {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.exit.active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage.transition-slide .slide {
|
||||||
|
transition: transform 420ms ease, opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(48px);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.enter.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.exit {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.exit.active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-48px);
|
||||||
|
}
|
||||||
#notice {
|
#notice {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -84,6 +137,18 @@
|
|||||||
let idx = 0;
|
let idx = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
|
const ANIM_MS = 420;
|
||||||
|
|
||||||
|
function getTransitionMode(pl) {
|
||||||
|
const v = (pl && pl.transition ? String(pl.transition) : 'none').toLowerCase();
|
||||||
|
return (v === 'fade' || v === 'slide' || v === 'none') ? v : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransitionClass(mode) {
|
||||||
|
stage.classList.remove('transition-none', 'transition-fade', 'transition-slide');
|
||||||
|
stage.classList.add(`transition-${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPlaylist() {
|
async function fetchPlaylist() {
|
||||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
||||||
@@ -99,6 +164,78 @@
|
|||||||
stage.innerHTML = '';
|
stage.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSlideContent(container, item) {
|
||||||
|
if (item.type === 'image') {
|
||||||
|
const el = document.createElement('img');
|
||||||
|
el.src = item.src;
|
||||||
|
container.appendChild(el);
|
||||||
|
} else if (item.type === 'video') {
|
||||||
|
const el = document.createElement('video');
|
||||||
|
el.src = item.src;
|
||||||
|
el.autoplay = true;
|
||||||
|
el.muted = true;
|
||||||
|
el.playsInline = true;
|
||||||
|
el.onended = next;
|
||||||
|
container.appendChild(el);
|
||||||
|
} else if (item.type === 'webpage') {
|
||||||
|
const el = document.createElement('iframe');
|
||||||
|
el.src = item.url;
|
||||||
|
container.appendChild(el);
|
||||||
|
} else if (item.type === 'youtube') {
|
||||||
|
const el = document.createElement('iframe');
|
||||||
|
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
|
||||||
|
// Add common playback params client-side.
|
||||||
|
const u = item.url || '';
|
||||||
|
const sep = u.includes('?') ? '&' : '?';
|
||||||
|
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
|
||||||
|
container.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItemWithTransition(item) {
|
||||||
|
const mode = getTransitionMode(playlist);
|
||||||
|
applyTransitionClass(mode);
|
||||||
|
|
||||||
|
// Create new slide container.
|
||||||
|
const slide = document.createElement('div');
|
||||||
|
slide.className = 'slide enter';
|
||||||
|
setSlideContent(slide, item);
|
||||||
|
|
||||||
|
// Determine previous slide (if any).
|
||||||
|
const prev = stage.querySelector('.slide');
|
||||||
|
|
||||||
|
// First render: no animation needed.
|
||||||
|
if (!prev || mode === 'none') {
|
||||||
|
stage.innerHTML = '';
|
||||||
|
slide.classList.remove('enter');
|
||||||
|
stage.appendChild(slide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition: keep both on stage and animate.
|
||||||
|
stage.appendChild(slide);
|
||||||
|
|
||||||
|
// Trigger transition.
|
||||||
|
// In some browsers the style changes can get coalesced into a single paint (no animation),
|
||||||
|
// especially on fast/fullscreen pages. We force a layout read before activating.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prev.classList.add('exit');
|
||||||
|
// Force reflow so the browser commits initial (enter) styles.
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
slide.offsetHeight;
|
||||||
|
slide.classList.add('active');
|
||||||
|
prev.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after animation.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (prev && prev.parentNode === stage) stage.removeChild(prev);
|
||||||
|
slide.classList.remove('enter');
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
}, ANIM_MS + 50);
|
||||||
|
}
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
setNotice('No playlists assigned.');
|
setNotice('No playlists assigned.');
|
||||||
@@ -109,36 +246,19 @@
|
|||||||
const item = playlist.items[idx % playlist.items.length];
|
const item = playlist.items[idx % playlist.items.length];
|
||||||
idx = (idx + 1) % playlist.items.length;
|
idx = (idx + 1) % playlist.items.length;
|
||||||
|
|
||||||
clearStage();
|
// Clear any active timers (but keep DOM for transition).
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
setNotice('');
|
setNotice('');
|
||||||
|
|
||||||
|
showItemWithTransition(item);
|
||||||
|
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
const el = document.createElement('img');
|
|
||||||
el.src = item.src;
|
|
||||||
stage.appendChild(el);
|
|
||||||
timer = setTimeout(next, (item.duration || 10) * 1000);
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
||||||
} else if (item.type === 'video') {
|
} else if (item.type === 'video') {
|
||||||
const el = document.createElement('video');
|
// next() is called on video end.
|
||||||
el.src = item.src;
|
|
||||||
el.autoplay = true;
|
|
||||||
el.muted = true;
|
|
||||||
el.playsInline = true;
|
|
||||||
el.onended = next;
|
|
||||||
stage.appendChild(el);
|
|
||||||
} else if (item.type === 'webpage') {
|
} else if (item.type === 'webpage') {
|
||||||
const el = document.createElement('iframe');
|
|
||||||
el.src = item.url;
|
|
||||||
stage.appendChild(el);
|
|
||||||
timer = setTimeout(next, (item.duration || 10) * 1000);
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
||||||
} else if (item.type === 'youtube') {
|
} else if (item.type === 'youtube') {
|
||||||
const el = document.createElement('iframe');
|
|
||||||
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
|
|
||||||
// Add common playback params client-side.
|
|
||||||
const u = item.url || '';
|
|
||||||
const sep = u.includes('?') ? '&' : '?';
|
|
||||||
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
|
|
||||||
stage.appendChild(el);
|
|
||||||
|
|
||||||
// YouTube iframes don't reliably emit an "ended" event without the JS API.
|
// YouTube iframes don't reliably emit an "ended" event without the JS API.
|
||||||
// We keep it simple: play for the configured duration (default 30s).
|
// We keep it simple: play for the configured duration (default 30s).
|
||||||
timer = setTimeout(next, (item.duration || 30) * 1000);
|
timer = setTimeout(next, (item.duration || 30) * 1000);
|
||||||
@@ -151,6 +271,7 @@
|
|||||||
try {
|
try {
|
||||||
playlist = await fetchPlaylist();
|
playlist = await fetchPlaylist();
|
||||||
idx = 0;
|
idx = 0;
|
||||||
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
@@ -181,6 +302,7 @@
|
|||||||
playlist = newPlaylist;
|
playlist = newPlaylist;
|
||||||
if (oldStr !== newStr) {
|
if (oldStr !== newStr) {
|
||||||
idx = 0;
|
idx = 0;
|
||||||
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user