diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba43be4..54ad3e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/build/package.player.json b/build/package.player.json
index 30a0d9e..68b4701 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -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": {
diff --git a/build/package.web.json b/build/package.web.json
index 328c192..4bb6ec4 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -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": {
diff --git a/docs/user/guide-web.md b/docs/user/guide-web.md
index 25cf631..5cbe843 100644
--- a/docs/user/guide-web.md
+++ b/docs/user/guide-web.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index 0e3d596..75c7d04 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index a89746c..91376ec 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/data/admin.js b/src/data/admin.js
index 4853cad..87ef535 100644
--- a/src/data/admin.js
+++ b/src/data/admin.js
@@ -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
diff --git a/src/db/common.js b/src/db/common.js
index 0e5cd18..8086b3c 100644
--- a/src/db/common.js
+++ b/src/db/common.js
@@ -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))`
);
}
diff --git a/src/db/index.js b/src/db/index.js
index c5d4ee9..20b0d60 100644
--- a/src/db/index.js
+++ b/src/db/index.js
@@ -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,
diff --git a/src/db/migrations.js b/src/db/migrations.js
index ab5b5d2..eac5b11 100644
--- a/src/db/migrations.js
+++ b/src/db/migrations.js
@@ -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');
+ }
}
];
diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html
index c2cdcac..fe5c00f 100644
--- a/src/player/player-page.script.html
+++ b/src/player/player-page.script.html
@@ -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);
diff --git a/src/player/playlist.js b/src/player/playlist.js
index 3336465..14a4341 100644
--- a/src/player/playlist.js
+++ b/src/player/playlist.js
@@ -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) {
diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js
index 3467ed8..41b2c12 100644
--- a/src/player/public/js/player-page-commands.js
+++ b/src/player/public/js/player-page-commands.js
@@ -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);
}
diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js
index 9ed8e51..9f84cac 100644
--- a/src/player/public/js/player-page-playback.js
+++ b/src/player/public/js/player-page-playback.js
@@ -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;
diff --git a/src/player/public/js/player-page-playlist.js b/src/player/public/js/player-page-playlist.js
index 9a3ccee..04cbf9c 100644
--- a/src/player/public/js/player-page-playlist.js
+++ b/src/player/public/js/player-page-playlist.js
@@ -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('|');
}
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index 9755fea..d321c5c 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -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) {
diff --git a/src/player/public/js/player-page-transition.js b/src/player/public/js/player-page-transition.js
index 7e05a43..f792a8d 100644
--- a/src/player/public/js/player-page-transition.js
+++ b/src/player/public/js/player-page-transition.js
@@ -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);
}
diff --git a/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js
index 14add75..1d72272 100644
--- a/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js
+++ b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js
@@ -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
};
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 418d84f..edfc352 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -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;
diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js
index 04bd1ff..9127982 100644
--- a/src/web/public/js/playlists/playlist-schedule.js
+++ b/src/web/public/js/playlists/playlist-schedule.js
@@ -168,6 +168,144 @@
return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
}
+ function parseScheduleBoundary(value) {
+ if (!value) return null;
+ var date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+ }
+
+ function startOfScheduleDay(date) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ }
+
+ function getScheduleBoundaryMinute(date) {
+ return date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60 + date.getMilliseconds() / 60000;
+ }
+
+ function getScheduleTimeIntervals(rule, day) {
+ var dayStart = startOfScheduleDay(day);
+ var dayEnd = new Date(dayStart.getTime());
+ dayEnd.setDate(dayEnd.getDate() + 1);
+ dayEnd = new Date(dayEnd.getTime() - 1);
+
+ if (rule.start_datetime) {
+ var ruleStart = parseScheduleBoundary(rule.start_datetime);
+ if (!ruleStart || dayEnd < ruleStart) return [];
+ }
+ if (rule.end_datetime) {
+ var ruleEnd = parseScheduleBoundary(rule.end_datetime);
+ if (!ruleEnd || dayStart > ruleEnd) return [];
+ }
+ if (Array.isArray(rule.days) && rule.days.length && rule.days.indexOf(day.getDay()) === -1) {
+ return [];
+ }
+
+ var lowerMinute = 0;
+ var upperMinute = 1440;
+ if (rule.start_datetime) {
+ var startDate = parseScheduleBoundary(rule.start_datetime);
+ if (startDate && startOfScheduleDay(startDate).getTime() === dayStart.getTime()) {
+ lowerMinute = Math.max(lowerMinute, getScheduleBoundaryMinute(startDate));
+ }
+ }
+ if (rule.end_datetime) {
+ var endDate = parseScheduleBoundary(rule.end_datetime);
+ if (endDate && startOfScheduleDay(endDate).getTime() === dayStart.getTime()) {
+ upperMinute = Math.min(upperMinute, getScheduleBoundaryMinute(endDate));
+ }
+ }
+ if (lowerMinute > upperMinute) return [];
+
+ var intervals = [];
+ if (!rule.start_time || !rule.end_time) {
+ intervals.push([0, 1440]);
+ } else {
+ var startTime = normalizeScheduleRuleValue({ start_time: rule.start_time, end_time: rule.end_time });
+ if (!startTime) return [];
+ var startMinute = Number(String(rule.start_time).slice(0, 2)) * 60 + Number(String(rule.start_time).slice(3, 5));
+ var endMinute = Number(String(rule.end_time).slice(0, 2)) * 60 + Number(String(rule.end_time).slice(3, 5));
+ if (startMinute <= endMinute) {
+ intervals.push([startMinute, endMinute]);
+ } else {
+ intervals.push([0, endMinute], [startMinute, 1440]);
+ }
+ }
+
+ return intervals.map(function (interval) {
+ return [Math.max(interval[0], lowerMinute), Math.min(interval[1], upperMinute)];
+ }).filter(function (interval) {
+ return interval[0] <= interval[1];
+ });
+ }
+
+ function scheduleRulePairCanOverlap(leftRule, rightRule) {
+ var startDates = [parseScheduleBoundary(leftRule.start_datetime), parseScheduleBoundary(rightRule.start_datetime)].filter(Boolean);
+ var endDates = [parseScheduleBoundary(leftRule.end_datetime), parseScheduleBoundary(rightRule.end_datetime)].filter(Boolean);
+ var startDate = startDates.reduce(function (latest, date) {
+ return !latest || date > latest ? date : latest;
+ }, null);
+ var endDate = endDates.reduce(function (earliest, date) {
+ return !earliest || date < earliest ? date : earliest;
+ }, null);
+
+ if (startDate && endDate && startDate > endDate) {
+ return false;
+ }
+
+ var firstDay = startDate ? startOfScheduleDay(startDate) : endDate ? startOfScheduleDay(endDate) : startOfScheduleDay(new Date());
+ if (!startDate && endDate) {
+ firstDay.setDate(firstDay.getDate() - 7);
+ }
+ var lastDay = endDate ? startOfScheduleDay(endDate) : startOfScheduleDay(firstDay);
+ if (!endDate) {
+ lastDay.setDate(lastDay.getDate() + 7);
+ }
+
+ var spanDays = Math.floor((Date.UTC(lastDay.getFullYear(), lastDay.getMonth(), lastDay.getDate())
+ - Date.UTC(firstDay.getFullYear(), firstDay.getMonth(), firstDay.getDate())) / 86400000);
+ var candidateOffsets = [];
+ var lastOffset = Math.max(0, spanDays);
+ if (lastOffset <= 16) {
+ for (var offset = 0; offset <= lastOffset; offset += 1) candidateOffsets.push(offset);
+ } else {
+ for (var startOffset = 0; startOffset <= 7; startOffset += 1) candidateOffsets.push(startOffset);
+ for (var endOffset = lastOffset - 7; endOffset <= lastOffset; endOffset += 1) candidateOffsets.push(endOffset);
+ }
+
+ var checkedDays = new Set();
+ for (var index = 0; index < candidateOffsets.length; index += 1) {
+ var day = new Date(firstDay.getTime());
+ day.setDate(day.getDate() + candidateOffsets[index]);
+ var dayKey = [day.getFullYear(), day.getMonth(), day.getDate()].join('-');
+ if (checkedDays.has(dayKey)) continue;
+ checkedDays.add(dayKey);
+ var leftIntervals = getScheduleTimeIntervals(leftRule, day);
+ var rightIntervals = getScheduleTimeIntervals(rightRule, day);
+ for (var leftIndex = 0; leftIndex < leftIntervals.length; leftIndex += 1) {
+ for (var rightIndex = 0; rightIndex < rightIntervals.length; rightIndex += 1) {
+ if (Math.max(leftIntervals[leftIndex][0], rightIntervals[rightIndex][0])
+ <= Math.min(leftIntervals[leftIndex][1], rightIntervals[rightIndex][1])) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ function scheduleRuleSetsCanOverlap(slideRulesValue, groupRulesValue) {
+ var slideRules = parseScheduleRulesValue(slideRulesValue);
+ var groupRules = parseScheduleRulesValue(groupRulesValue);
+ if (!slideRules.length || !groupRules.length) {
+ return true;
+ }
+ return slideRules.some(function (slideRule) {
+ return groupRules.some(function (groupRule) {
+ return scheduleRulePairCanOverlap(slideRule, groupRule);
+ });
+ });
+ }
+
function getScheduleRowFields(row) {
return {
rulesContainer: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.rulesContainer) : null,
@@ -728,6 +866,10 @@
}
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
+ var groupKey = String(values.groupKey || '');
+ var groupName = escapeHtmlAttribute(String(values.groupName || ''));
+ var groupPickCount = Math.max(1, Number(values.groupPickCount) || 1);
+ var groupScheduleRules = Array.isArray(values.groupScheduleRules) ? values.groupScheduleRules : [];
return '' +
'