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 '' + '' + '
' + @@ -736,6 +878,13 @@ '
' + '' + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ''; } @@ -754,7 +903,7 @@ '
' + '
' + '' + - '' + + '' + '
' + '' + '' + @@ -774,9 +923,9 @@ '
'; } - function collectScheduleParams(row, rowKey) { + function collectScheduleParams(row, rowKey, isGroupSchedule) { var params = new URLSearchParams(); - var fields = row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : []; + var fields = !isGroupSchedule && row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : []; params.set('row_key', String(rowKey || '')); fields.forEach(function (field) { @@ -785,6 +934,24 @@ } params.append(field.name, String(field.value || '')); }); + if (isGroupSchedule && row) { + var rulesInput = row.querySelector('[data-playlist-group-schedule-rules]'); + var rules = []; + try { + rules = JSON.parse(rulesInput && rulesInput.value || '[]'); + } catch (_error) { + rules = []; + } + (Array.isArray(rules) ? rules : []).forEach(function (rule, index) { + params.append('schedule_rule_row_key[]', String(rowKey || '')); + params.append('schedule_rule_position[]', String(index)); + params.append('schedule_rule_start_datetime[]', String(rule && rule.start_datetime || '')); + params.append('schedule_rule_end_datetime[]', String(rule && rule.end_datetime || '')); + params.append('schedule_rule_start_time[]', String(rule && rule.start_time || '')); + params.append('schedule_rule_end_time[]', String(rule && rule.end_time || '')); + params.append('schedule_rule_days_csv[]', Array.isArray(rule && rule.days) ? rule.days.join(',') : ''); + }); + } return params; } @@ -900,10 +1067,42 @@ fields.rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(rules).replace(/
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
Pick
' + + '
' + + '' + + '' + + '
'; + header.querySelector('[data-playlist-group-name]').value = groupName; + header.querySelector('[data-playlist-group-pick-count]').value = String(pickCount); + header.querySelector('[data-playlist-group-repeat-count]').value = String(Math.min(100, Math.max(1, Number(group.groupRepeatCount) || 1))); + syncPlaylistGroupModeControls(header); + var shuffleButton = header.querySelector('[data-playlist-group-shuffle]'); + var shuffleEnabled = Boolean(group.groupShuffle); + shuffleButton.setAttribute('aria-pressed', shuffleEnabled ? 'true' : 'false'); + shuffleButton.setAttribute('aria-label', shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'); + shuffleButton.title = shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'; + shuffleButton.classList.toggle('btn-secondary', shuffleEnabled); + shuffleButton.classList.toggle('btn-outline-secondary', !shuffleEnabled); + shuffleButton.querySelector('i').className = shuffleEnabled ? 'bi bi-shuffle' : 'bi bi-chevron-double-right'; + header.querySelector('[data-playlist-group-schedule-rules]').value = JSON.stringify(rules); + header.querySelector('[data-group-schedule-summary]').textContent = formatScheduleRulesSummary(rules) || 'Always visible'; + return header; + } + + function syncPlaylistGroupModeControls(header) { + var mode = header.getAttribute('data-group-mode') === 'repeat' ? 'repeat' : 'select'; + Array.prototype.forEach.call(header.querySelectorAll('[data-playlist-group-mode]'), function (button) { + var active = button.getAttribute('data-playlist-group-mode') === mode; + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + button.classList.toggle('btn-secondary', active); + button.classList.toggle('btn-outline-secondary', !active); + }); + header.querySelector('[data-playlist-group-select-controls]').hidden = mode === 'repeat'; + header.querySelector('[data-playlist-group-repeat-controls]').hidden = mode !== 'repeat'; + } + + function syncGroupRowMetadata(header) { + if (!header) { + return; + } + var groupKey = String(header.getAttribute('data-group-key') || ''); + var groupMode = header.getAttribute('data-group-mode') === 'repeat' ? 'repeat' : 'select'; + var repeatInput = header.querySelector('[data-playlist-group-repeat-count]'); + var repeatCount = Math.min(100, Math.max(1, Math.floor(Number(repeatInput && repeatInput.value) || 1))); + if (repeatInput) repeatInput.value = String(repeatCount); + syncPlaylistGroupModeControls(header); + var nameInput = header.querySelector('[data-playlist-group-name]'); + var pickInput = header.querySelector('[data-playlist-group-pick-count]'); + var shuffleButton = header.querySelector('[data-playlist-group-shuffle]'); + var shuffleEnabled = shuffleButton && shuffleButton.getAttribute('aria-pressed') === 'true'; + var rulesInput = header.querySelector('[data-playlist-group-schedule-rules]'); + var groupName = String(nameInput && nameInput.value || '').trim() || 'Group'; + var members = getGroupRows(groupKey); + var maximum = Math.max(1, members.length); + var pickCount = Math.min(maximum, Math.max(1, Math.floor(Number(pickInput && pickInput.value) || 1))); + if (pickInput) { + pickInput.max = String(maximum); + pickInput.disabled = members.length === 0; + pickInput.value = String(pickCount); + } + var scheduleRules = []; + try { + scheduleRules = JSON.parse(rulesInput && rulesInput.value || '[]'); + } catch (_error) { + scheduleRules = []; + } + if (!Array.isArray(scheduleRules)) { + scheduleRules = []; + } + getGroupRows(groupKey).forEach(function (row) { + var keyField = row.querySelector('[data-playlist-group-key]'); + var nameField = row.querySelector('[data-playlist-group-name-value]'); + var pickField = row.querySelector('[data-playlist-group-pick-count-value]'); + var shuffleField = row.querySelector('[data-playlist-group-shuffle-value]'); + var modeField = row.querySelector('[data-playlist-group-mode-value]'); + var repeatField = row.querySelector('[data-playlist-group-repeat-count-value]'); + var scheduleField = row.querySelector('[data-playlist-group-schedule-rules]'); + row.classList.add('playlist-group-child'); + row.setAttribute('data-group-key', groupKey); + if (keyField) keyField.value = groupKey; + if (nameField) nameField.value = groupName; + if (pickField) pickField.value = String(pickCount); + if (shuffleField) shuffleField.value = shuffleEnabled ? '1' : '0'; + if (modeField) modeField.value = groupMode; + if (repeatField) repeatField.value = String(repeatCount); + if (scheduleField) scheduleField.value = JSON.stringify(scheduleRules); + }); + } + + function initializePlaylistGroups(currentTbody) { + var target = currentTbody || document.getElementById('playlist-items-body') || tbody; + var rows = target ? Array.prototype.slice.call(target.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')) : []; + var groupValues = new Map(); + var renderedKeys = new Set(); + var orderedNodes = []; + rows.forEach(function (row) { + var keyField = row.querySelector('[data-playlist-group-key]'); + var groupKey = String(keyField && keyField.value || ''); + if (!groupKey || groupValues.has(groupKey)) { + return; + } + var nameField = row.querySelector('[data-playlist-group-name-value]'); + var pickField = row.querySelector('[data-playlist-group-pick-count-value]'); + var rulesField = row.querySelector('[data-playlist-group-schedule-rules]'); + var rules = []; + try { + rules = JSON.parse(rulesField && rulesField.value || '[]'); + } catch (_error) { + rules = []; + } + groupValues.set(groupKey, { + groupKey: groupKey, + groupName: String(nameField && nameField.value || 'Group'), + groupPickCount: Number(pickField && pickField.value) || 1, + groupShuffle: String((row.querySelector('[data-playlist-group-shuffle-value]') || {}).value || '0') === '1', + groupMode: String((row.querySelector('[data-playlist-group-mode-value]') || {}).value || 'select'), + groupRepeatCount: Number((row.querySelector('[data-playlist-group-repeat-count-value]') || {}).value) || 1, + groupScheduleRules: Array.isArray(rules) ? rules : [] + }); + }); + + rows.forEach(function (row) { + var keyField = row.querySelector('[data-playlist-group-key]'); + var groupKey = String(keyField && keyField.value || ''); + if (!groupKey) { + row.classList.remove('playlist-group-child'); + orderedNodes.push(row); + return; + } + if (renderedKeys.has(groupKey)) { + return; + } + renderedKeys.add(groupKey); + orderedNodes.push(createGroupHeader(groupValues.get(groupKey))); + rows.filter(function (member) { + var memberKey = member.querySelector('[data-playlist-group-key]'); + return String(memberKey && memberKey.value || '') === groupKey; + }).forEach(function (member) { + member.classList.add('playlist-group-child'); + orderedNodes.push(member); + }); + }); + + var emptyRow = target && target.querySelector('.playlist-empty-row'); + if (emptyRow) { + orderedNodes.push(emptyRow); + } + if (target) { + var groupBlockFragment = document.createDocumentFragment(); + orderedNodes.forEach(function (node) { + groupBlockFragment.appendChild(node); + }); + target.replaceChildren(groupBlockFragment); + } + getGroupHeaders().forEach(syncGroupRowMetadata); + updateGroupScheduleWarnings(); + } + + function getTopLevelPlaylistBlocks() { + var currentTbody = document.getElementById('playlist-items-body') || tbody; + var children = currentTbody ? Array.prototype.slice.call(currentTbody.children) : []; + var blocks = []; + for (var index = 0; index < children.length; index += 1) { + var node = children[index]; + if (node.classList.contains('playlist-empty-row')) { + continue; + } + if (!node.matches('[data-playlist-group-row]')) { + blocks.push({ key: String(node.getAttribute('data-group-key') || ''), nodes: [node] }); + continue; + } + var groupKey = String(node.getAttribute('data-group-key') || ''); + var groupNodes = [node]; + var nextIndex = index + 1; + while (nextIndex < children.length && children[nextIndex].matches('[data-playlist-slide-row], [data-playlist-group-empty]') + && String(children[nextIndex].getAttribute('data-group-key') || '') === groupKey) { + groupNodes.push(children[nextIndex]); + nextIndex += 1; + } + blocks.push({ key: groupKey, nodes: groupNodes }); + index = nextIndex - 1; + } + return blocks; + } + + function moveTopLevelBlock(blockNode, offset) { + var currentTbody = document.getElementById('playlist-items-body') || tbody; + var blocks = getTopLevelPlaylistBlocks(); + var currentIndex = blocks.findIndex(function (block) { return block.nodes[0] === blockNode; }); + var targetIndex = currentIndex + Number(offset || 0); + if (!currentTbody || currentIndex < 0 || targetIndex < 0 || targetIndex >= blocks.length) { + return false; + } + var movingNodes = blocks[currentIndex].nodes; + var referenceNode = offset < 0 + ? blocks[targetIndex].nodes[0] + : blocks[targetIndex].nodes[blocks[targetIndex].nodes.length - 1].nextSibling; + var movingBlock = document.createDocumentFragment(); + movingNodes.forEach(function (node) { movingBlock.appendChild(node); }); + if (referenceNode) currentTbody.insertBefore(movingBlock, referenceNode); + else currentTbody.appendChild(movingBlock); + updateRowOrder(true); + return true; + } + + function moveGroupBlock(header, offset) { + return moveTopLevelBlock(header, offset); + } + + function moveGroupBlockRelative(header, targetBlock, placeAfter, markDirty) { + var currentTbody = document.getElementById('playlist-items-body') || tbody; + var blocks = getTopLevelPlaylistBlocks(); + var sourceBlock = blocks.find(function (block) { return block.nodes[0] === header; }); + if (!currentTbody || !sourceBlock || !targetBlock || sourceBlock === targetBlock) { + return false; + } + var sourceNextSibling = sourceBlock.nodes[sourceBlock.nodes.length - 1].nextSibling; + var targetNextSibling = targetBlock.nodes[targetBlock.nodes.length - 1].nextSibling; + if ((!placeAfter && sourceNextSibling === targetBlock.nodes[0]) + || (placeAfter && targetNextSibling === sourceBlock.nodes[0])) { + return false; + } + var previousRects = new Map(); + Array.prototype.forEach.call(currentTbody.children, function (node) { + if (node.matches('[data-playlist-group-row], [data-playlist-slide-row]')) { + previousRects.set(node, node.getBoundingClientRect()); + } + }); + var referenceNode = placeAfter + ? targetNextSibling + : targetBlock.nodes[0]; + var movingBlock = document.createDocumentFragment(); + sourceBlock.nodes.forEach(function (node) { movingBlock.appendChild(node); }); + if (referenceNode) currentTbody.insertBefore(movingBlock, referenceNode); + else currentTbody.appendChild(movingBlock); + updateRowOrder(markDirty !== false); + if (typeof window.animatePlaylistRowReorder === 'function') { + window.animatePlaylistRowReorder(previousRects, sourceBlock.nodes); + } + return true; + } + + function bindGroupBlockDrag(targetTbody) { + if (!targetTbody || targetTbody.dataset.groupBlockDragBound === 'true') { + return; + } + targetTbody.dataset.groupBlockDragBound = 'true'; + var draggedHeader = null; + var dragPreview = null; + var draggedMembers = []; + var originalNodes = []; + var placeholderNodes = []; + var placeholderHeader = null; + var groupDragLayer = null; + var originalNextSibling = null; + var groupDragMoved = false; + var groupDropCompleted = false; + var groupDragCleanupTimer = null; + var groupPointerState = null; + + Array.prototype.forEach.call(document.querySelectorAll('.playlist-group-drag-preview'), function (preview) { + preview.remove(); + }); + Array.prototype.forEach.call(document.querySelectorAll('.playlist-group-original-drag-layer'), function (layer) { + layer.remove(); + }); + + function createGroupDragPreview(header, point) { + var groupKey = String(header.getAttribute('data-group-key') || ''); + var preview = document.createElement('div'); + var groupRows = getGroupRows(groupKey).slice(0, 3); + var heading = document.createElement('div'); + var icon = document.createElement('i'); + var name = document.createElement('strong'); + var memberList = document.createElement('div'); + var groupNameInput = header.querySelector('[data-playlist-group-name]'); + + preview.className = 'playlist-group-drag-preview'; + preview.style.setProperty('--playlist-group-accent', header.style.getPropertyValue('--playlist-group-accent')); + preview.style.setProperty('--playlist-group-bg', header.style.getPropertyValue('--playlist-group-bg')); + preview.setAttribute('aria-hidden', 'true'); + preview.style.position = 'fixed'; + preview.style.top = '0'; + preview.style.left = '0'; + preview.style.width = Math.min(460, Math.max(300, window.innerWidth * 0.4)) + 'px'; + preview.style.pointerEvents = 'none'; + preview.style.zIndex = '1200'; + preview.style.transform = 'translate3d(' + Math.max(0, Number(point && point.x || 0) + 14) + 'px, ' + Math.max(0, Number(point && point.y || 0) + 14) + 'px, 0)'; + + heading.className = 'playlist-group-drag-preview-heading'; + icon.className = 'bi bi-collection-fill playlist-group-icon'; + icon.setAttribute('aria-hidden', 'true'); + name.textContent = String(groupNameInput && groupNameInput.value || 'Group'); + heading.appendChild(icon); + heading.appendChild(name); + memberList.className = 'playlist-group-drag-preview-members'; + + groupRows.forEach(function (row) { + var member = document.createElement('div'); + var thumbnail = row.querySelector('[data-playlist-slide-thumb-image]'); + var title = row.querySelector('[data-playlist-slide-title]'); + member.className = 'playlist-group-drag-preview-member'; + if (thumbnail && thumbnail.getAttribute('src')) { + var image = document.createElement('img'); + image.alt = ''; + image.src = thumbnail.getAttribute('src'); + member.appendChild(image); + } else { + var placeholder = document.createElement('span'); + placeholder.className = 'playlist-group-drag-preview-placeholder'; + placeholder.innerHTML = ''; + member.appendChild(placeholder); + } + var memberTitle = document.createElement('span'); + memberTitle.textContent = String(title && title.textContent || 'Slide'); + member.appendChild(memberTitle); + memberList.appendChild(member); + }); + if (getGroupRows(groupKey).length > groupRows.length) { + var remaining = document.createElement('div'); + remaining.className = 'playlist-group-drag-preview-more'; + remaining.textContent = '+ ' + String(getGroupRows(groupKey).length - groupRows.length) + ' more'; + memberList.appendChild(remaining); + } + preview.appendChild(heading); + preview.appendChild(memberList); + document.body.appendChild(preview); + return preview; + } + + function cloneGroupPlaceholderNode(node) { + var clone = node.cloneNode(true); + clone.classList.add('playlist-group-block-placeholder'); + clone.setAttribute('aria-hidden', 'true'); + clone.style.pointerEvents = 'none'; + if (clone.matches('[data-playlist-slide-row]')) { + clone.setAttribute('data-playlist-ghost-row', 'true'); + } + Array.prototype.forEach.call(clone.querySelectorAll('[id]'), function (element) { + element.removeAttribute('id'); + }); + Array.prototype.forEach.call(clone.querySelectorAll('input, select, textarea, button'), function (control) { + control.disabled = true; + control.removeAttribute('name'); + control.removeAttribute('form'); + control.setAttribute('tabindex', '-1'); + }); + return clone; + } + + function createGroupDragLayer(nodes) { + var layer = document.createElement('table'); + var layerBody = document.createElement('tbody'); + layer.className = 'playlist-group-original-drag-layer'; + layer.setAttribute('aria-hidden', 'true'); + layer.style.position = 'fixed'; + layer.style.top = '0'; + layer.style.left = '-10000px'; + layer.style.width = String(targetTbody.getBoundingClientRect().width) + 'px'; + layer.style.pointerEvents = 'none'; + layer.style.opacity = '0'; + layer.style.tableLayout = 'fixed'; + layer.style.zIndex = '-1'; + nodes.forEach(function (node) { + layerBody.appendChild(node); + }); + layer.appendChild(layerBody); + document.body.appendChild(layer); + return layer; + } + + function getGroupDragPoint(event) { + var touch = event.touches && event.touches[0] + ? event.touches[0] + : event.changedTouches && event.changedTouches[0] + ? event.changedTouches[0] + : null; + return { + x: touch ? touch.clientX : event.clientX, + y: touch ? touch.clientY : event.clientY + }; + } + + function beginGroupPointerDrag(point) { + if (!groupPointerState || groupPointerState.active) { + return false; + } + draggedHeader = groupPointerState.header; + var sourceBlock = getTopLevelPlaylistBlocks().find(function (block) { return block.nodes[0] === draggedHeader; }); + if (!sourceBlock) { + draggedHeader = null; + return false; + } + originalNodes = sourceBlock.nodes.slice(); + originalNextSibling = originalNodes[originalNodes.length - 1].nextSibling; + dragPreview = createGroupDragPreview(draggedHeader, point); + placeholderNodes = originalNodes.map(cloneGroupPlaceholderNode); + placeholderHeader = placeholderNodes[0]; + var placeholderBlock = document.createDocumentFragment(); + placeholderNodes.forEach(function (node) { placeholderBlock.appendChild(node); }); + targetTbody.insertBefore(placeholderBlock, draggedHeader); + draggedMembers = originalNodes.filter(function (node) { return node.matches('[data-playlist-slide-row]'); }); + originalNodes.forEach(function (node) { node.classList.add('playlist-group-block-dragging'); }); + groupDragLayer = createGroupDragLayer(originalNodes); + groupPointerState.active = true; + groupDragMoved = false; + groupDropCompleted = false; + groupDragCleanupTimer = window.setTimeout(finishGroupDrag, 120000); + return true; + } + + function moveGroupPlaceholder(clientY) { + var blocks = getTopLevelPlaylistBlocks(); + var sourceBlock = blocks.find(function (block) { return block.nodes[0] === placeholderHeader; }); + if (!sourceBlock) { + return; + } + var destination = null; + var placeAfter = false; + for (var index = 0; index < blocks.length; index += 1) { + var block = blocks[index]; + if (block === sourceBlock) { + continue; + } + var firstRect = block.nodes[0].getBoundingClientRect(); + var lastRect = block.nodes[block.nodes.length - 1].getBoundingClientRect(); + var midpoint = (firstRect.top + lastRect.bottom) / 2; + if (clientY < midpoint) { + destination = block; + placeAfter = false; + break; + } + destination = block; + placeAfter = true; + } + if (destination && moveGroupBlockRelative(placeholderHeader, destination, placeAfter, false)) { + groupDragMoved = true; + } + } + + function handleGroupPointerMove(event) { + if (!groupPointerState) { + return; + } + var point = getGroupDragPoint(event); + if (!groupPointerState.active) { + var threshold = 4; + if (Math.abs(point.x - groupPointerState.startPoint.x) < threshold + && Math.abs(point.y - groupPointerState.startPoint.y) < threshold) { + return; + } + if (!beginGroupPointerDrag(groupPointerState.currentPoint || point)) { + return; + } + } + event.preventDefault(); + if (dragPreview) { + dragPreview.style.transform = 'translate3d(' + Math.max(0, point.x + 14) + 'px, ' + Math.max(0, point.y + 14) + 'px, 0)'; + } + moveGroupPlaceholder(point.y); + } + + function handleGroupPointerEnd(event, shouldCancel) { + if (!groupPointerState) { + return; + } + if (groupPointerState.active) { + groupDropCompleted = !shouldCancel && groupDragMoved; + finishGroupDrag(); + } else { + groupPointerState = null; + } + } + + function finishGroupDrag() { + var hasGroupDrag = Boolean(originalNodes.length || placeholderNodes.length || groupDragLayer); + var shouldCommit = groupDropCompleted && groupDragMoved; + if (groupDragCleanupTimer !== null) { + window.clearTimeout(groupDragCleanupTimer); + groupDragCleanupTimer = null; + } + if (placeholderNodes.length && placeholderHeader && placeholderHeader.parentNode && !shouldCommit) { + var resetPlaceholder = document.createDocumentFragment(); + placeholderNodes.forEach(function (node) { resetPlaceholder.appendChild(node); }); + if (originalNextSibling && originalNextSibling.parentNode === targetTbody) { + targetTbody.insertBefore(resetPlaceholder, originalNextSibling); + } else { + targetTbody.appendChild(resetPlaceholder); + } + } + if (originalNodes.length && placeholderHeader && placeholderHeader.parentNode) { + var restoredBlock = document.createDocumentFragment(); + originalNodes.forEach(function (node) { + node.classList.remove('playlist-group-block-dragging'); + restoredBlock.appendChild(node); + }); + placeholderHeader.parentNode.insertBefore(restoredBlock, placeholderHeader); + } + placeholderNodes.forEach(function (node) { + if (node.parentNode) node.parentNode.removeChild(node); + }); + if (groupDragLayer && groupDragLayer.parentNode) groupDragLayer.parentNode.removeChild(groupDragLayer); + if (draggedHeader) draggedHeader.classList.remove('playlist-group-block-dragging'); + draggedMembers.forEach(function (row) { row.classList.remove('playlist-group-block-dragging'); }); + draggedMembers = []; + Array.prototype.forEach.call(document.querySelectorAll('.playlist-group-drag-preview'), function (preview) { + preview.remove(); + }); + Array.prototype.forEach.call(document.querySelectorAll('.playlist-group-drop-target'), function (target) { + target.classList.remove('playlist-group-drop-target'); + }); + dragPreview = null; + draggedHeader = null; + originalNodes = []; + placeholderNodes = []; + placeholderHeader = null; + groupDragLayer = null; + originalNextSibling = null; + groupDragMoved = false; + groupDropCompleted = false; + groupPointerState = null; + if (hasGroupDrag) updateRowOrder(shouldCommit); + } + + function handleGroupPointerStart(event) { + var handle = event.target && event.target.closest ? event.target.closest('[data-playlist-group-drag-handle]') : null; + var header = handle ? handle.closest('[data-playlist-group-row]') : null; + var point = getGroupDragPoint(event); + if (!header || groupPointerState || !point) { + return; + } + if (event.type === 'mousedown' && event.button !== 0) { + return; + } + event.preventDefault(); + event.stopPropagation(); + groupPointerState = { + header: header, + startPoint: point, + currentPoint: point, + pointerId: event.pointerId, + active: false + }; + } + + function handleGroupPointerMove(event) { + if (!groupPointerState) { + return; + } + if (groupPointerState.pointerId !== undefined && event.pointerId !== undefined + && groupPointerState.pointerId !== event.pointerId) { + return; + } + var point = getGroupDragPoint(event); + groupPointerState.currentPoint = point; + if (!groupPointerState.active) { + var threshold = 4; + if (Math.abs(point.x - groupPointerState.startPoint.x) < threshold + && Math.abs(point.y - groupPointerState.startPoint.y) < threshold) { + return; + } + if (!beginGroupPointerDrag(point)) { + return; + } + } + event.preventDefault(); + if (dragPreview) { + dragPreview.style.transform = 'translate3d(' + Math.max(0, point.x + 14) + 'px, ' + Math.max(0, point.y + 14) + 'px, 0)'; + } + moveGroupPlaceholder(point.y); + } + + function handleGroupPointerEnd(event, shouldCancel) { + if (!groupPointerState) { + return; + } + if (groupPointerState.pointerId !== undefined && event && event.pointerId !== undefined + && groupPointerState.pointerId !== event.pointerId) { + return; + } + if (groupPointerState.active) { + groupDropCompleted = !shouldCancel && groupDragMoved; + finishGroupDrag(); + return; + } + groupPointerState = null; + } + + if (typeof window !== 'undefined' && window.PointerEvent) { + targetTbody.addEventListener('pointerdown', handleGroupPointerStart); + document.addEventListener('pointermove', handleGroupPointerMove, { passive: false }); + document.addEventListener('pointerup', function (event) { handleGroupPointerEnd(event, false); }); + document.addEventListener('pointercancel', function (event) { handleGroupPointerEnd(event, true); }); + } else { + targetTbody.addEventListener('mousedown', handleGroupPointerStart); + document.addEventListener('mousemove', handleGroupPointerMove); + document.addEventListener('mouseup', function (event) { handleGroupPointerEnd(event, false); }); + targetTbody.addEventListener('touchstart', handleGroupPointerStart, { passive: false }); + document.addEventListener('touchmove', handleGroupPointerMove, { passive: false }); + document.addEventListener('touchend', function (event) { handleGroupPointerEnd(event, false); }); + document.addEventListener('touchcancel', function (event) { handleGroupPointerEnd(event, true); }); + } + + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape') handleGroupPointerEnd(event, true); + }); + document.addEventListener('visibilitychange', function () { + if (document.hidden) handleGroupPointerEnd(null, true); + }); + window.addEventListener('blur', function () { handleGroupPointerEnd(null, true); }); + } + + function removePlaylistGroup(header) { + if (!header) { + return; + } + var groupKey = String(header.getAttribute('data-group-key') || ''); + getGroupRows(groupKey).forEach(function (row) { + var keyField = row.querySelector('[data-playlist-group-key]'); + var nameField = row.querySelector('[data-playlist-group-name-value]'); + var pickField = row.querySelector('[data-playlist-group-pick-count-value]'); + var rulesField = row.querySelector('[data-playlist-group-schedule-rules]'); + if (keyField) keyField.value = ''; + if (nameField) nameField.value = ''; + if (pickField) pickField.value = '1'; + var shuffleField = row.querySelector('[data-playlist-group-shuffle-value]'); + if (shuffleField) shuffleField.value = '0'; + var modeField = row.querySelector('[data-playlist-group-mode-value]'); + var repeatField = row.querySelector('[data-playlist-group-repeat-count-value]'); + if (modeField) modeField.value = 'select'; + if (repeatField) repeatField.value = '1'; + if (rulesField) rulesField.value = '[]'; + row.setAttribute('data-group-key', ''); + row.classList.remove('playlist-group-child'); + }); + header.remove(); + updateRowOrder(true); + } + + function setSlideGroupFields(row, targetGroupKey) { + var groupKey = String(targetGroupKey || ''); + var keyField = row && row.querySelector('[data-playlist-group-key]'); + var nameField = row && row.querySelector('[data-playlist-group-name-value]'); + var pickField = row && row.querySelector('[data-playlist-group-pick-count-value]'); + var shuffleField = row && row.querySelector('[data-playlist-group-shuffle-value]'); + var modeField = row && row.querySelector('[data-playlist-group-mode-value]'); + var repeatField = row && row.querySelector('[data-playlist-group-repeat-count-value]'); + var scheduleField = row && row.querySelector('[data-playlist-group-schedule-rules]'); + var header = groupKey ? getGroupHeader(groupKey) : null; + if (groupKey && !header) { + return false; + } + if (keyField) keyField.value = groupKey; + row.setAttribute('data-group-key', groupKey); + row.classList.toggle('playlist-group-child', Boolean(groupKey)); + if (groupKey) { + var groupNameInput = header.querySelector('[data-playlist-group-name]'); + var groupPickInput = header.querySelector('[data-playlist-group-pick-count]'); + var groupScheduleInput = header.querySelector('[data-playlist-group-schedule-rules]'); + if (nameField) nameField.value = String(groupNameInput && groupNameInput.value || '').trim() || 'Group'; + if (pickField) pickField.value = String(Math.max(1, Number(groupPickInput && groupPickInput.value) || 1)); + var groupShuffleButton = header.querySelector('[data-playlist-group-shuffle]'); + if (shuffleField) shuffleField.value = groupShuffleButton && groupShuffleButton.getAttribute('aria-pressed') === 'true' ? '1' : '0'; + if (modeField) modeField.value = header.getAttribute('data-group-mode') === 'repeat' ? 'repeat' : 'select'; + var groupRepeatInput = header.querySelector('[data-playlist-group-repeat-count]'); + if (repeatField) repeatField.value = String(Number(groupRepeatInput && groupRepeatInput.value) || 1); + if (scheduleField) scheduleField.value = String(groupScheduleInput && groupScheduleInput.value || '[]'); + } else { + if (nameField) nameField.value = ''; + if (pickField) pickField.value = '1'; + if (shuffleField) shuffleField.value = '0'; + if (modeField) modeField.value = 'select'; + if (repeatField) repeatField.value = '1'; + if (scheduleField) scheduleField.value = '[]'; + } + return true; + } + function updateEmptyState() { var currentTbody = document.getElementById('playlist-items-body') || tbody; var rows = getRows(); var placeholder = currentTbody ? currentTbody.querySelector('.playlist-empty-row') : null; - if (rows.length) { + if (rows.length || getGroupHeaders().length) { if (placeholder) { placeholder.remove(); } @@ -2116,20 +3072,79 @@ syncSlidePickerState(); } + function updatePlaylistGroupEmptyRows() { + var currentTbody = document.getElementById('playlist-items-body') || tbody; + if (!currentTbody) return; + Array.prototype.forEach.call(currentTbody.querySelectorAll('[data-playlist-group-empty]'), function (row) { + row.remove(); + }); + getGroupHeaders().forEach(function (header) { + var groupKey = String(header.getAttribute('data-group-key') || ''); + if (getGroupRows(groupKey).length) return; + var emptyRow = document.createElement('tr'); + emptyRow.className = 'playlist-group-child playlist-group-empty'; + emptyRow.setAttribute('data-playlist-group-empty', ''); + emptyRow.setAttribute('data-group-key', groupKey); + emptyRow.innerHTML = ''; + header.after(emptyRow); + }); + } + + function updatePlaylistGroupColours(rows, blocks) { + var palette = ['success', 'warning', 'info', 'danger']; + var usedColours = new Set(); + blocks.forEach(function (block) { + var colour = block.nodes[0].getAttribute('data-group-colour'); + if (palette.indexOf(colour) !== -1) usedColours.add(colour); + }); + rows.forEach(function (row) { + row.style.removeProperty('--playlist-group-accent'); + row.style.removeProperty('--playlist-group-bg'); + }); + var groupIndex = 0; + blocks.forEach(function (block) { + var header = block.nodes[0]; + if (!header.matches('[data-playlist-group-row]')) return; + var colour = header.getAttribute('data-group-colour'); + if (palette.indexOf(colour) === -1) { + colour = palette.find(function (candidate) { return !usedColours.has(candidate); }) || palette[groupIndex % palette.length]; + header.setAttribute('data-group-colour', colour); + } + usedColours.add(colour); + groupIndex += 1; + block.nodes.forEach(function (row) { + row.style.setProperty('--playlist-group-accent', 'var(--bs-' + colour + ')'); + row.style.setProperty('--playlist-group-bg', 'var(--bs-' + colour + '-bg-subtle)'); + }); + }); + } + // Recompute row numbers and derived picker state after drag/drop or adds. function updateRowOrder(markDirty) { var rows = getRows(); - rows.forEach(function (row, index) { + updatePlaylistGroupEmptyRows(); + getGroupHeaders().forEach(syncGroupRowMetadata); + var blocks = getTopLevelPlaylistBlocks(); + updatePlaylistGroupColours(rows, blocks); + rows.forEach(function (row) { + var rowGroupKey = String((row.querySelector('[data-playlist-group-key]') || {}).value || ''); + var siblingRows = rows.filter(function (candidate) { + return String((candidate.querySelector('[data-playlist-group-key]') || {}).value || '') === rowGroupKey; + }); + var siblingIndex = siblingRows.indexOf(row); + var blockIndex = blocks.findIndex(function (block) { + return rowGroupKey ? block.key === rowGroupKey : block.nodes[0] === row; + }); var orderNumber = row.querySelector('.playlist-order-number'); var moveUpButton = row.querySelector('[data-playlist-row-move="up"]'); var moveDownButton = row.querySelector('[data-playlist-row-move="down"]'); var moveUpIcon = row.querySelector('[data-playlist-row-move-icon="up"]'); var moveDownIcon = row.querySelector('[data-playlist-row-move-icon="down"]'); - var isFirst = index === 0; - var isLast = index === rows.length - 1; + var isFirst = rowGroupKey ? siblingIndex === 0 : blockIndex === 0; + var isLast = rowGroupKey ? siblingIndex === siblingRows.length - 1 : blockIndex === blocks.length - 1; if (orderNumber) { - orderNumber.textContent = String(index + 1); + orderNumber.textContent = String(blockIndex + 1) + (rowGroupKey ? '.' + String(siblingIndex + 1) : ''); } if (moveUpButton) { @@ -2152,7 +3167,30 @@ moveDownIcon.classList.toggle('bi-caret-down', isLast); } }); + blocks.forEach(function (block, index) { + var header = block.nodes[0]; + if (!header || !header.matches('[data-playlist-group-row]')) { + return; + } + var orderNumber = header.querySelector('[data-playlist-group-order]'); + if (orderNumber) orderNumber.textContent = String(index + 1); + var moveUpButton = header.querySelector('[data-playlist-group-move="up"]'); + var moveDownButton = header.querySelector('[data-playlist-group-move="down"]'); + var moveUpIcon = header.querySelector('[data-playlist-group-move-icon="up"]'); + var moveDownIcon = header.querySelector('[data-playlist-group-move-icon="down"]'); + if (moveUpButton) moveUpButton.disabled = index === 0; + if (moveDownButton) moveDownButton.disabled = index === blocks.length - 1; + if (moveUpIcon) { + moveUpIcon.classList.toggle('bi-caret-up-fill', index !== 0); + moveUpIcon.classList.toggle('bi-caret-up', index === 0); + } + if (moveDownIcon) { + moveDownIcon.classList.toggle('bi-caret-down-fill', index !== blocks.length - 1); + moveDownIcon.classList.toggle('bi-caret-down', index === blocks.length - 1); + } + }); syncAddSlideOptions(); + updateGroupScheduleWarnings(); syncPlaylistCanvasLock(); syncAddSlideButtonState(); updateEmptyState(); @@ -2162,6 +3200,7 @@ } form.addEventListener('submit', function () { + getGroupHeaders().forEach(syncGroupRowMetadata); syncScheduleDraftRows(); }, true); @@ -2172,13 +3211,32 @@ window.initPlaylistTableDrag({ tbody: tbodyElement, - onOrderChanged: updateRowOrder + onOrderChanged: updateRowOrder, + onRowDrop: function (row, targetGroupKey) { + if (setSlideGroupFields(row, targetGroupKey)) { + var groupHeader = targetGroupKey ? getGroupHeader(targetGroupKey) : null; + if (groupHeader) { + var previousMember = groupHeader; + getGroupRows(targetGroupKey).forEach(function (member) { + previousMember.after(member); + previousMember = member; + }); + } + markPlaylistDirty(); + } + } }); } function movePlaylistRowByOffset(row, offset) { var currentTbody = document.getElementById('playlist-items-body') || tbody; - var rows = getRows(); + var groupKey = String((row && row.querySelector('[data-playlist-group-key]') || {}).value || ''); + if (!groupKey) { + return moveTopLevelBlock(row, offset); + } + var rows = getRows().filter(function (candidate) { + return String((candidate.querySelector('[data-playlist-group-key]') || {}).value || '') === groupKey; + }); var currentIndex = rows.indexOf(row); var targetIndex; var referenceRow; @@ -2230,6 +3288,15 @@ var titleNode = row ? row.querySelector('[data-playlist-slide-title]') : null; var slideIdInput = row ? row.querySelector('[data-playlist-slide-id-input]') : null; var rowKeyInput = row ? row.querySelector('[data-playlist-row-key-input]') : null; + var groupKeyInput = row ? row.querySelector('[data-playlist-group-key]') : null; + var groupNameInput = row ? row.querySelector('[data-playlist-group-name-value]') : null; + var groupPickCountInput = row ? row.querySelector('[data-playlist-group-pick-count-value]') : null; + var groupShuffleInput = row ? row.querySelector('[data-playlist-group-shuffle-value]') : null; + var groupModeInput = row ? row.querySelector('[data-playlist-group-mode-value]') : null; + var groupRepeatInput = row ? row.querySelector('[data-playlist-group-repeat-count-value]') : null; + var groupScheduleInput = row ? row.querySelector('[data-playlist-group-schedule-rules]') : null; + var groupKey = String(values.groupKey || ''); + var groupScheduleRules = Array.isArray(values.groupScheduleRules) ? values.groupScheduleRules : []; var scheduleSummaryChip = row ? row.querySelector('[data-schedule-summary-chip]') : null; var rulesContainer = row ? row.querySelector('[data-schedule-rules-container]') : null; @@ -2239,6 +3306,7 @@ row.setAttribute('data-row-key', rowKey); row.setAttribute('data-slide-id', String(values.slide_id || '')); + row.setAttribute('data-group-key', groupKey); row.setAttribute('data-canvas-id', String(values.canvas_id || '')); row.setAttribute('data-video-source-path', String(values.videoSourcePath || '')); row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || '')); @@ -2262,6 +3330,23 @@ if (rowKeyInput) { rowKeyInput.value = String(rowKey || ''); } + if (groupKeyInput) { + groupKeyInput.value = groupKey; + } + if (groupNameInput) { + groupNameInput.value = String(values.groupName || ''); + } + if (groupPickCountInput) { + groupPickCountInput.value = String(Math.max(1, Number(values.groupPickCount) || 1)); + } + if (groupShuffleInput) { + groupShuffleInput.value = values.groupShuffle ? '1' : '0'; + } + if (groupModeInput) groupModeInput.value = values.groupMode === 'repeat' ? 'repeat' : 'select'; + if (groupRepeatInput) groupRepeatInput.value = String(Math.min(100, Math.max(1, Number(values.groupRepeatCount) || 1))); + if (groupScheduleInput) { + groupScheduleInput.value = JSON.stringify(groupScheduleRules); + } if (scheduleSummaryChip) { scheduleSummaryChip.textContent = summary; } @@ -2316,6 +3401,7 @@ row = document.createElement('tr'); row.setAttribute('data-playlist-slide-row', ''); row.setAttribute('data-row-key', rowKey); + row.setAttribute('data-group-key', String(values.groupKey || '')); row.setAttribute('data-slide-id', String(values.slide_id)); row.setAttribute('data-canvas-id', String(values.canvas_id || '')); row.setAttribute('data-video-source-path', String(values.videoSourcePath || '')); @@ -2441,6 +3527,68 @@ return; } + if (createGroupButton && event.target.closest('#playlist-create-group')) { + event.preventDefault(); + var nextGroupKey = 'group-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + var emptyPlaylistRow = tbody.querySelector('.playlist-empty-row'); + if (emptyPlaylistRow) emptyPlaylistRow.remove(); + var newGroupHeader = createGroupHeader({ groupKey: nextGroupKey, groupName: 'New group', groupPickCount: 1, groupScheduleRules: [] }); + tbody.appendChild(newGroupHeader); + updateRowOrder(true); + var newGroupName = newGroupHeader.querySelector('[data-playlist-group-name]'); + if (newGroupName) { + newGroupName.focus(); + newGroupName.select(); + } + return; + } + + var groupHeader = event.target.closest('tr[data-playlist-group-row]'); + var groupModeButton = event.target.closest('[data-playlist-group-mode]'); + if (groupHeader && groupModeButton) { + event.preventDefault(); + groupHeader.setAttribute('data-group-mode', groupModeButton.getAttribute('data-playlist-group-mode') === 'repeat' ? 'repeat' : 'select'); + syncGroupRowMetadata(groupHeader); + markPlaylistDirty(); + return; + } + var groupShuffleButton = event.target.closest('[data-playlist-group-shuffle]'); + if (groupHeader && groupShuffleButton) { + event.preventDefault(); + var shuffleEnabled = groupShuffleButton.getAttribute('aria-pressed') !== 'true'; + groupShuffleButton.setAttribute('aria-pressed', shuffleEnabled ? 'true' : 'false'); + groupShuffleButton.setAttribute('aria-label', shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'); + groupShuffleButton.title = shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'; + groupShuffleButton.classList.toggle('btn-secondary', shuffleEnabled); + groupShuffleButton.classList.toggle('btn-outline-secondary', !shuffleEnabled); + groupShuffleButton.querySelector('i').className = shuffleEnabled ? 'bi bi-shuffle' : 'bi bi-chevron-double-right'; + syncGroupRowMetadata(groupHeader); + markPlaylistDirty(); + return; + } + var groupMoveButton = event.target.closest('[data-playlist-group-move]'); + var groupRemoveButton = event.target.closest('[data-playlist-group-remove]'); + var groupScheduleButton = event.target.closest('[data-group-schedule-config]'); + if (groupHeader && groupMoveButton) { + event.preventDefault(); + moveGroupBlock(groupHeader, groupMoveButton.getAttribute('data-playlist-group-move') === 'up' ? -1 : 1); + return; + } + if (groupHeader && groupRemoveButton) { + event.preventDefault(); + removePlaylistGroup(groupHeader); + return; + } + if (groupHeader && groupScheduleButton && !groupScheduleButton.disabled) { + event.preventDefault(); + if (typeof window.openScheduleModal === 'function') { + var groupKey = String(groupHeader.getAttribute('data-group-key') || ''); + var groupScheduleParams = collectScheduleParams(groupHeader, 'group:' + groupKey, true); + window.openScheduleModal(groupScheduleButton.getAttribute('data-group-schedule-config') + '?' + groupScheduleParams.toString()); + } + return; + } + var moveButton = event.target.closest('[data-playlist-row-move]'); var removeButton = event.target.closest('[data-playlist-remove-row]'); var durationButton = event.target.closest('.playlist-use-video-duration'); @@ -2582,9 +3730,54 @@ editedMirror.parentNode.removeChild(editedMirror); } markPlaylistDirty(); + return; + } + + if (event.target && event.target.matches('[data-playlist-group-name]')) { + var editedGroupHeader = event.target.closest('[data-playlist-group-row]'); + if (!editedGroupHeader) { + return; + } + syncGroupRowMetadata(editedGroupHeader); + markPlaylistDirty(); + return; + } + + if (event.target && event.target.matches('[data-playlist-group-pick-count], [data-playlist-group-repeat-count]')) { + var quotaGroupHeader = event.target.closest('[data-playlist-group-row]'); + if (quotaGroupHeader) { + syncGroupRowMetadata(quotaGroupHeader); + markPlaylistDirty(); + } } }); + document.addEventListener('change', function (event) { + if (!event.target) { + return; + } + if (event.target.matches('[data-playlist-group-name]')) { + var groupNameHeader = event.target.closest('[data-playlist-group-row]'); + if (groupNameHeader) { + if (!String(event.target.value || '').trim()) event.target.value = 'Group'; + syncGroupRowMetadata(groupNameHeader); + markPlaylistDirty(); + } + return; + } + if (event.target.matches('[data-playlist-group-pick-count], [data-playlist-group-repeat-count]')) { + var groupQuotaHeader = event.target.closest('[data-playlist-group-row]'); + if (groupQuotaHeader) { + event.target.value = String(Math.max(1, Math.floor(Number(event.target.value) || 1))); + syncGroupRowMetadata(groupQuotaHeader); + markPlaylistDirty(); + } + return; + } + }); + + initializePlaylistGroups(tbody); + bindGroupBlockDrag(tbody); bindPlaylistTableDrag(tbody); hydrateScheduleRowContainers(tbody); initPlaylistScheduleModal(); @@ -2638,7 +3831,11 @@ } window.rebindPlaylistTableDrag = function (tbodyElement) { + initializePlaylistGroups(tbodyElement); + bindGroupBlockDrag(tbodyElement); bindPlaylistTableDrag(tbodyElement); + hydrateScheduleRowContainers(tbodyElement); + updateRowOrder(); }; updateRowOrder(); diff --git a/src/web/public/js/playlists/playlist-table-drag.js b/src/web/public/js/playlists/playlist-table-drag.js index 8bdd06c..8115594 100644 --- a/src/web/public/js/playlists/playlist-table-drag.js +++ b/src/web/public/js/playlists/playlist-table-drag.js @@ -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; }()); \ No newline at end of file diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 2131ed7..f5b02be 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -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 }); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 030c9a7..573db76 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -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 : []; diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index 874b367..8566be3 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -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) diff --git a/src/web/routes/signage/playlists/slide-config.js b/src/web/routes/signage/playlists/slide-config.js index 68043de..0c1c6ae 100644 --- a/src/web/routes/signage/playlists/slide-config.js +++ b/src/web/routes/signage/playlists/slide-config.js @@ -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, diff --git a/src/web/routes/signage/screens/form-view-model.js b/src/web/routes/signage/screens/form-view-model.js index af42af4..9a8bf8d 100644 --- a/src/web/routes/signage/screens/form-view-model.js +++ b/src/web/routes/signage/screens/form-view-model.js @@ -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"' diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index 245fa05..3ede382 100644 --- a/src/web/routes/signage/screens/routes.js +++ b/src/web/routes/signage/screens/routes.js @@ -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) { diff --git a/src/web/views/data-sources/api-sources/form.hbs b/src/web/views/data-sources/api-sources/form.hbs index 8288a90..5201e1f 100644 --- a/src/web/views/data-sources/api-sources/form.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -32,7 +32,7 @@ -
+

Request

diff --git a/src/web/views/shared/frame-layout.hbs b/src/web/views/shared/frame-layout.hbs index efe72c9..dbac89c 100644 --- a/src/web/views/shared/frame-layout.hbs +++ b/src/web/views/shared/frame-layout.hbs @@ -9,7 +9,7 @@ - + {{#if stylesheets.length}} {{#each stylesheets}} diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index 5a92564..cee0c5a 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -9,7 +9,7 @@ - + {{#if stylesheets.length}} {{#each stylesheets}} diff --git a/src/web/views/signage/playlists/form.hbs b/src/web/views/signage/playlists/form.hbs index 0cf0ff2..132aa53 100644 --- a/src/web/views/signage/playlists/form.hbs +++ b/src/web/views/signage/playlists/form.hbs @@ -58,9 +58,10 @@
-
-

Playlist items

-
+
+

Playlist items

+
+
diff --git a/src/web/views/signage/playlists/slide-config.hbs b/src/web/views/signage/playlists/slide-config.hbs index ea5f601..2d27126 100644 --- a/src/web/views/signage/playlists/slide-config.hbs +++ b/src/web/views/signage/playlists/slide-config.hbs @@ -1,6 +1,6 @@
-

Slide schedule

+

{{scheduleHeading}}

diff --git a/src/web/views/signage/playlists/slide-row.hbs b/src/web/views/signage/playlists/slide-row.hbs index bca9541..d563f88 100644 --- a/src/web/views/signage/playlists/slide-row.hbs +++ b/src/web/views/signage/playlists/slide-row.hbs @@ -1,4 +1,4 @@ - +
@@ -24,6 +24,13 @@
+ + + + + + +
{{slide.scheduleSummary}}
@@ -33,7 +40,7 @@
- +
diff --git a/src/web/views/signage/screens/form.hbs b/src/web/views/signage/screens/form.hbs index 911b3bb..574edae 100644 --- a/src/web/views/signage/screens/form.hbs +++ b/src/web/views/signage/screens/form.hbs @@ -33,10 +33,20 @@
-
diff --git a/test/onboarding-device-prune.test.js b/test/onboarding-device-prune.test.js index 04701a4..e9246d6 100644 --- a/test/onboarding-device-prune.test.js +++ b/test/onboarding-device-prune.test.js @@ -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/); }); diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index 81b5de0..cf20803 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -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, diff --git a/test/player-playlist.test.js b/test/player-playlist.test.js index dd7f6b7..a7cf703 100644 --- a/test/player-playlist.test.js +++ b/test/player-playlist.test.js @@ -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 }); diff --git a/test/player-preload-helpers.test.js b/test/player-preload-helpers.test.js index c98aae2..69507dd 100644 --- a/test/player-preload-helpers.test.js +++ b/test/player-preload-helpers.test.js @@ -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 = ''; diff --git a/test/playlists-duplicate.test.js b/test/playlists-duplicate.test.js index db3355a..6d015a4 100644 --- a/test/playlists-duplicate.test.js +++ b/test/playlists-duplicate.test.js @@ -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, /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', () => { @@ -53,4 +61,177 @@ 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, / { + 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'); }); \ No newline at end of file diff --git a/test/playlists-routes.test.js b/test/playlists-routes.test.js index 4f11354..cf6366f 100644 --- a/test/playlists-routes.test.js +++ b/test/playlists-routes.test.js @@ -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); + } }); \ No newline at end of file diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index fce2920..d68f31b 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -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([ { diff --git a/test/web-manage-routes.test.js b/test/web-manage-routes.test.js index 87ff64c..f2591c3 100644 --- a/test/web-manage-routes.test.js +++ b/test/web-manage-routes.test.js @@ -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 = { diff --git a/test/web-screens-routes.test.js b/test/web-screens-routes.test.js index 82704fc..4a4d483 100644 --- a/test/web-screens-routes.test.js +++ b/test/web-screens-routes.test.js @@ -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*
{ const handlers = {}; const app = {