Initial import

This commit is contained in:
2026-01-25 13:26:45 +01:00
parent a5fe0f73a0
commit f4b7fb62f5
8 changed files with 834 additions and 149 deletions

View File

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

View File

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

View File

@@ -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:
# single-playlist fallback; apply schedule filter too.
if scheduled_ids:
merged = ( merged = (
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0]) PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
.order_by(PlaylistItem.position.asc()) .order_by(PlaylistItem.position.asc())
.all() .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,
} }

View File

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

View File

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

View File

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

View File

@@ -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,6 +856,83 @@
const fd = new FormData(form); const fd = new FormData(form);
// For upload progress we need XHR (fetch does not provide upload progress reliably).
const useXhrProgress = (typeHidden.value === 'video');
function setVideoProgressVisible(visible) {
const wrap = document.getElementById('video-upload-progress');
wrap?.classList.toggle('d-none', !visible);
}
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, { const res = await fetch(form.action, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -613,21 +942,39 @@
body: fd body: fd
}); });
if (!res.ok) { resOk = res.ok;
if (!resOk) {
let errText = 'Failed to add item.'; let errText = 'Failed to add item.';
try { try {
const j = await res.json(); const j = await res.json();
if (j && j.error) errText = j.error; if (j && j.error) errText = j.error;
} catch (e) {} } 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();
} }

View File

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