Release v2.15.2
This commit is contained in:
1 parent
ddcc665f81
commit
7295f83f04
43 files changed
+2526
-104
No files matched your search
@@ -2,6 +2,40 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.15.2 - 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stale screen bindings without a recorded heartbeat now expire based on their last modification time.
|
||||
- Screen groups can be force-deleted when the live connection check confirms no players are connected.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Pick / Repeat group modes beside the group title. Repeat plays the entire eligible group in order for the configured number of passes, independently of Pick and shuffle settings.
|
||||
|
||||
## 2.15.1 - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added an optional shuffle toggle for playlist groups; ordered rotation is now the default.
|
||||
|
||||
### Changed
|
||||
|
||||
- Group selection fills the configured Pick count on every pass, wrapping when necessary, subject to schedule eligibility.
|
||||
- Group Pick values are limited to a minimum of one and a maximum of the group's slide count.
|
||||
|
||||
## 2.15.0 - 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added playlist groups with configurable per-pass selection counts and independent multi-rule display schedules.
|
||||
|
||||
## 2.14.3 - 2026-10-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored spacing above the Request section when POST is selected on the API source form.
|
||||
|
||||
## 2.14.2 - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.14.2",
|
||||
"version": "2.15.2",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.14.2",
|
||||
"version": "2.15.2",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
+69
-8
@@ -199,19 +199,80 @@ A playlist is the sequence a screen plays. It controls which slides appear, the
|
||||
|
||||
Use playlists to organize content by destination, audience, or purpose. A lobby playlist, for example, may combine welcome messages, schedules, announcements, and a repeating information slide without changing the underlying templates.
|
||||
|
||||
When editing a playlist:
|
||||
#### Building a Playlist
|
||||
|
||||
- Add only the slides that belong to that display experience.
|
||||
- Reorder slides to establish the intended viewing sequence.
|
||||
- Review each slide's duration and the overall rhythm of the sequence.
|
||||
- Configure transitions where a change in visual pacing is useful.
|
||||
- Decide how unavailable streams should be handled if the playlist uses live media.
|
||||
- Save the playlist before checking the player.
|
||||
1. Choose the playlist's canvas size and give the playlist a recognizable name.
|
||||
2. Choose **Add slide** in the **Playlist items** header, select the slides, and add them. Slides must match the playlist's canvas size. The same slide can only be assigned once to a playlist; use a Repeat group when it needs to play multiple times.
|
||||
3. Use the drag handle or up/down arrows to arrange the slides. You can also organize slides into groups as described below.
|
||||
4. Review each slide's duration, audio settings, and schedule.
|
||||
5. Configure transitions and the handling of unavailable live streams where appropriate.
|
||||
6. Save the playlist before checking the player. Unsaved editor changes do not affect playback.
|
||||
|
||||
The order is significant: viewers see slides from top to bottom and then return to the beginning. Use longer durations for slides with more text or live data, and use shorter durations for simple notices. A transition changes how one slide gives way to the next; it does not change the region animations inside either slide.
|
||||
The player works through the playlist from top to bottom, then starts another playthrough. Groups stay in their assigned position; their mode determines which members play at that position.
|
||||
|
||||
#### Slide Playback Controls
|
||||
|
||||
- **Duration:** enter the time in seconds. Numeric values can include fractional seconds, such as `8.341`. Use longer durations for text-heavy slides and shorter durations for simple notices.
|
||||
- **Video duration:** on supported video slides, the camera icon toggles using the video's duration instead of a manually entered duration.
|
||||
- **Audio:** use the speaker/mute button to enable or disable the slide's audio.
|
||||
- **Schedule:** configure when that playlist slide is eligible to play. A slide without schedule rules is always eligible unless its group schedule restricts it.
|
||||
|
||||
A transition changes how one slide gives way to the next; it does not change the region animations inside either slide.
|
||||
|
||||
#### Creating and Organizing Groups
|
||||
|
||||
Choose **New group**, to the left of **Add slide**, to create a group. Enter its name and drag slides into the blank, colour-matched area beneath the header. The empty area disappears when the group has members and returns if its last slide is moved out. Add at least one slide before saving if you want to retain the group; empty groups are not saved.
|
||||
|
||||
Group headers and their members share a colour, with alternating shading between member rows. The colours help distinguish groups and do not affect playback.
|
||||
|
||||
Use a group's drag handle or arrows to move the whole group, including its members. Drag individual slides to reorder them within a group, move them to another group, or move them outside groups. Removing a group leaves its member slides in the playlist as standalone slides.
|
||||
|
||||
The **Order** column numbers groups as top-level playlist entries. For example, group `8` contains slides `8.1`, `8.2`, and so on; the next standalone slide or group is `9`. These labels update automatically when you move items.
|
||||
|
||||
#### Pick and Repeat Modes
|
||||
|
||||
The **Pick / Repeat** selector sits immediately after the group name. The Playback column shows the controls for the selected mode. Switching modes remembers the settings for both modes.
|
||||
|
||||
**Pick** selects a specified number of eligible members on each playlist playthrough:
|
||||
|
||||
- Set **Pick** from `1` to the number of slides in the group. The maximum updates as slides are added or removed. The input is disabled while the group is empty.
|
||||
- With the **double-chevron** button shown, selection follows the group's editor order and wraps around to the beginning when needed.
|
||||
- Toggle that button to **shuffle arrows** for shuffled selection and playback order. A selection stays fixed for the current playthrough; the next playthrough makes another selection. A slide can be selected on consecutive playthroughs, but it is not selected twice within the same group's selection on one playthrough.
|
||||
- The player fills the Pick count whenever enough members are schedule-eligible. If fewer are eligible, only those available members play; unavailable slides are not used to fill the count.
|
||||
|
||||
For three eligible members in editor order A, B, C:
|
||||
|
||||
| Settings | First playthrough | Second playthrough | Third playthrough |
|
||||
| --- | --- | --- | --- |
|
||||
| Pick 1, ordered | A | B | C |
|
||||
| Pick 2, ordered | A, B | C, A | B, C |
|
||||
| Pick 2, shuffled | For example: C, A | For example: A, B | For example: C, A |
|
||||
|
||||
Shuffled results are illustrative, not a guaranteed sequence or a promise that every member will play before another repeats. Selection is calculated by the player; restarting it or applying a playlist update resets its playthrough tracking. Changes in schedule eligibility can also change which members are selected.
|
||||
|
||||
**Repeat** plays the entire eligible group in editor order, a specified number of times, before continuing to the next playlist entry:
|
||||
|
||||
- Set **Repeat** from `1` to `100`. This is the total number of passes through the group, not a count of additional repeats.
|
||||
- Pick and shuffle controls are hidden and do not affect Repeat playback.
|
||||
- For A, B, C with Repeat `2`, playback is **A, B, C, A, B, C**, then the next playlist entry.
|
||||
- Repeat `1` plays the whole eligible group once per playlist playthrough.
|
||||
|
||||
#### Group and Slide Schedules
|
||||
|
||||
Use **Schedule** on a group header to restrict the whole group. Use **Schedule** on a member row to restrict that slide independently.
|
||||
|
||||
A grouped slide must satisfy both its own schedule and its group's schedule. Within either schedule, multiple rules are alternatives: matching any rule is enough. Within one rule, all configured conditions must match, including any date range, time window, and selected days.
|
||||
|
||||
For example, a group scheduled for weekdays from 09:00 to 17:00 and a member scheduled from 12:00 to 14:00 can only show that member on weekdays between 12:00 and 14:00. Pick and Repeat both respect these restrictions.
|
||||
|
||||
When a member's schedule has no overlap with the group schedule, the editor shows a **Never shown** warning. Adjust the slide or group rules so they overlap. If no members are eligible, the group contributes no slides to playback at that time.
|
||||
|
||||
#### Publishing Playlist Changes
|
||||
|
||||
A playlist change affects the screens using that playlist. A slide change can affect every playlist that includes that slide, so choose the object that matches the scope of the change you intend to make.
|
||||
|
||||
After saving, check a screen using the playlist and confirm the intended order, duration, group mode, and schedules. Saved updates are applied by the player at a slide transition rather than interrupting the slide currently playing.
|
||||
|
||||
### Canvas Sizes
|
||||
|
||||
Canvas sizes describe the dimensions or aspect ratios a design is meant to use. They help templates and slides stay predictable when a display has a known resolution or orientation.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.14.2",
|
||||
"version": "2.15.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.14.2",
|
||||
"version": "2.15.2",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.14.2",
|
||||
"version": "2.15.2",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -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
@@ -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))`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('|');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}());
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}" />
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const { pruneStaleOnboardingDevices } = require('../src/db/common');
|
||||
|
||||
test('prunes old unbound onboarding devices without deleting completed bindings', async () => {
|
||||
test('prunes old unbound pairings and stale screen bindings', async () => {
|
||||
let queryText = '';
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
@@ -16,7 +16,7 @@ test('prunes old unbound onboarding devices without deleting completed bindings'
|
||||
|
||||
assert.match(queryText, /screen_id IS NULL/);
|
||||
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 15 MINUTE\)/);
|
||||
assert.match(queryText, /last_seen_at < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
|
||||
assert.match(queryText, /COALESCE\(last_seen_at, modified_at\) < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
|
||||
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1003,6 +1003,57 @@ test('player page arrow keys move between slides', async () => {
|
||||
assert.deepEqual(calls, [0, 2]);
|
||||
});
|
||||
|
||||
test('manual forward wrap advances the playlist group pass', () => {
|
||||
const handlers = Object.create(null);
|
||||
const calls = [];
|
||||
const firstPassSlides = [{ id: 1 }, { id: 2 }];
|
||||
const nextPassSlides = [{ id: 3 }, { id: 4 }];
|
||||
const sandbox = {
|
||||
window: null,
|
||||
console,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Boolean,
|
||||
Number,
|
||||
Math,
|
||||
Date,
|
||||
JSON,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
||||
addEventListener(type, handler) { handlers[type] = handler; },
|
||||
slides: firstPassSlides,
|
||||
lastRenderedSlide: { id: 2 },
|
||||
index: 1,
|
||||
playlistPassNumber: 0,
|
||||
timer: null,
|
||||
slideOutroTimers: [],
|
||||
getCurrentActiveSlides() { return sandbox.playlistPassNumber ? nextPassSlides : firstPassSlides; },
|
||||
clearSlideTimer() {},
|
||||
clearActiveSlidesCache() {},
|
||||
applyPendingPlaylistUpdate() {},
|
||||
renderSlideAtIndex(sourceSlides, targetIndex) { calls.push({ sourceSlides, targetIndex }); }
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
||||
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
||||
sandbox.navigateSlides(1);
|
||||
|
||||
assert.equal(sandbox.playlistPassNumber, 1);
|
||||
assert.deepEqual(calls, [{ sourceSlides: nextPassSlides, targetIndex: 0 }]);
|
||||
const repeated = [{ id: 1 }, { id: 2 }, { id: 1 }, { id: 2 }, { id: 9 }];
|
||||
sandbox.getCurrentActiveSlides = () => repeated;
|
||||
sandbox.index = 3;
|
||||
sandbox.lastRenderedSlide = { id: 2 };
|
||||
sandbox.navigateSlides(1);
|
||||
assert.equal(calls.at(-1).targetIndex, 4);
|
||||
sandbox.navigateSlides(-1);
|
||||
assert.equal(calls.at(-1).targetIndex, 2);
|
||||
});
|
||||
|
||||
test('playlist removal of the current slide queues without rerendering it immediately', async () => {
|
||||
const calls = {
|
||||
showCurrent: 0,
|
||||
|
||||
@@ -63,6 +63,13 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
duration_seconds: 9,
|
||||
use_video_duration: 1,
|
||||
disable_audio: null,
|
||||
group_key: 'news',
|
||||
group_name: 'News',
|
||||
group_pick_count: 2,
|
||||
group_shuffle: 1,
|
||||
group_mode: 'repeat',
|
||||
group_repeat_count: 2,
|
||||
group_schedule_rules_json: '[{"start_time":"08:00","end_time":"12:00","days":[1,2,3]}]',
|
||||
template_name: 'Template 33',
|
||||
canvas_size_name: 'HD',
|
||||
canvas_size_width: 1920,
|
||||
@@ -134,6 +141,16 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
|
||||
assert.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
|
||||
assert.equal(payload.slides[0].scheduleRules.length, 2);
|
||||
assert.equal(payload.slides[0].groupKey, 'news');
|
||||
assert.equal(payload.slides[0].groupName, 'News');
|
||||
assert.equal(payload.slides[0].groupPickCount, 2);
|
||||
assert.equal(payload.slides[0].groupShuffle, true);
|
||||
assert.equal(payload.slides[0].groupMode, 'repeat');
|
||||
assert.equal(payload.slides[0].groupRepeatCount, 2);
|
||||
assert.equal(payload.slides[1].groupMode, 'select');
|
||||
assert.equal(payload.slides[1].groupRepeatCount, 1);
|
||||
assert.equal(payload.slides[1].groupShuffle, false);
|
||||
assert.deepEqual(payload.slides[0].groupScheduleRules, [{ start_time: '08:00', end_time: '12:00', days: [1, 2, 3] }]);
|
||||
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
|
||||
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
|
||||
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
|
||||
|
||||
@@ -46,6 +46,99 @@ test('media preload cleanup releases unpromoted video elements on every early ex
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist groups rotate in order, fill every pass, and optionally shuffle', () => {
|
||||
const sandbox = {
|
||||
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
|
||||
playlistPassNumber: 0
|
||||
};
|
||||
const renderingScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
|
||||
const playlistScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
|
||||
loadScript(renderingScriptPath, sandbox);
|
||||
loadScript(playlistScriptPath, sandbox);
|
||||
|
||||
const groupSlides = Array.from({ length: 5 }, function (_value, index) {
|
||||
return { id: index + 1, groupKey: 'news', groupPickCount: 2 };
|
||||
});
|
||||
sandbox.slides = groupSlides.concat([{ id: 99 }]);
|
||||
|
||||
function selectedGroupIds() {
|
||||
return Array.from(sandbox.getActiveSlidesFrom(sandbox.slides)
|
||||
.filter(function (slide) { return slide.groupKey; })
|
||||
.map(function (slide) { return slide.id; }));
|
||||
}
|
||||
|
||||
const firstPass = selectedGroupIds();
|
||||
assert.equal(firstPass.length, 2);
|
||||
assert.deepEqual(selectedGroupIds(), firstPass);
|
||||
sandbox.playlistPassNumber = 1;
|
||||
const secondPass = selectedGroupIds();
|
||||
assert.equal(secondPass.length, 2);
|
||||
assert.equal(firstPass.some(function (id) { return secondPass.includes(id); }), false);
|
||||
sandbox.playlistPassNumber = 2;
|
||||
const thirdPass = selectedGroupIds();
|
||||
assert.equal(thirdPass.length, 2);
|
||||
assert.equal(new Set(firstPass.concat(secondPass, thirdPass)).size, 5);
|
||||
assert.deepEqual(firstPass, [1, 2]);
|
||||
assert.deepEqual(secondPass, [3, 4]);
|
||||
assert.deepEqual(thirdPass, [5, 1]);
|
||||
groupSlides.forEach(slide => { slide.groupShuffle = true; });
|
||||
const shuffled = selectedGroupIds();
|
||||
assert.equal(shuffled.length, 2);
|
||||
assert.equal(new Set(shuffled).size, 2);
|
||||
assert.deepEqual(selectedGroupIds(), shuffled);
|
||||
const selections = new Set();
|
||||
for (let pass = 0; pass < 10; pass += 1) {
|
||||
sandbox.playlistPassNumber = pass;
|
||||
const selection = selectedGroupIds();
|
||||
assert.equal(selection.length, 2);
|
||||
assert.equal(new Set(selection).size, 2);
|
||||
selections.add(selection.join(','));
|
||||
}
|
||||
assert.ok(selections.size > 1);
|
||||
sandbox.slides = groupSlides.slice(0, 3);
|
||||
sandbox.slides.forEach(slide => { slide.groupShuffle = false; });
|
||||
for (const [pass, expected] of [[0, [1, 2]], [1, [3, 1]], [2, [2, 3]]]) {
|
||||
sandbox.playlistPassNumber = pass;
|
||||
assert.deepEqual(selectedGroupIds(), expected);
|
||||
}
|
||||
sandbox.slides[0].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
|
||||
assert.deepEqual(selectedGroupIds(), [2, 3]);
|
||||
sandbox.slides[1].groupPickCount = 99;
|
||||
assert.equal(selectedGroupIds().length, 2);
|
||||
sandbox.slides[2].scheduleRules = sandbox.slides[0].scheduleRules;
|
||||
assert.deepEqual(selectedGroupIds(), [2]);
|
||||
|
||||
const now = new Date(2026, 9, 14, 10, 30);
|
||||
const gatedSlide = {
|
||||
groupKey: 'weekday',
|
||||
groupScheduleRules: [{ start_time: '10:00', end_time: '11:00', days: [now.getDay()] }]
|
||||
};
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, now), true);
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false);
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 15, 10, 30)), false);
|
||||
});
|
||||
|
||||
test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => {
|
||||
const sandbox = {
|
||||
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
|
||||
playlistPassNumber: 0
|
||||
};
|
||||
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js'), sandbox);
|
||||
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
|
||||
sandbox.slides = [{ id: 9 }].concat([1, 2, 3].map(id => ({
|
||||
id, groupKey: 'news', groupMode: 'repeat', groupRepeatCount: 2, groupPickCount: 1, groupShuffle: true
|
||||
})), [{ id: 10 }]);
|
||||
const ids = () => Array.from(sandbox.getActiveSlidesFrom(sandbox.slides), slide => slide.id);
|
||||
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
|
||||
sandbox.playlistPassNumber = 1;
|
||||
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
|
||||
assert.equal(sandbox.hasSelectableGroupVariants(), false);
|
||||
sandbox.slides[2].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
|
||||
assert.deepEqual(ids(), [9, 1, 3, 1, 3, 10]);
|
||||
sandbox.slides.forEach(slide => { if (slide.groupKey) slide.groupRepeatCount = 1; });
|
||||
assert.deepEqual(ids(), [9, 1, 3, 10]);
|
||||
});
|
||||
|
||||
test('warmed video node is promoted into its slide instead of fetched again', () => {
|
||||
const createdVideos = [];
|
||||
let nextSource = '';
|
||||
|
||||
@@ -46,6 +46,14 @@ test('playlist duplicate form page reuses the copied playlist id for rows', asyn
|
||||
assert.match(html, /data-playlist-id="12"/);
|
||||
assert.match(html, /Welcome/);
|
||||
assert.match(html, /Save and Close/);
|
||||
assert.ok(html.indexOf('id="playlist-create-group"') < html.indexOf('id="playlist-open-slide-modal"'));
|
||||
assert.doesNotMatch(html, /<th>Group<\/th>/);
|
||||
assert.match(html, /name="group_key\[\]"/);
|
||||
assert.match(html, /name="group_schedule_rules_json\[\]"/);
|
||||
assert.match(html, /name="group_shuffle\[\]" value="0"/);
|
||||
assert.match(html, /name="group_mode\[\]" value="select"/);
|
||||
assert.match(html, /name="group_repeat_count\[\]" value="1"/);
|
||||
assert.match(html, /name="duration_seconds\[\]" type="number" inputmode="decimal" min="1" step="any"/);
|
||||
});
|
||||
|
||||
test('playlist list template includes duplicate action', () => {
|
||||
@@ -54,3 +62,176 @@ test('playlist list template includes duplicate action', () => {
|
||||
assert.match(template, /\/playlists\/\{\{id\}\}\/duplicate/);
|
||||
assert.match(template, />Dupe<\/a>/);
|
||||
});
|
||||
|
||||
test('playlist group colours differ, survive reorder, and clear on ungrouped slides', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||
const functionSource = source.match(/function updatePlaylistGroupColours\(rows, blocks\) \{[\s\S]*?\n \}/)[0];
|
||||
const updateColours = require('node:vm').runInNewContext('(' + functionSource + ')');
|
||||
function makeRow(isHeader) {
|
||||
const attributes = new Map();
|
||||
return {
|
||||
style: { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } },
|
||||
getAttribute(key) { return attributes.get(key) || null; },
|
||||
setAttribute(key, value) { attributes.set(key, value); },
|
||||
matches() { return isHeader; }
|
||||
};
|
||||
}
|
||||
const firstHeader = makeRow(true);
|
||||
const secondHeader = makeRow(true);
|
||||
firstHeader.setAttribute('data-group-colour', 'primary');
|
||||
secondHeader.setAttribute('data-group-colour', 'secondary');
|
||||
const firstMember = makeRow(false);
|
||||
const secondMember = makeRow(false);
|
||||
const blocks = [{ nodes: [firstHeader, firstMember] }, { nodes: [secondHeader, secondMember] }];
|
||||
updateColours([firstMember, secondMember], blocks);
|
||||
const firstColour = firstHeader.style['--playlist-group-accent'];
|
||||
const secondColour = secondHeader.style['--playlist-group-accent'];
|
||||
assert.notEqual(firstColour, secondColour);
|
||||
assert.equal(firstHeader.getAttribute('data-group-colour'), 'success');
|
||||
assert.equal(secondHeader.getAttribute('data-group-colour'), 'warning');
|
||||
const paletteHeaders = Array.from({ length: 8 }, () => makeRow(true));
|
||||
updateColours([], paletteHeaders.map(header => ({ nodes: [header] })));
|
||||
paletteHeaders.forEach(header => {
|
||||
assert.ok(['success', 'warning', 'info', 'danger'].includes(header.getAttribute('data-group-colour')));
|
||||
});
|
||||
assert.equal(firstMember.style['--playlist-group-accent'], firstColour);
|
||||
assert.equal(secondMember.style['--playlist-group-bg'], secondHeader.style['--playlist-group-bg']);
|
||||
updateColours([secondMember, firstMember], blocks.slice().reverse());
|
||||
assert.equal(firstHeader.style['--playlist-group-accent'], firstColour);
|
||||
assert.equal(secondHeader.style['--playlist-group-accent'], secondColour);
|
||||
blocks[0].nodes = [firstHeader];
|
||||
updateColours([firstMember, secondMember], blocks);
|
||||
assert.equal(firstMember.style['--playlist-group-accent'], undefined);
|
||||
assert.equal(firstMember.style['--playlist-group-bg'], undefined);
|
||||
});
|
||||
|
||||
test('empty playlist groups keep a drop area until a member is added', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||
const functionSource = source.match(/function updatePlaylistGroupEmptyRows\(\) \{[\s\S]*?\n \}/)[0];
|
||||
const emptyRows = new Set();
|
||||
const members = [];
|
||||
const header = { getAttribute() { return 'new-group'; }, after(row) { emptyRows.add(row); } };
|
||||
const tbody = { querySelectorAll() { return Array.from(emptyRows); } };
|
||||
const updateEmptyRows = require('node:vm').runInNewContext('(' + functionSource + ')', {
|
||||
document: {
|
||||
getElementById() { return tbody; },
|
||||
createElement() {
|
||||
return {
|
||||
attributes: {},
|
||||
setAttribute(key, value) { this.attributes[key] = value; },
|
||||
remove() { emptyRows.delete(this); }
|
||||
};
|
||||
}
|
||||
},
|
||||
getGroupHeaders() { return [header]; },
|
||||
getGroupRows() { return members; }
|
||||
});
|
||||
updateEmptyRows();
|
||||
assert.equal(emptyRows.size, 1);
|
||||
const emptyRow = Array.from(emptyRows)[0];
|
||||
assert.equal(emptyRow.attributes['data-group-key'], 'new-group');
|
||||
assert.match(emptyRow.innerHTML, /colspan="5"/);
|
||||
assert.doesNotMatch(emptyRow.innerHTML, /<input/);
|
||||
updateEmptyRows();
|
||||
assert.equal(emptyRows.size, 1);
|
||||
members.push({});
|
||||
updateEmptyRows();
|
||||
assert.equal(emptyRows.size, 0);
|
||||
members.pop();
|
||||
updateEmptyRows();
|
||||
assert.equal(emptyRows.size, 1);
|
||||
});
|
||||
|
||||
test('group metadata clamps Pick to membership and propagates shuffle state', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||
const functionSource = source.match(/function syncGroupRowMetadata\(header\) \{[\s\S]*?\n \}/)[0];
|
||||
const pick = { value: '99' };
|
||||
const controls = {
|
||||
'[data-playlist-group-name]': { value: 'News' },
|
||||
'[data-playlist-group-pick-count]': pick,
|
||||
'[data-playlist-group-repeat-count]': { value: '2' },
|
||||
'[data-playlist-group-shuffle]': { getAttribute() { return 'true'; } },
|
||||
'[data-playlist-group-schedule-rules]': { value: '[]' }
|
||||
};
|
||||
let mode = 'select';
|
||||
const header = { getAttribute(key) { return key === 'data-group-mode' ? mode : 'news'; }, querySelector(selector) { return controls[selector]; } };
|
||||
const members = Array.from({ length: 3 }, () => {
|
||||
const fields = new Map();
|
||||
return {
|
||||
classList: { add() {} }, setAttribute() {},
|
||||
querySelector(selector) { if (!fields.has(selector)) fields.set(selector, {}); return fields.get(selector); }
|
||||
};
|
||||
});
|
||||
const sync = require('node:vm').runInNewContext('(' + functionSource + ')', { getGroupRows() { return members; }, syncPlaylistGroupModeControls() {} });
|
||||
sync(header);
|
||||
assert.equal(pick.value, '3');
|
||||
assert.equal(pick.max, '3');
|
||||
assert.equal(members[0].querySelector('[data-playlist-group-shuffle-value]').value, '1');
|
||||
assert.equal(members[0].querySelector('[data-playlist-group-mode-value]').value, 'select');
|
||||
mode = 'repeat';
|
||||
sync(header);
|
||||
assert.equal(members[0].querySelector('[data-playlist-group-mode-value]').value, 'repeat');
|
||||
assert.equal(members[0].querySelector('[data-playlist-group-repeat-count-value]').value, '2');
|
||||
assert.equal(members[0].querySelector('[data-playlist-group-shuffle-value]').value, '1');
|
||||
controls['[data-playlist-group-repeat-count]'].value = '101';
|
||||
sync(header);
|
||||
assert.equal(controls['[data-playlist-group-repeat-count]'].value, '100');
|
||||
controls['[data-playlist-group-repeat-count]'].value = '0';
|
||||
sync(header);
|
||||
assert.equal(controls['[data-playlist-group-repeat-count]'].value, '1');
|
||||
pick.value = '0';
|
||||
sync(header);
|
||||
assert.equal(pick.value, '1');
|
||||
pick.value = '2.9';
|
||||
sync(header);
|
||||
assert.equal(pick.value, '2');
|
||||
members.splice(1);
|
||||
sync(header);
|
||||
assert.equal(pick.value, '1');
|
||||
assert.equal(pick.max, '1');
|
||||
members.pop();
|
||||
sync(header);
|
||||
assert.equal(pick.disabled, true);
|
||||
});
|
||||
|
||||
test('playlist numbering gives groups whole numbers and members hierarchical numbers', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||
const functionSource = source.match(/function updateRowOrder\(markDirty\) \{[\s\S]*?\n \}/)[0];
|
||||
function makeRow(groupKey, isHeader) {
|
||||
const number = { textContent: '' };
|
||||
const key = { value: groupKey };
|
||||
return {
|
||||
number, key,
|
||||
matches() { return Boolean(isHeader); },
|
||||
querySelector(selector) {
|
||||
if (selector === '.playlist-order-number' || (isHeader && selector === '[data-playlist-group-order]')) return number;
|
||||
if (selector === '[data-playlist-group-key]') return key;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
const first = makeRow('');
|
||||
const header = makeRow('news', true);
|
||||
const members = [makeRow('news'), makeRow('news')];
|
||||
const last = makeRow('');
|
||||
const rows = [first, ...members, last];
|
||||
const blocks = [{ key: '', nodes: [first] }, { key: 'news', nodes: [header, ...members] }, { key: '', nodes: [last] }];
|
||||
const sandbox = {
|
||||
getRows() { return rows; }, getGroupHeaders() { return [header]; }, getTopLevelPlaylistBlocks() { return blocks; }
|
||||
};
|
||||
for (const name of ['updatePlaylistGroupEmptyRows', 'syncGroupRowMetadata', 'updatePlaylistGroupColours', 'syncAddSlideOptions', 'updateGroupScheduleWarnings', 'syncPlaylistCanvasLock', 'syncAddSlideButtonState', 'updateEmptyState']) {
|
||||
sandbox[name] = function () {};
|
||||
}
|
||||
const update = require('node:vm').runInNewContext('(' + functionSource + ')', sandbox);
|
||||
update(false);
|
||||
assert.deepEqual([first, header, ...members, last].map(row => row.number.textContent), ['1', '2', '2.1', '2.2', '3']);
|
||||
blocks.unshift(blocks.splice(1, 1)[0]);
|
||||
update(false);
|
||||
assert.deepEqual([header, ...members, first, last].map(row => row.number.textContent), ['1', '1.1', '1.2', '2', '3']);
|
||||
members[1].key.value = '';
|
||||
blocks[0].nodes.pop();
|
||||
blocks.splice(1, 0, { key: '', nodes: [members[1]] });
|
||||
update(false);
|
||||
assert.equal(members[1].number.textContent, '2');
|
||||
assert.equal(last.number.textContent, '4');
|
||||
});
|
||||
@@ -28,7 +28,8 @@ function createResponse() {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
send() {
|
||||
send(body) {
|
||||
this.body = body;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
@@ -36,12 +37,13 @@ function createResponse() {
|
||||
|
||||
test('playlist create and update redirect to the canonical edit path', async () => {
|
||||
const calls = [];
|
||||
let groupInsertParams = null;
|
||||
const connection = {
|
||||
async beginTransaction() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {},
|
||||
async query(sql) {
|
||||
async query(sql, params) {
|
||||
if (sql.startsWith('INSERT INTO c_playlists')) {
|
||||
return [{ insertId: 4 }];
|
||||
}
|
||||
@@ -53,6 +55,14 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
calls.push('delete-slides');
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
|
||||
return [[{ id: 11, canvas_size_id: 7 }]];
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||
groupInsertParams = params;
|
||||
calls.push('insert-slide');
|
||||
return [{ insertId: 101 }];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
@@ -73,7 +83,14 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
fetchOrderedPlaylistSlides: async () => [],
|
||||
fetchScreensByPlaylistId: async () => [],
|
||||
fetchPlaylistCanvasId: async () => null,
|
||||
readArrayField: () => [],
|
||||
readArrayField(body, keys) {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||
return Array.isArray(body[key]) ? body[key] : [body[key]];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
getAuditUserId: () => 12,
|
||||
redirectAfterSave(req, res, url) {
|
||||
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
|
||||
@@ -110,17 +127,55 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
params: { id: '4' },
|
||||
body: {
|
||||
name: 'Lunch Loop',
|
||||
slide_id: []
|
||||
slide_id: ['11'],
|
||||
row_key: ['row-11'],
|
||||
duration_seconds: ['10'],
|
||||
use_video_duration: ['0'],
|
||||
disable_audio: ['1'],
|
||||
group_key: ['news'],
|
||||
group_name: ['News'],
|
||||
group_pick_count: ['1'],
|
||||
group_shuffle: ['1'],
|
||||
group_mode: ['repeat'],
|
||||
group_repeat_count: ['2'],
|
||||
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])]
|
||||
}
|
||||
}, updateRes, () => {});
|
||||
|
||||
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
|
||||
assert.equal(updateRes.redirectedTo, '/playlists/4/edit');
|
||||
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
|
||||
assert.deepEqual(groupInsertParams.slice(6, 10), [
|
||||
'news',
|
||||
'News',
|
||||
1,
|
||||
JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])
|
||||
]);
|
||||
assert.equal(groupInsertParams[10], 1);
|
||||
assert.equal(groupInsertParams[11], 'repeat');
|
||||
assert.equal(groupInsertParams[12], 2);
|
||||
assert.deepEqual(calls, [
|
||||
'delete-slides',
|
||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
||||
'update-playlist',
|
||||
'delete-slides',
|
||||
'insert-slide',
|
||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
|
||||
]);
|
||||
for (const pickCount of ['0', '2']) {
|
||||
const invalidRes = createResponse();
|
||||
await updateHandlers[1]({
|
||||
path: '/playlists/4',
|
||||
params: { id: '4' },
|
||||
body: { name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: [pickCount] }
|
||||
}, invalidRes, () => {});
|
||||
assert.equal(invalidRes.statusCode, 400);
|
||||
}
|
||||
for (const settings of [{ group_mode: ['unknown'] }, { group_repeat_count: ['0'] }, { group_repeat_count: ['101'] }, { group_repeat_count: ['1.5'] }]) {
|
||||
const invalidRes = createResponse();
|
||||
await updateHandlers[1]({
|
||||
path: '/playlists/4', params: { id: '4' },
|
||||
body: Object.assign({ name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: ['1'] }, settings)
|
||||
}, invalidRes, () => {});
|
||||
assert.equal(invalidRes.statusCode, 400);
|
||||
}
|
||||
});
|
||||
@@ -44,11 +44,57 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.13.1' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: require('../package.json').version });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
|
||||
test('playlist shuffle migration upgrades an existing 2.15.0 installation', async () => {
|
||||
const pool = createPool([]);
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.0' });
|
||||
const shuffleMigration = pending.find(migration => migration.version === '2.15.1');
|
||||
assert.ok(shuffleMigration);
|
||||
await shuffleMigration.run(pool);
|
||||
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_shuffle')));
|
||||
});
|
||||
|
||||
test('playlist repeat migration upgrades an existing 2.15.1 installation', async () => {
|
||||
const pool = createPool([]);
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' });
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].version, '2.15.2');
|
||||
await pending[0].run(pool);
|
||||
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_mode')));
|
||||
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_repeat_count')));
|
||||
});
|
||||
|
||||
test('playlist group schema migration is pending for version 2.14.3', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
||||
},
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
||||
},
|
||||
result: [[{ table_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
||||
},
|
||||
result: [[{ table_count: 0 }]]
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.14.3' });
|
||||
|
||||
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.15.0'; }));
|
||||
});
|
||||
|
||||
test('pending migrations are reported when an older schema still needs scripts', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
|
||||
@@ -5,6 +5,55 @@ require('../src/common');
|
||||
|
||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||
|
||||
test('force-deleting a screen requires a verified empty live-connection list', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
let connectionResponse = { connections: [{ id: 'active-1' }] };
|
||||
const app = {
|
||||
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
|
||||
get() {}
|
||||
};
|
||||
|
||||
registerManageRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
queries.push({ sql, params });
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: { async fetchScreenById() { return { id: 7, name: 'Demo', slug: 'demo' }; } },
|
||||
pages: {},
|
||||
getScreenDeleteBlockMessage: async () => 'This screen is still linked to onboarding devices.',
|
||||
getScreenConnections: async () => connectionResponse,
|
||||
requirePermission() { return function (_req, _res, next) { next(); }; }
|
||||
});
|
||||
|
||||
const handler = handlers['/screens/:id/delete'][1];
|
||||
const createResponse = function () {
|
||||
return {
|
||||
redirect(url) { this.redirectedTo = url; },
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
send(body) { this.body = body; }
|
||||
};
|
||||
};
|
||||
|
||||
const activeResponse = createResponse();
|
||||
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, activeResponse, () => {});
|
||||
assert.match(decodeURIComponent(activeResponse.redirectedTo), /still in use by connected players/);
|
||||
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), false);
|
||||
|
||||
connectionResponse = { connections: [], degraded: true };
|
||||
const unverifiableResponse = createResponse();
|
||||
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, unverifiableResponse, () => {});
|
||||
assert.match(decodeURIComponent(unverifiableResponse.redirectedTo), /Unable to verify/);
|
||||
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), false);
|
||||
|
||||
connectionResponse = { connections: [] };
|
||||
const idleResponse = createResponse();
|
||||
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, idleResponse, () => {});
|
||||
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), true);
|
||||
});
|
||||
|
||||
test('screen update keeps the existing slug on edit', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
|
||||
@@ -259,6 +259,21 @@ test('screen edit page does not render a player URL table', async () => {
|
||||
assert.match(html, /Slug cannot be changed after creation/);
|
||||
});
|
||||
|
||||
test('screen edit page offers force delete for disconnected onboarding bindings', () => {
|
||||
const html = renderScreenEditPage(
|
||||
{ id: 7, name: 'Demo Conference', slug: 'demo-conference', canForceDelete: true },
|
||||
{ playlists: [] },
|
||||
'',
|
||||
{ id: 1 }
|
||||
);
|
||||
|
||||
assert.match(html, /Force delete/);
|
||||
assert.match(html, /name="force_delete" value="1"/);
|
||||
assert.match(html, /Force delete this screen group and unlink its onboarding devices\?/);
|
||||
assert.ok(html.indexOf('form="screen-force-delete-form"') < html.indexOf('class="btn-group save-action-group"'));
|
||||
assert.match(html, /class="ms-auto">\s*<div class="btn-group save-action-group"/);
|
||||
});
|
||||
|
||||
test('screen launcher downloads are shared and attached', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
|
||||
Reference in new issue
Block a user