Release v2.15.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 4m34s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 57s

This commit is contained in:
lzstealth committed 2026-10-15 01:50:39 +01:00
1 parent ddcc665f81
commit 7295f83f04
43 files changed
+2526 -104

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, ps.group_key, ps.group_name, ps.group_pick_count, ps.group_shuffle, ps.group_mode, ps.group_repeat_count, ps.group_schedule_rules_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
+1 -2
View File
@@ -22,8 +22,7 @@ async function pruneStaleOnboardingDevices(pool) {
`DELETE FROM d_onboarding_devices
WHERE (screen_id IS NULL
AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 15 MINUTE))
OR (last_seen_at IS NOT NULL
AND last_seen_at < (CURRENT_TIMESTAMP - INTERVAL 24 HOUR))`
OR (COALESCE(last_seen_at, modified_at) < (CURRENT_TIMESTAMP - INTERVAL 24 HOUR))`
);
}
+7
View File
@@ -130,6 +130,13 @@ async function ensureSchema(pool, options) {
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
group_key VARCHAR(64) NULL,
group_name VARCHAR(128) NULL,
group_pick_count INT NULL,
group_shuffle TINYINT(1) NOT NULL DEFAULT 0,
group_mode VARCHAR(16) NOT NULL DEFAULT 'select',
group_repeat_count INT NOT NULL DEFAULT 1,
group_schedule_rules_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+25
View File
@@ -652,6 +652,31 @@ const VERSIONED_MIGRATIONS = [
await pool.query('ALTER TABLE c_media_assets ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 1 AFTER file_size');
}
}
},
{
version: '2.15.0',
label: 'v2.15.0 playlist groups',
run: async function (pool) {
await ensureColumn(pool, 'c_playlist_slides', 'group_key', 'VARCHAR(64) NULL', 'disable_audio');
await ensureColumn(pool, 'c_playlist_slides', 'group_name', 'VARCHAR(128) NULL', 'group_key');
await ensureColumn(pool, 'c_playlist_slides', 'group_pick_count', 'INT NULL', 'group_name');
await ensureColumn(pool, 'c_playlist_slides', 'group_schedule_rules_json', 'JSON NULL', 'group_pick_count');
}
},
{
version: '2.15.1',
label: 'v2.15.1 playlist group shuffle',
run: async function (pool) {
await ensureColumn(pool, 'c_playlist_slides', 'group_shuffle', 'TINYINT(1) NOT NULL DEFAULT 0', 'group_pick_count');
}
},
{
version: '2.15.2',
label: 'v2.15.2 playlist group repeat mode',
run: async function (pool) {
await ensureColumn(pool, 'c_playlist_slides', 'group_mode', "VARCHAR(16) NOT NULL DEFAULT 'select'", 'group_shuffle');
await ensureColumn(pool, 'c_playlist_slides', 'group_repeat_count', 'INT NOT NULL DEFAULT 1', 'group_mode');
}
}
];
+1
View File
@@ -14,6 +14,7 @@
let pendingPlaylistUpdate = null;
let activeSlidesCacheKey = '';
let activeSlidesCacheValue = [];
let playlistPassNumber = 0;
let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null);
+19 -1
View File
@@ -213,8 +213,9 @@ function createPlayerPlaylistService(options) {
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
ps.group_key, ps.group_name, ps.group_pick_count, ps.group_shuffle, ps.group_mode, ps.group_repeat_count, ps.group_schedule_rules_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
@@ -317,6 +318,9 @@ function createPlayerPlaylistService(options) {
}));
const template = slide.template_id ? templatesById[slide.template_id] || null : null;
const groupScheduleRules = slide.group_schedule_rules_json
? common.parseJsonSafe(slide.group_schedule_rules_json) || []
: [];
if (template && template.background_image_path && !template.background_image_variants) {
const variants = await getImageVariants(template.background_image_path);
if (variants.length) {
@@ -352,6 +356,13 @@ function createPlayerPlaylistService(options) {
use_video_duration: Boolean(slide.use_video_duration),
disable_audio: disableAudio,
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
groupKey: slide.group_key || null,
groupName: slide.group_name || null,
groupPickCount: Number(slide.group_pick_count) || null,
groupShuffle: Boolean(Number(slide.group_shuffle)),
groupMode: slide.group_mode === 'repeat' ? 'repeat' : 'select',
groupRepeatCount: Math.min(100, Math.max(1, Number(slide.group_repeat_count) || 1)),
groupScheduleRules: Array.isArray(groupScheduleRules) ? groupScheduleRules : [],
template_id: slide.template_id,
template: template,
content: content
@@ -445,6 +456,13 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.disable_audio);
updatePlaylistRevisionHash(hash, slide.group_key);
updatePlaylistRevisionHash(hash, slide.group_name);
updatePlaylistRevisionHash(hash, slide.group_pick_count);
updatePlaylistRevisionHash(hash, slide.group_shuffle);
updatePlaylistRevisionHash(hash, slide.group_mode);
updatePlaylistRevisionHash(hash, slide.group_repeat_count);
updatePlaylistRevisionHash(hash, slide.group_schedule_rules_json);
});
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
+16 -7
View File
@@ -206,19 +206,28 @@ window.addEventListener('keydown', handlePlayerKeydown);
// Move to the previous or next active slide.
function navigateSlides(offset) {
const manualSlides = getCurrentActiveSlides();
clearSlideTimer();
applyPendingPlaylistUpdate();
let manualSlides = getCurrentActiveSlides();
if (!manualSlides.length) {
return;
}
let currentIndex = manualSlides.findIndex(function (slide) {
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
});
let currentIndex = Number(index);
if (!Number.isInteger(currentIndex) || !manualSlides[currentIndex] || !lastRenderedSlide || manualSlides[currentIndex].id !== lastRenderedSlide.id) {
currentIndex = manualSlides.findIndex(function (slide) {
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
});
}
if (currentIndex < 0) {
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
}
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
clearSlideTimer();
applyPendingPlaylistUpdate();
let nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
if (offset > 0 && currentIndex + offset >= manualSlides.length) {
playlistPassNumber += 1;
clearActiveSlidesCache();
manualSlides = getCurrentActiveSlides();
nextIndex = 0;
}
renderSlideAtIndex(manualSlides, nextIndex);
}
@@ -85,6 +85,7 @@ function applyPendingPlaylistUpdate() {
var nextIndex = Number(index || 0);
slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature;
playlistPassNumber = 0;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
pendingPlaylistUpdate = null;
+79 -2
View File
@@ -21,9 +21,85 @@ function getWebpageUrls(sourceSlides) {
// Filter the slides down to those that are active right now.
function getActiveSlidesFrom(sourceSlides) {
const now = new Date();
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
const eligibleSlides = (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
return isSlideActive(slide, now);
});
const groups = new Map();
eligibleSlides.forEach(function (slide) {
const groupKey = String(slide && slide.groupKey || '');
if (!groupKey) {
return;
}
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey).push(slide);
});
const selectedGroups = new Map();
groups.forEach(function (members, groupKey) {
if (members[0].groupMode === 'repeat') {
const repeatCount = Math.min(100, Math.max(1, Math.floor(Number(members[0].groupRepeatCount) || 1)));
const repeated = [];
for (let repetition = 0; repetition < repeatCount; repetition += 1) {
repeated.push.apply(repeated, members);
}
selectedGroups.set(groupKey, repeated);
return;
}
const pickCount = Math.min(members.length, Math.max(1, Math.floor(Number(members[0].groupPickCount) || 1)));
const passNumber = Number(playlistPassNumber) || 0;
if (members[0].groupShuffle) {
selectedGroups.set(groupKey, members.slice().sort(function (left, right) {
return getGroupSlideSelectionScore(groupKey, left.id, passNumber) - getGroupSlideSelectionScore(groupKey, right.id, passNumber);
}).slice(0, pickCount));
} else {
selectedGroups.set(groupKey, Array.from({ length: pickCount }, function (_value, offset) {
return members[(passNumber * pickCount + offset) % members.length];
}));
}
});
const result = [];
eligibleSlides.forEach(function (slide) {
if (!slide.groupKey) {
result.push(slide);
} else if (selectedGroups.has(slide.groupKey)) {
result.push.apply(result, selectedGroups.get(slide.groupKey));
selectedGroups.delete(slide.groupKey);
}
});
return result;
}
function getGroupSlideSelectionScore(groupKey, slideId, cycleNumber) {
const value = String(groupKey || '') + '|' + String(cycleNumber || 0) + '|' + String(slideId || '');
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hasSelectableGroupVariants() {
const now = new Date();
const eligibleGroups = new Map();
(Array.isArray(slides) ? slides : []).forEach(function (slide) {
const groupKey = String(slide && slide.groupKey || '');
if (!groupKey || !isSlideActive(slide, now)) {
return;
}
if (!eligibleGroups.has(groupKey)) {
eligibleGroups.set(groupKey, []);
}
eligibleGroups.get(groupKey).push(slide);
});
return Array.from(eligibleGroups.values()).some(function (members) {
if (members[0].groupMode === 'repeat') return false;
return members.length > Math.max(1, Number(members[0] && members[0].groupPickCount) || 1)
|| (members.length > 1 && Boolean(members[0].groupShuffle));
});
}
// Build a cache key for the active slide set.
@@ -36,7 +112,8 @@ function getActiveSlidesCacheKey() {
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds()
now.getSeconds(),
playlistPassNumber
].join('|');
}
+11 -5
View File
@@ -193,12 +193,18 @@ function matchesScheduleRule(rule, now) {
// Determine whether a slide should be shown at the current time.
function isSlideActive(slide, now) {
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
if (!rules.length) {
return true;
}
return rules.some(function (rule) {
if (rules.length && !rules.some(function (rule) {
return matchesScheduleRule(rule, now);
});
})) {
return false;
}
const groupRules = parseScheduleRules(slide.groupScheduleRules || slide.group_schedule_rules_json);
if (slide.groupKey && groupRules.length && !groupRules.some(function (rule) {
return matchesScheduleRule(rule, now);
})) {
return false;
}
return true;
}
// Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) {
+11 -3
View File
@@ -122,15 +122,23 @@ function scheduleSlideAdvance(delayMs) {
pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
let activeSlides = getCurrentActiveSlides();
const hasGroupVariants = typeof hasSelectableGroupVariants === 'function' && hasSelectableGroupVariants();
if (activeSlides.length < 2 && !hasGroupVariants) {
showCurrent();
return;
}
if (index >= activeSlides.length) {
index = 0;
}
index = (index + 1) % activeSlides.length;
if (index + 1 >= activeSlides.length) {
playlistPassNumber += 1;
clearActiveSlidesCache();
activeSlides = getCurrentActiveSlides();
index = 0;
} else {
index += 1;
}
showCurrent();
}, holdDelayMs);
}
@@ -3,7 +3,7 @@ const TASK = {
title: 'Onboarding device prune',
category: 'cleanup',
trigger: 'scheduled recurring task, hourly',
purpose: 'remove unbound onboarding devices that have been idle for more than one minute.',
purpose: 'remove unbound pairings idle for more than 15 minutes and screen bindings idle for more than 24 hours.',
taskType: 'recurring-run',
intervalMs: 60 * 60 * 1000
};
+182 -8
View File
@@ -3709,28 +3709,48 @@ td[data-label="Actions"] > div {
flex: 0 0 auto;
}
.playlist-duration-input {
.playlist-duration-input,
.playlist-group-block-quota {
width: 4rem !important;
min-width: 4rem !important;
flex: 0 0 4rem !important;
min-width: 0 !important;
flex: 1 1 4rem !important;
}
.playlist-duration-input-group {
.playlist-duration-input-group,
.playlist-group-block-quota-wrap {
display: inline-flex !important;
flex: 0 0 auto !important;
width: fit-content !important;
max-width: fit-content !important;
width: 6rem !important;
max-width: 6rem !important;
flex-wrap: nowrap;
border-radius: var(--bs-border-radius-sm);
overflow: hidden;
}
.playlist-group-block-quota-wrap {
width: calc(6rem + 0.5rem + 2rem) !important;
max-width: calc(6rem + 0.5rem + 2rem) !important;
}
.playlist-use-video-duration,
.playlist-disable-audio,
[data-playlist-group-shuffle] {
flex: 0 0 2rem;
width: 2rem;
padding-left: 0;
padding-right: 0;
}
.playlist-duration-input::-webkit-outer-spin-button,
.playlist-duration-input::-webkit-inner-spin-button {
.playlist-duration-input::-webkit-inner-spin-button,
.playlist-group-block-quota::-webkit-outer-spin-button,
.playlist-group-block-quota::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-duration-input {
.playlist-duration-input,
.playlist-group-block-quota {
-moz-appearance: textfield;
appearance: textfield;
}
@@ -3839,6 +3859,156 @@ table.table > :not(caption) > * > * {
gap: 0.02rem;
}
.playlist-group-block-row > td {
padding: 0.5rem 0.5rem !important;
border-top: 1px solid var(--bs-border-color) !important;
border-bottom: 1px solid var(--bs-border-color) !important;
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg)) !important;
}
.playlist-group-icon {
color: var(--playlist-group-accent, var(--bs-primary));
}
.playlist-group-block-row > td[data-label="Actions"] {
padding-right: 1.5rem !important;
}
.playlist-group-block-row > td[data-label="Order"] {
padding-left: 1.5rem !important;
}
.playlist-group-block-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.playlist-group-block-slide {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
min-width: 0;
}
.playlist-group-block-name {
flex: 1 1 8rem;
width: auto;
min-width: 6rem;
max-width: 24rem;
font-weight: 600;
}
.playlist-group-mode {
flex: 0 0 auto;
}
[data-playlist-group-select-controls][hidden],
[data-playlist-group-repeat-controls][hidden] {
display: none !important;
}
.playlist-group-child > td[data-label="Slide"] {
padding-left: 1.5rem !important;
}
.playlist-group-empty > td {
height: 3.5rem;
}
.playlist-group-child > td {
background-color: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg));
}
.table > tbody > tr.playlist-group-block-row > td {
box-shadow: none;
}
.table > tbody > tr.playlist-group-child > td:first-child {
box-shadow: inset 4px 0 var(--playlist-group-accent, var(--bs-primary)), inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)));
}
.playlist-schedule-conflict-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.35rem;
border: 1px solid var(--bs-danger-border-subtle);
background: var(--bs-danger-bg-subtle);
color: var(--bs-danger-text-emphasis);
vertical-align: middle;
}
.playlist-group-block-dragging > td {
opacity: 0.55;
}
.playlist-group-block-placeholder > td {
border-bottom: 1px dashed var(--bs-border-color) !important;
opacity: 0.72;
}
.playlist-group-drag-preview {
overflow: hidden;
padding: 0.65rem;
border: 1px solid var(--playlist-group-accent, var(--bs-primary));
border-left-width: 4px;
border-radius: 0.25rem;
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-body-bg));
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.32);
opacity: 0.98;
}
.playlist-group-drag-preview-heading {
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--bs-border-color);
}
.playlist-group-drag-preview-members {
padding-top: 0.35rem;
}
.playlist-group-drag-preview-member {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
padding: 0.2rem 0;
}
.playlist-group-drag-preview-member img,
.playlist-group-drag-preview-placeholder {
flex: 0 0 2.5rem;
width: 2.5rem;
height: 1.45rem;
object-fit: cover;
}
.playlist-group-drag-preview-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bs-secondary-bg);
color: var(--bs-secondary-color);
}
.playlist-group-drag-preview-member > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-group-drag-preview-more {
padding: 0.25rem 0 0 3.05rem;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.playlist-drag-handle {
display: inline-flex;
align-items: center;
@@ -3901,6 +4071,10 @@ table.table > :not(caption) > * > * {
opacity: 0.45;
}
.playlist-row-ghost.playlist-group-child {
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg)) !important;
}
.playlist-row-dragging {
box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18);
opacity: 0.98;
File diff suppressed because it is too large. Load diff
@@ -14,11 +14,26 @@
function measureDraggedRowWidths(row) {
var metrics = {
rowRect: row.getBoundingClientRect(),
cellRects: []
cellRects: [],
rowStyles: {
width: row.style.width,
height: row.style.height,
boxSizing: row.style.boxSizing
},
cellStyles: []
};
Array.prototype.forEach.call(row.children, function (cell) {
metrics.cellRects.push(cell.getBoundingClientRect());
metrics.cellStyles.push({
width: cell.style.width,
height: cell.style.height,
boxSizing: cell.style.boxSizing,
paddingLeft: cell.style.paddingLeft,
paddingRight: cell.style.paddingRight,
paddingTop: cell.style.paddingTop,
paddingBottom: cell.style.paddingBottom
});
});
return metrics;
@@ -61,19 +76,25 @@
});
}
function unlockDraggedRowWidths(row) {
function unlockDraggedRowWidths(row, metrics) {
if (!row) {
return;
}
row.style.width = '';
row.style.height = '';
row.style.boxSizing = '';
var rowStyles = metrics && metrics.rowStyles ? metrics.rowStyles : {};
row.style.width = rowStyles.width || '';
row.style.height = rowStyles.height || '';
row.style.boxSizing = rowStyles.boxSizing || '';
Array.prototype.forEach.call(row.children, function (cell) {
cell.style.width = '';
cell.style.height = '';
cell.style.boxSizing = '';
Array.prototype.forEach.call(row.children, function (cell, index) {
var cellStyles = metrics && metrics.cellStyles && metrics.cellStyles[index] ? metrics.cellStyles[index] : {};
cell.style.width = cellStyles.width || '';
cell.style.height = cellStyles.height || '';
cell.style.boxSizing = cellStyles.boxSizing || '';
cell.style.paddingLeft = cellStyles.paddingLeft || '';
cell.style.paddingRight = cellStyles.paddingRight || '';
cell.style.paddingTop = cellStyles.paddingTop || '';
cell.style.paddingBottom = cellStyles.paddingBottom || '';
});
}
@@ -130,6 +151,7 @@
function initPlaylistTableDrag(options) {
var tbody = options && options.tbody ? options.tbody : null;
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
var onRowDrop = options && typeof options.onRowDrop === 'function' ? options.onRowDrop : function () {};
var rowDragState = null;
if (!tbody) {
@@ -148,6 +170,64 @@
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
}
function getDropTargets() {
return Array.prototype.slice.call(tbody.children).filter(function (node) {
return node === rowDragState.placeholder
|| node.matches('[data-playlist-group-row]')
|| (node.matches('[data-playlist-slide-row]') && !node.hasAttribute('data-playlist-ghost-row'));
});
}
function getGroupAtY(clientY) {
var headers = Array.prototype.slice.call(tbody.querySelectorAll('[data-playlist-group-row]'));
for (var index = 0; index < headers.length; index += 1) {
var header = headers[index];
var groupKey = String(header.getAttribute('data-group-key') || '');
var top = header.getBoundingClientRect().top;
var bottom = header.getBoundingClientRect().bottom;
var member = header.nextElementSibling;
while (member && member.matches('[data-playlist-slide-row], [data-playlist-group-empty]')
&& String(member.getAttribute('data-group-key') || '') === groupKey) {
bottom = member.getBoundingClientRect().bottom;
member = member.nextElementSibling;
}
if (clientY >= top && clientY <= bottom) {
return { key: groupKey, header: header };
}
}
return null;
}
function syncDropTarget(clientY) {
var groupTarget = getGroupAtY(clientY);
var rows = getRows();
var rowTarget = rows.find(function (row) {
var rect = row.getBoundingClientRect();
return clientY >= rect.top && clientY <= rect.bottom;
}) || null;
var nextHeader = groupTarget ? groupTarget.header : null;
var nextRow = !groupTarget ? rowTarget : null;
if (rowDragState.highlightedGroupHeader && rowDragState.highlightedGroupHeader !== nextHeader) {
rowDragState.highlightedGroupHeader.classList.remove('playlist-group-drop-target');
}
if (rowDragState.highlightedRow && rowDragState.highlightedRow !== nextRow) {
rowDragState.highlightedRow.classList.remove('playlist-row-drop-target');
}
if (nextHeader) nextHeader.classList.add('playlist-group-drop-target');
if (nextRow) nextRow.classList.add('playlist-row-drop-target');
rowDragState.highlightedGroupHeader = nextHeader;
rowDragState.highlightedRow = nextRow;
rowDragState.targetGroupKey = groupTarget ? groupTarget.key : '';
}
function clearDropTarget() {
if (!rowDragState) return;
if (rowDragState.highlightedGroupHeader) rowDragState.highlightedGroupHeader.classList.remove('playlist-group-drop-target');
if (rowDragState.highlightedRow) rowDragState.highlightedRow.classList.remove('playlist-row-drop-target');
rowDragState.highlightedGroupHeader = null;
rowDragState.highlightedRow = null;
}
function getDragHandle(target) {
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
}
@@ -242,30 +322,44 @@
}
function repositionGhostRow(clientY) {
var rows = getRows();
var insertBeforeRow = null;
var targets;
var insertBeforeNode = null;
var previousRects = new Map();
var moved = false;
syncDropTarget(clientY);
if (rowDragState.placeholder) {
rowDragState.placeholder.setAttribute('data-group-key', rowDragState.targetGroupKey || '');
rowDragState.placeholder.classList.toggle('playlist-group-child', Boolean(rowDragState.targetGroupKey));
['--playlist-group-accent', '--playlist-group-bg'].forEach(function (property) {
var header = rowDragState.highlightedGroupHeader;
if (header) {
rowDragState.placeholder.style.setProperty(property, header.style.getPropertyValue(property));
} else {
rowDragState.placeholder.style.removeProperty(property);
}
});
}
targets = getDropTargets();
rows.forEach(function (row) {
if (row !== rowDragState.placeholder) {
previousRects.set(row, row.getBoundingClientRect());
targets.forEach(function (node) {
if (node !== rowDragState.placeholder) {
previousRects.set(node, node.getBoundingClientRect());
}
});
for (var index = 0; index < rows.length; index += 1) {
var candidateRow = rows[index];
var rect = candidateRow.getBoundingClientRect();
for (var index = 0; index < targets.length; index += 1) {
var candidateNode = targets[index];
var rect = candidateNode.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
insertBeforeRow = candidateRow;
insertBeforeNode = candidateNode;
break;
}
}
if (insertBeforeRow) {
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
if (insertBeforeNode) {
if (rowDragState.placeholder !== insertBeforeNode && rowDragState.placeholder.nextSibling !== insertBeforeNode) {
tbody.insertBefore(rowDragState.placeholder, insertBeforeNode);
moved = true;
}
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
@@ -310,7 +404,9 @@
rowDragState.row.classList.remove('playlist-row-dragging');
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
rowDragState.row.removeAttribute('aria-grabbed');
unlockDraggedRowWidths(rowDragState.row);
unlockDraggedRowWidths(rowDragState.row, rowDragState.metrics);
onRowDrop(rowDragState.row, rowDragState.targetGroupKey || '');
clearDropTarget();
onOrderChanged();
}
@@ -374,6 +470,7 @@
metrics: measureDraggedRowWidths(row),
startX: point.x,
startY: point.y,
targetGroupKey: String(row.getAttribute('data-group-key') || ''),
currentPoint: point,
threshold: 3,
active: false,
@@ -406,5 +503,6 @@
}
}
window.animatePlaylistRowReorder = animateRowReorder;
window.initPlaylistTableDrag = initPlaylistTableDrag;
}());
+35 -3
View File
@@ -21,6 +21,27 @@ module.exports = function registerManageRoutes(app, deps) {
const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice;
const requirePermission = deps.requirePermission;
async function getScreenLiveConnectionState(screen) {
if (typeof getScreenConnections !== 'function' || !String(screen && screen.slug || '').trim()) {
return { verified: false, inUse: true };
}
try {
const response = await getScreenConnections(screen.slug);
const connections = Array.isArray(response)
? response
: response && Array.isArray(response.connections)
? response.connections
: null;
return {
verified: Array.isArray(connections) && !Boolean(response && response.degraded),
inUse: !Array.isArray(connections) || connections.length > 0
};
} catch (_error) {
return { verified: false, inUse: true };
}
}
const SCREEN_NAME_MAX_LENGTH = 255;
const SCREEN_SLUG_MAX_LENGTH = 255;
@@ -306,9 +327,20 @@ module.exports = function registerManageRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
if (blockMessage) {
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
const forceDelete = String(req.body && req.body.force_delete || '') === '1';
if (forceDelete) {
const liveState = await getScreenLiveConnectionState(screen);
if (!liveState.verified) {
return res.redirect('/screens?message=' + encodeURIComponent('Unable to verify whether this screen has connected players. Try again.'));
}
if (liveState.inUse) {
return res.redirect('/screens?message=' + encodeURIComponent('This screen is still in use by connected players.'));
}
} else {
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
if (blockMessage) {
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
}
}
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.deleted', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: screen.name });
+99 -2
View File
@@ -146,6 +146,13 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
const groupKeys = readRawFieldArray(reqBody, ['group_key[]', 'group_key']);
const groupNames = readRawFieldArray(reqBody, ['group_name[]', 'group_name']);
const groupPickCounts = readRawFieldArray(reqBody, ['group_pick_count[]', 'group_pick_count']);
const groupShuffles = readRawFieldArray(reqBody, ['group_shuffle[]', 'group_shuffle']);
const groupModes = readRawFieldArray(reqBody, ['group_mode[]', 'group_mode']);
const groupRepeatCounts = readRawFieldArray(reqBody, ['group_repeat_count[]', 'group_repeat_count']);
const groupScheduleRulesJson = readRawFieldArray(reqBody, ['group_schedule_rules_json[]', 'group_schedule_rules_json']);
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
if (durations.length && durations.length !== slideIds.length) {
@@ -157,6 +164,11 @@ module.exports = function registerPlaylistRoutes(app, deps) {
if (disableAudios.length && disableAudios.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if ([groupKeys, groupNames, groupPickCounts, groupShuffles, groupModes, groupRepeatCounts, groupScheduleRulesJson].some(function (values) {
return values.length && values.length !== slideIds.length;
})) {
throw createValidationError('Playlist group data is invalid.');
}
if (rowKeys.length && rowKeys.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
@@ -190,6 +202,58 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
const rowKey = String(rowKeys[i] || '').trim();
const groupKey = String(groupKeys[i] || '').trim();
const groupName = String(groupNames[i] || '').trim();
const groupPickCount = Number(groupPickCounts[i] || 1);
const groupShuffle = String(groupShuffles[i] || '0') === '1' ? 1 : 0;
const groupMode = String(groupModes[i] || 'select');
const groupRepeatCount = Number(groupRepeatCounts[i] || 1);
if (!['select', 'repeat'].includes(groupMode) || !Number.isInteger(groupRepeatCount) || groupRepeatCount < 1 || groupRepeatCount > 100) {
throw createValidationError('Playlist group repeat settings are invalid.');
}
let groupScheduleRules = [];
if (groupScheduleRulesJson[i]) {
try {
const parsedRules = JSON.parse(groupScheduleRulesJson[i]);
if (!Array.isArray(parsedRules)) {
throw new Error('Group schedule rules must be an array.');
}
groupScheduleRules = parsedRules.map(function (rule) {
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
throw new Error('Group schedule rule is invalid.');
}
const startDatetime = String(rule.start_datetime || '').trim() || null;
const endDatetime = String(rule.end_datetime || '').trim() || null;
const startTime = String(rule.start_time || '').trim() || null;
const endTime = String(rule.end_time || '').trim() || null;
const days = Array.isArray(rule.days) ? Array.from(new Set(rule.days.map(Number))) : [];
if (Boolean(startDatetime) !== Boolean(endDatetime) || Boolean(startTime) !== Boolean(endTime)
|| (startDatetime && (Number.isNaN(new Date(startDatetime).getTime()) || Number.isNaN(new Date(endDatetime).getTime()) || new Date(endDatetime) < new Date(startDatetime)))
|| (startTime && (!/^\d{2}:\d{2}(?::\d{2})?$/.test(startTime) || !/^\d{2}:\d{2}(?::\d{2})?$/.test(endTime) || endTime < startTime))
|| days.some(function (day) { return !Number.isInteger(day) || day < 0 || day > 6; })) {
throw new Error('Group schedule rule is invalid.');
}
const normalizedRule = {};
if (startDatetime) normalizedRule.start_datetime = startDatetime;
if (endDatetime) normalizedRule.end_datetime = endDatetime;
if (startTime) normalizedRule.start_time = startTime;
if (endTime) normalizedRule.end_time = endTime;
if (days.length) normalizedRule.days = days.sort(function (left, right) { return left - right; });
if (!Object.keys(normalizedRule).length) {
throw new Error('Group schedule rule is empty.');
}
return normalizedRule;
});
} catch (_error) {
throw createValidationError('Playlist group schedule data is invalid.');
}
}
if (groupKey && (!/^[a-zA-Z0-9_-]{1,64}$/.test(groupKey) || !groupName || groupName.length > 128 || !Number.isInteger(groupPickCount) || groupPickCount < 1)) {
throw createValidationError('Playlist group data is invalid.');
}
if (!groupKey && (groupName || groupScheduleRules.length)) {
throw createValidationError('Playlist group data is invalid.');
}
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
for (const rule of playlistScheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
@@ -207,10 +271,43 @@ module.exports = function registerPlaylistRoutes(app, deps) {
durationSeconds,
useVideoDuration,
disableAudio,
groupKey: groupKey || null,
groupName: groupKey ? groupName : null,
groupPickCount: groupKey ? groupPickCount : null,
groupShuffle: groupKey ? groupShuffle : 0,
groupMode: groupKey ? groupMode : 'select',
groupRepeatCount: groupKey ? groupRepeatCount : 1,
groupScheduleRules: groupKey ? groupScheduleRules : [],
scheduleRules: playlistScheduleRules
});
}
const groupSettings = new Map();
const groupSizes = new Map();
normalizedSlides.forEach(function (item) {
if (item.groupKey) groupSizes.set(item.groupKey, (groupSizes.get(item.groupKey) || 0) + 1);
});
normalizedSlides.forEach(function (item) {
if (!item.groupKey) {
return;
}
const serializedRules = JSON.stringify(item.groupScheduleRules);
if (item.groupPickCount > groupSizes.get(item.groupKey)) {
throw createValidationError('Group pick count cannot exceed the number of slides in the group.');
}
const settings = { name: item.groupName, pickCount: item.groupPickCount, shuffle: item.groupShuffle, mode: item.groupMode, repeatCount: item.groupRepeatCount, rules: serializedRules };
const previousSettings = groupSettings.get(item.groupKey);
if (previousSettings && (previousSettings.name.toLowerCase() !== settings.name.toLowerCase()
|| previousSettings.pickCount !== settings.pickCount || previousSettings.shuffle !== settings.shuffle
|| previousSettings.mode !== settings.mode || previousSettings.repeatCount !== settings.repeatCount || previousSettings.rules !== settings.rules)) {
throw createValidationError('All slides in a playlist group must use the same group settings.');
}
if (!previousSettings) {
groupSettings.set(item.groupKey, settings);
}
item.groupName = groupSettings.get(item.groupKey).name;
});
let selectedCanvasId = null;
if (normalizedSlides.length) {
const [slides] = await connection.query(
@@ -249,8 +346,8 @@ module.exports = function registerPlaylistRoutes(app, deps) {
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
const [insertResult] = await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_key, group_name, group_pick_count, group_schedule_rules_json, group_shuffle, group_mode, group_repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey, item.groupName, item.groupPickCount, item.groupKey ? JSON.stringify(item.groupScheduleRules) : null, item.groupShuffle, item.groupMode, item.groupRepeatCount, actorId, actorId]
);
const insertedPlaylistSlideId = Number(insertResult.insertId);
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
@@ -203,6 +203,22 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
useVideoDuration: Boolean(item.use_video_duration),
disableAudio: getDisableAudioValue(item),
durationSeconds: getVideoDurationValue(item),
groupKey: item.group_key || '',
groupName: item.group_name || '',
groupPickCount: Number(item.group_pick_count) || 1,
groupShuffle: Boolean(Number(item.group_shuffle)),
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
groupScheduleRules: Array.isArray(item.group_schedule_rules_json)
? item.group_schedule_rules_json
: (function () {
try {
const parsed = JSON.parse(item.group_schedule_rules_json || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}()),
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
scheduleSummary: scheduleSummary(item)
}));
@@ -282,7 +298,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
playlistCanvasId: playlistCanvasId,
playlistCanvasMismatch: playlistCanvasMismatch,
defaultSlideDurationSeconds: defaultSlideDurationSeconds,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260816.1'],
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20261014.7', 'js/playlists/playlist-schedule.js?v=20261015.8'],
message: message,
currentUser: currentUser || null,
inUse: Boolean(viewPlaylist.inUse)
@@ -40,6 +40,7 @@ function formatTimeInput(value) {
}
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
const scheduleHeading = String(rowKey || '').indexOf('group:') === 0 ? 'Group schedule' : 'Slide schedule';
const slide = playlistSlide || {
id: 0,
scheduleRules: []
@@ -72,7 +73,8 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
}));
return renderFragment('playlists/slide-config', {
title: 'Slide schedule',
title: scheduleHeading,
scheduleHeading: scheduleHeading,
framePopupCard: true,
hideFrameHeader: true,
hideThemeToggle: true,
@@ -19,6 +19,7 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
showSaveSecondaryActions: true,
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
deleteConfirmMessage: Boolean(isEdit) ? 'Delete this screen?' : '',
forceDeleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : '',
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
formAttrs: Boolean(isEdit)
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
+36 -2
View File
@@ -81,6 +81,36 @@ module.exports = function registerScreensRoutes(app, deps) {
return common.fetchPlayerPublicBaseUrl(pool);
}
async function getScreenLiveConnectionState(screen) {
if (typeof deps.getScreenConnections !== 'function' || !String(screen && screen.slug || '').trim()) {
return { verified: false, inUse: true };
}
try {
const response = await deps.getScreenConnections(screen.slug);
const connections = Array.isArray(response)
? response
: response && Array.isArray(response.connections)
? response.connections
: null;
return {
verified: Array.isArray(connections) && !Boolean(response && response.degraded),
inUse: !Array.isArray(connections) || connections.length > 0
};
} catch (_error) {
return { verified: false, inUse: true };
}
}
async function canForceDeleteScreen(screen, blockMessage) {
if (blockMessage !== 'This screen is still linked to onboarding devices.') {
return false;
}
const liveState = await getScreenLiveConnectionState(screen);
return liveState.verified && !liveState.inUse;
}
function buildLauncherContent(templatePath, playerUrl) {
const template = fs.readFileSync(templatePath, 'utf8');
const normalizedPlayerUrl = String(playerUrl || '').trim();
@@ -110,7 +140,9 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections);
screen.inUse = Boolean(blockMessage);
screen.canForceDelete = await canForceDeleteScreen(screen, blockMessage);
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
@@ -135,7 +167,9 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections);
screen.inUse = Boolean(blockMessage);
screen.canForceDelete = await canForceDeleteScreen(screen, blockMessage);
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -32,7 +32,7 @@
</select>
</div>
</div>
<div class="mb-2" data-api-source-request-section>
<div class="mt-3 mb-2" data-api-source-request-section>
<h4 class="api-source-section-heading">Request</h4>
<div class="row g-3 align-items-end">
<div class="col-12">
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261015.6" />
{{#if stylesheets.length}}
{{#each stylesheets}}
<link rel="stylesheet" href="/assets/{{this}}" />
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261015.6" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}
+4 -3
View File
@@ -58,9 +58,10 @@
<div class="row g-4 mb-4">
<div class="col-12">
<div class="card card-outline card-secondary mb-0">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Playlist items</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="card-header bg-body-tertiary d-flex flex-wrap align-items-center row-gap-2">
<h3 class="card-title mb-0">Playlist items</h3>
<div class="card-tools d-flex flex-wrap align-items-center gap-2 my-0 me-0 ms-auto">
<button type="button" class="btn btn-outline-primary btn-sm" id="playlist-create-group"><i class="bi bi-collection" aria-hidden="true"></i> New group</button>
<button type="button" class="btn btn-primary btn-sm" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal" {{#unless playlistCanvasId}}disabled{{/unless}}>Add slide</button>
</div>
</div>
@@ -1,6 +1,6 @@
<div class="card card-outline card-primary mb-0 slide-schedule-card">
<div class="card-header d-flex align-items-center gap-2">
<h3 class="card-title mb-0">Slide schedule</h3>
<h3 class="card-title mb-0">{{scheduleHeading}}</h3>
<div class="card-tools ms-auto">
<button type="button" class="btn btn-primary btn-sm" id="schedule-add-rule">Add another rule</button>
</div>
@@ -1,4 +1,4 @@
<tr data-playlist-slide-row data-row-key="{{slide.row_key}}" data-slide-id="{{slide.slide_id}}" data-canvas-signature="{{slide.canvasSignature}}" data-media-type="{{slide.media_type}}" data-media-path="{{slide.media_path}}" data-video-source-path="{{slide.videoSourcePath}}" data-video-duration-seconds="{{slide.videoDurationSeconds}}" data-use-video-duration-state="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" data-disable-audio-state="{{#if slide.disableAudio}}true{{else}}false{{/if}}">
<tr data-playlist-slide-row data-row-key="{{slide.row_key}}" data-slide-id="{{slide.slide_id}}" data-group-key="{{slide.groupKey}}" data-canvas-signature="{{slide.canvasSignature}}" data-media-type="{{slide.media_type}}" data-media-path="{{slide.media_path}}" data-video-source-path="{{slide.videoSourcePath}}" data-video-duration-seconds="{{slide.videoDurationSeconds}}" data-use-video-duration-state="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" data-disable-audio-state="{{#if slide.disableAudio}}true{{else}}false{{/if}}">
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<div class="playlist-order-stepper" aria-label="Reorder slide controls">
@@ -24,6 +24,13 @@
</div>
<input type="hidden" name="slide_id[]" value="{{slide.slide_id}}" form="playlist-edit-form" data-playlist-slide-id-input />
<input type="hidden" name="row_key[]" value="{{slide.row_key}}" form="playlist-edit-form" data-playlist-row-key-input />
<input type="hidden" name="group_key[]" value="{{slide.groupKey}}" form="playlist-edit-form" data-playlist-group-key />
<input type="hidden" name="group_name[]" value="{{slide.groupName}}" form="playlist-edit-form" data-playlist-group-name-value />
<input type="hidden" name="group_pick_count[]" value="{{#if slide.groupPickCount}}{{slide.groupPickCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-pick-count-value />
<input type="hidden" name="group_shuffle[]" value="{{#if slide.groupShuffle}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-group-shuffle-value />
<input type="hidden" name="group_mode[]" value="{{#if slide.groupMode}}{{slide.groupMode}}{{else}}select{{/if}}" form="playlist-edit-form" data-playlist-group-mode-value />
<input type="hidden" name="group_repeat_count[]" value="{{#if slide.groupRepeatCount}}{{slide.groupRepeatCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-repeat-count-value />
<input type="hidden" name="group_schedule_rules_json[]" value="{{#if slide.groupScheduleRules}}{{json slide.groupScheduleRules}}{{else}}[]{{/if}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
</td>
<td data-label="Rules">
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
@@ -33,7 +40,7 @@
<div class="playlist-duration-field">
<div class="input-group input-group-sm flex-nowrap playlist-duration-input-group">
<span class="input-group-text playlist-duration-prefix text-body-secondary" aria-hidden="true"><i class="bi bi-clock"></i></span>
<input name="duration_seconds[]" type="text" inputmode="decimal" min="1" value="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-limit-input-value data-playlist-duration-input />
<input name="duration_seconds[]" type="number" inputmode="decimal" min="1" step="any" value="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-limit-input-value data-playlist-duration-input />
</div>
<input type="hidden" name="use_video_duration[]" value="{{#if slide.useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-use-video-duration-input />
<input type="hidden" name="disable_audio[]" value="{{#if slide.disableAudio}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-disable-audio-input />
+12 -2
View File
@@ -33,10 +33,20 @@
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
<div class="card-footer d-flex align-items-center gap-2">
{{#if screen.canForceDelete}}
<button type="submit" form="screen-force-delete-form" class="btn btn-outline-danger">Force delete</button>
{{/if}}
<div class="ms-auto">
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
</form>
{{#if screen.canForceDelete}}
<form id="screen-force-delete-form" method="post" action="{{forceDeleteUrl}}" data-confirm-message="Force delete this screen group and unlink its onboarding devices?">
<input type="hidden" name="force_delete" value="1" />
</form>
{{/if}}
</div>
</div>