Add company dashboard improvements and upload/auth features
This commit is contained in:
@@ -93,9 +93,20 @@
|
||||
<div>
|
||||
<strong>{{ u.email or "(no email)" }}</strong>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||
</form>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||
data-confirm="Delete user {{ u.email or '(no email)' }}? This cannot be undone."
|
||||
onsubmit="return confirm(this.dataset.confirm);"
|
||||
>
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item text-muted">No users.</div>
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">Admin</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('company.dashboard') }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('company.my_company') }}">My Company</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="small text-muted">{{ current_user.email }}</div>
|
||||
|
||||
@@ -29,9 +29,6 @@
|
||||
<td class="text-end">
|
||||
<div class="d-inline-flex gap-2">
|
||||
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
|
||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -59,6 +56,7 @@
|
||||
<div class="display-preview">
|
||||
<iframe
|
||||
title="Preview — {{ d.name }}"
|
||||
data-display-id="{{ d.id }}"
|
||||
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@@ -173,6 +171,22 @@
|
||||
return data.display;
|
||||
}
|
||||
|
||||
function refreshPreviewIframe(displayId) {
|
||||
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
||||
if (!iframe || !iframe.src) return;
|
||||
try {
|
||||
const u = new URL(iframe.src, window.location.origin);
|
||||
// Ensure preview flag is present (and bust cache).
|
||||
u.searchParams.set('preview', '1');
|
||||
u.searchParams.set('_ts', String(Date.now()));
|
||||
iframe.src = u.toString();
|
||||
} catch (e) {
|
||||
// Fallback: naive cache buster
|
||||
const sep = iframe.src.includes('?') ? '&' : '?';
|
||||
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist auto-save
|
||||
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
||||
sel.addEventListener('change', async () => {
|
||||
@@ -182,6 +196,7 @@
|
||||
try {
|
||||
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
||||
showToast('Playlist saved', 'text-bg-success');
|
||||
refreshPreviewIframe(displayId);
|
||||
} catch (e) {
|
||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||
} finally {
|
||||
|
||||
119
app/templates/company/my_company.html
Normal file
119
app/templates/company/my_company.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="page-title">My Company</h1>
|
||||
<div class="text-muted">{{ company.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-ink" href="{{ url_for('company.dashboard') }}">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card card-elevated h-100">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Company stats</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Users</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['users'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Displays</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['displays'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Playlists</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['playlists'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Playlist items</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['items'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Active display sessions</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['active_sessions'] }}</div>
|
||||
<div class="text-muted small">(last ~90 seconds)</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Storage used</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['storage_human'] }}</div>
|
||||
<div class="text-muted small">({{ stats['storage_bytes'] }} bytes)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card card-elevated h-100">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Invite user</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('company.invite_user') }}" class="d-flex gap-2 flex-wrap">
|
||||
<input class="form-control" type="email" name="email" placeholder="Email address" required />
|
||||
<button class="btn btn-brand" type="submit">Send invite</button>
|
||||
</form>
|
||||
<div class="text-muted small mt-2">
|
||||
The user will receive an email with a password set link (valid for 30 minutes).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-elevated mt-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Users</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th class="text-muted">Created</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ u.email or "(no email)" }}</strong>
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="badge bg-secondary ms-2">you</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else "—" }}</td>
|
||||
<td class="text-end">
|
||||
{% if u.id != current_user.id %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('company.delete_company_user', user_id=u.id) }}"
|
||||
class="d-inline"
|
||||
data-confirm="Delete user {{ u.email or "(no email)" }}? This cannot be undone."
|
||||
onsubmit="return confirm(this.dataset.confirm);"
|
||||
>
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">No users.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -65,6 +65,14 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#renamePlaylistModal"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
||||
</form>
|
||||
@@ -72,6 +80,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Rename Playlist Modal #}
|
||||
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renamePlaylistModalLabel">Rename playlist</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('company.update_playlist', playlist_id=playlist.id) }}">
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="playlist-name">Name</label>
|
||||
<input
|
||||
id="playlist-name"
|
||||
class="form-control"
|
||||
name="name"
|
||||
value="{{ playlist.name }}"
|
||||
placeholder="Playlist name"
|
||||
maxlength="120"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<div class="text-muted small mt-2">Rename only changes the playlist title; items and assignments stay the same.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Items</h2>
|
||||
@@ -164,6 +204,7 @@
|
||||
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="response" value="json" />
|
||||
<input type="hidden" name="item_type" id="item_type" value="image" />
|
||||
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Title (optional)</label>
|
||||
@@ -192,6 +233,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="crop-mode-group">
|
||||
<label class="form-label">Image crop</label>
|
||||
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
||||
</div>
|
||||
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
border: 2px dashed #6c757d;
|
||||
@@ -289,7 +345,7 @@
|
||||
</div>
|
||||
|
||||
<div id="step-crop" class="step">
|
||||
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
||||
<div class="text-muted small mb-2" id="crop-step-hint">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
||||
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
|
||||
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
|
||||
</div>
|
||||
@@ -324,8 +380,11 @@
|
||||
if (!form) return;
|
||||
|
||||
const typeHidden = document.getElementById('item_type');
|
||||
const cropModeHidden = document.getElementById('crop_mode');
|
||||
const submitBtn = document.getElementById('add-item-submit');
|
||||
const durationGroup = document.getElementById('duration-group');
|
||||
const cropModeGroup = document.getElementById('crop-mode-group');
|
||||
const cropHint = document.getElementById('crop-step-hint');
|
||||
|
||||
const sectionImage = document.getElementById('section-image');
|
||||
const sectionWebpage = document.getElementById('section-webpage');
|
||||
@@ -357,6 +416,7 @@
|
||||
sectionVideo.classList.toggle('d-none', t !== 'video');
|
||||
// duration applies to image/webpage/youtube. Video plays until ended.
|
||||
durationGroup.classList.toggle('d-none', t === 'video');
|
||||
cropModeGroup?.classList.toggle('d-none', t !== 'image');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.title = '';
|
||||
|
||||
@@ -373,6 +433,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
function currentCropMode() {
|
||||
// crop_mode_choice is only UI; we submit hidden crop_mode for server fallback
|
||||
return (cropModeHidden?.value || '16:9').toLowerCase();
|
||||
}
|
||||
|
||||
function updateCropHint() {
|
||||
const cm = currentCropMode();
|
||||
if (!cropHint) return;
|
||||
if (cm === 'none') {
|
||||
cropHint.innerHTML = 'No crop selected. The image will be resized/compressed, keeping its original aspect ratio.';
|
||||
if (cropResetBtn) cropResetBtn.disabled = true;
|
||||
} else if (cm === '9:16') {
|
||||
cropHint.innerHTML = 'Crop to <strong>9:16</strong> (portrait).';
|
||||
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||
} else {
|
||||
cropHint.innerHTML = 'Crop to <strong>16:9</strong> (landscape, recommended for display screens).';
|
||||
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
||||
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
||||
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
||||
@@ -437,13 +517,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
cropper = new window.Cropper(cropImg, {
|
||||
aspectRatio: 16 / 9,
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
});
|
||||
// Create cropper only when cropping is enabled
|
||||
const cm = currentCropMode();
|
||||
if (cm !== 'none') {
|
||||
cropper = new window.Cropper(cropImg, {
|
||||
aspectRatio: (cm === '9:16') ? (9 / 16) : (16 / 9),
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
});
|
||||
}
|
||||
|
||||
updateCropHint();
|
||||
|
||||
// Enable Add button now that cropper exists
|
||||
if (typeHidden.value === 'image') submitBtn.disabled = false;
|
||||
@@ -486,26 +572,33 @@
|
||||
|
||||
// If image, replace file with cropped version before sending.
|
||||
if (typeHidden.value === 'image') {
|
||||
if (!cropper) {
|
||||
cropStatus.textContent = 'Please select an image first.';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
const cm = currentCropMode();
|
||||
|
||||
// If no crop is selected, just upload the original file.
|
||||
if (cm !== 'none') {
|
||||
if (!cropper) {
|
||||
cropStatus.textContent = 'Please select an image first.';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
cropStatus.textContent = 'Preparing cropped image…';
|
||||
const isPortrait = cm === '9:16';
|
||||
const canvas = cropper.getCroppedCanvas({
|
||||
width: isPortrait ? 720 : 1280,
|
||||
height: isPortrait ? 1280 : 720,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) {
|
||||
cropStatus.textContent = 'Failed to crop image.';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
|
||||
setFileOnInput(fileInput, croppedFile);
|
||||
cropStatus.textContent = '';
|
||||
}
|
||||
cropStatus.textContent = 'Preparing cropped image…';
|
||||
const canvas = cropper.getCroppedCanvas({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) {
|
||||
cropStatus.textContent = 'Failed to crop image.';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
|
||||
setFileOnInput(fileInput, croppedFile);
|
||||
cropStatus.textContent = '';
|
||||
}
|
||||
|
||||
const fd = new FormData(form);
|
||||
@@ -553,6 +646,8 @@
|
||||
form.reset();
|
||||
typeHidden.value = 'image';
|
||||
document.getElementById('type-image')?.click();
|
||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||
document.getElementById('crop-16-9')?.click();
|
||||
destroyCropper();
|
||||
showStep('select');
|
||||
submitBtn.disabled = true;
|
||||
@@ -648,6 +743,9 @@
|
||||
setEnabled(urlInput, t === 'webpage');
|
||||
setEnabled(youtubeUrlInput, t === 'youtube');
|
||||
|
||||
// Crop mode only applies to images
|
||||
setEnabled(cropModeHidden, t === 'image');
|
||||
|
||||
if (t === 'webpage') {
|
||||
// Keep preview behavior
|
||||
schedulePreview();
|
||||
@@ -661,8 +759,10 @@
|
||||
|
||||
// Set initial state
|
||||
setType('image');
|
||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||
showStep('select');
|
||||
syncEnabledInputs();
|
||||
updateCropHint();
|
||||
|
||||
// Modal open
|
||||
openBtn?.addEventListener('click', () => {
|
||||
@@ -678,6 +778,43 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Crop mode selection
|
||||
function setCropMode(mode) {
|
||||
if (!cropModeHidden) return;
|
||||
cropModeHidden.value = mode;
|
||||
|
||||
// If cropper exists, update aspect ratio or destroy it
|
||||
const cm = currentCropMode();
|
||||
if (typeHidden.value !== 'image') return;
|
||||
if (!cropImg?.src) {
|
||||
updateCropHint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cropper state matches selection
|
||||
if (cm === 'none') {
|
||||
destroyCropper();
|
||||
// Re-load the selected file into the preview without cropper.
|
||||
const f = fileInput?.files?.[0];
|
||||
if (f) loadImageFile(f);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cropper) {
|
||||
cropper.setAspectRatio((cm === '9:16') ? (9 / 16) : (16 / 9));
|
||||
updateCropHint();
|
||||
return;
|
||||
}
|
||||
|
||||
// No cropper yet (e.g. user changed crop mode after selecting file but before cropper init)
|
||||
const f = fileInput?.files?.[0];
|
||||
if (f) loadImageFile(f);
|
||||
}
|
||||
|
||||
document.getElementById('crop-16-9')?.addEventListener('change', () => setCropMode('16:9'));
|
||||
document.getElementById('crop-9-16')?.addEventListener('change', () => setCropMode('9:16'));
|
||||
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
||||
|
||||
// Whenever type changes, keep enabled inputs in sync
|
||||
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
||||
|
||||
@@ -8,16 +8,15 @@
|
||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
||||
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
|
||||
/* removed bottom-left status text */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<script>
|
||||
const token = "{{ display.token }}";
|
||||
const stage = document.getElementById('stage');
|
||||
const notice = document.getElementById('notice');
|
||||
function setNotice(_text) { /* intentionally no-op: notice UI removed */ }
|
||||
|
||||
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
||||
|
||||
@@ -39,6 +38,9 @@
|
||||
let idx = 0;
|
||||
let timer = null;
|
||||
|
||||
let es = null;
|
||||
let esRetryMs = 1000;
|
||||
|
||||
async function fetchPlaylist() {
|
||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
||||
@@ -56,7 +58,7 @@
|
||||
|
||||
function next() {
|
||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||
notice.textContent = 'No playlist assigned.';
|
||||
setNotice('No playlist assigned.');
|
||||
clearStage();
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +67,7 @@
|
||||
idx = (idx + 1) % playlist.items.length;
|
||||
|
||||
clearStage();
|
||||
notice.textContent = playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display;
|
||||
setNotice(playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display);
|
||||
|
||||
if (item.type === 'image') {
|
||||
const el = document.createElement('img');
|
||||
@@ -109,10 +111,14 @@
|
||||
next();
|
||||
} catch (e) {
|
||||
clearStage();
|
||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
||||
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
|
||||
// keep retrying; if a slot frees up the display will start automatically.
|
||||
}
|
||||
// refresh playlist every 60s
|
||||
|
||||
// Open live event stream: when server signals a change, reload playlist immediately.
|
||||
connectEvents();
|
||||
|
||||
// Fallback refresh (in case SSE is blocked by a proxy/network): every 5 minutes.
|
||||
setInterval(async () => {
|
||||
try {
|
||||
playlist = await fetchPlaylist();
|
||||
@@ -122,9 +128,47 @@
|
||||
}
|
||||
} catch(e) {
|
||||
clearStage();
|
||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
||||
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
|
||||
}
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (isPreview) return; // preview shouldn't consume a slot / keep a long-lived connection
|
||||
|
||||
try { if (es) es.close(); } catch(e) { /* ignore */ }
|
||||
|
||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||
es = new EventSource(`/api/display/${token}/events${qs}`);
|
||||
|
||||
es.addEventListener('changed', async () => {
|
||||
try {
|
||||
const newPlaylist = await fetchPlaylist();
|
||||
|
||||
// If content changed, restart from the beginning.
|
||||
const oldStr = JSON.stringify(playlist);
|
||||
const newStr = JSON.stringify(newPlaylist);
|
||||
playlist = newPlaylist;
|
||||
if (oldStr !== newStr) {
|
||||
idx = 0;
|
||||
next();
|
||||
}
|
||||
|
||||
esRetryMs = 1000; // reset backoff on success
|
||||
} catch(e) {
|
||||
// leave current playback; we'll retry via reconnect handler
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
try { es.close(); } catch(e) { /* ignore */ }
|
||||
es = null;
|
||||
|
||||
// Exponential backoff up to 30s
|
||||
const wait = esRetryMs;
|
||||
esRetryMs = Math.min(30000, Math.floor(esRetryMs * 1.7));
|
||||
setTimeout(connectEvents, wait);
|
||||
};
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
Reference in New Issue
Block a user