(function () { // Shared constants used across the playlist schedule editor. var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail'; var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist'; var PICKER_CARD_CLASS = 'playlist-slide-picker-card'; var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media'; var PICKER_TITLE_CLASS = 'playlist-slide-picker-title'; var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle'; var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary'; var SCHEDULE_ROW_FIELD_NAMES = { rulesContainer: '[data-schedule-rules-container]', summaryNode: '.playlist-schedule-summary' }; // Schedule helpers. function formatDays(daysValue) { var days = []; if (Array.isArray(daysValue)) { days = daysValue; } else if (daysValue) { try { var parsedDays = JSON.parse(daysValue); days = Array.isArray(parsedDays) ? parsedDays : []; } catch (_error) { days = []; } } return days .map(function (day) { return DAY_NAMES[Number(day)] || ''; }) .filter(Boolean) .join(', '); } function normalizeScheduleRuleValue(rule) { var input = rule && typeof rule === 'object' ? rule : {}; var startDatetime = String(input.start_datetime || input.startDateTime || input.startDateTimeValue || '').trim(); var endDatetime = String(input.end_datetime || input.endDateTime || input.endDateTimeValue || '').trim(); var startTime = String(input.start_time || input.startTime || input.startTimeValue || '').trim(); var endTime = String(input.end_time || input.endTime || input.endTimeValue || '').trim(); var days = []; if (Array.isArray(input.days)) { days = input.days; } else if (Array.isArray(input.dayOptions)) { days = input.dayOptions.filter(function (dayOption) { return Boolean(dayOption && dayOption.checked); }).map(function (dayOption) { return dayOption.value; }); } else if (input.daysCsv !== undefined && input.daysCsv !== null && String(input.daysCsv).trim()) { days = String(input.daysCsv).split(','); } else if (input.days !== undefined && input.days !== null && String(input.days).trim()) { try { var parsedDays = JSON.parse(String(input.days)); days = Array.isArray(parsedDays) ? parsedDays : []; } catch (_error) { days = String(input.days).split(','); } } days = Array.from(new Set(days.map(function (day) { return Number(day); }).filter(function (day) { return Number.isInteger(day) && day >= 0 && day <= 6; }))).sort(function (left, right) { return left - right; }); if (startDatetime || endDatetime) { if (!startDatetime || !endDatetime) { return null; } } if (startTime || endTime) { if (!startTime || !endTime) { return null; } } if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) { return null; } var normalized = {}; if (startDatetime) { normalized.start_datetime = startDatetime; } if (endDatetime) { normalized.end_datetime = endDatetime; } if (startTime) { normalized.start_time = startTime; } if (endTime) { normalized.end_time = endTime; } if (days.length) { normalized.days = days; } return normalized; } function parseScheduleRulesValue(value) { var rawRules = []; if (Array.isArray(value)) { rawRules = value; } else { var raw = String(value || '').trim(); if (!raw) { return []; } try { var parsed = JSON.parse(raw); rawRules = Array.isArray(parsed) ? parsed : []; } catch (_error) { return []; } } return rawRules.map(function (rule) { return normalizeScheduleRuleValue(rule); }).filter(Boolean); } function formatScheduleRuleSummary(rule) { var normalized = normalizeScheduleRuleValue(rule); var left = []; var right = []; if (!normalized) { return 'Always visible'; } if (normalized.start_datetime && normalized.end_datetime) { left.push('Dates ' + normalized.start_datetime.replace('T', ' ').slice(0, 16) + ' to ' + normalized.end_datetime.replace('T', ' ').slice(0, 16)); } else { left.push('Any date'); } var days = formatDays(normalized.days || []); if (days) { right.push(days); } if (normalized.start_time && normalized.end_time) { right.push(normalized.start_time.slice(0, 5) + '-' + normalized.end_time.slice(0, 5)); } if (right.length) { return left[0] + ': ' + right.join(' '); } return left[0]; } function formatScheduleRulesSummary(value) { var rules = parseScheduleRulesValue(value); if (!rules.length) { return 'Always visible'; } return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules'; } function getScheduleRowFields(row) { return { rulesContainer: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.rulesContainer) : null, summaryNode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.summaryNode) : null }; } function buildScheduleRuleHiddenInputs(rule, rowKey, position, formId) { var normalizedRule = normalizeScheduleRuleValue(rule) || {}; var daysCsv = Array.isArray(normalizedRule.days) ? normalizedRule.days.join(',') : ''; var formAttr = formId ? ' form="' + formId + '"' : ''; return '' + '' + '' + '' + '' + '' + '' + ''; } function buildScheduleRulesMarkup(values, rowKey, formId) { var rules = Array.isArray(values) ? values : []; return rules.map(function (rule, index) { return buildScheduleRuleHiddenInputs(rule, rowKey, index, formId); }).join(''); } function parseLocalDate(value) { var date = new Date(String(value || '').trim()); if (Number.isNaN(date.getTime())) { return null; } return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function isDayInDateRange(startDatetime, endDatetime, dayIndex) { var startDate = parseLocalDate(startDatetime); var endDate = parseLocalDate(endDatetime); var currentDate; if (!startDate || !endDate) { return false; } for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) { if (currentDate.getDay() === Number(dayIndex)) { return true; } } return false; } function getTimeParts(value) { var match = String(value || '').trim().match(/^(\d{2}):(\d{2})(?::\d{2})?$/); if (!match) { return null; } return { hours: Number(match[1]), minutes: Number(match[2]) }; } function combineDateAndTime(baseDate, timeValue) { var timeParts = getTimeParts(timeValue); if (!baseDate || !timeParts) { return null; } return new Date( baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), timeParts.hours, timeParts.minutes, 0, 0 ); } function hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, selectedDays) { var startDateTime = new Date(String(startDatetime || '').trim()); var endDateTime = new Date(String(endDatetime || '').trim()); var startDate = parseLocalDate(startDatetime); var endDate = parseLocalDate(endDatetime); var allowedDays = Array.isArray(selectedDays) && selectedDays.length ? selectedDays : [0, 1, 2, 3, 4, 5, 6]; var currentDate; if (!startDate || !endDate || Number.isNaN(startDateTime.getTime()) || Number.isNaN(endDateTime.getTime())) { return false; } for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) { if (allowedDays.indexOf(currentDate.getDay()) === -1) { continue; } var dayStart = combineDateAndTime(currentDate, startTime); var dayEnd = combineDateAndTime(currentDate, endTime); if (!dayStart || !dayEnd) { continue; } if (dayEnd >= startDateTime && dayStart <= endDateTime) { return true; } } return false; } function getDateRangeDayIndices(startDatetime, endDatetime) { var startDate = parseLocalDate(startDatetime); var endDate = parseLocalDate(endDatetime); var dayIndices = []; var currentDate; if (!startDate || !endDate) { return dayIndices; } for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) { if (dayIndices.indexOf(currentDate.getDay()) === -1) { dayIndices.push(currentDate.getDay()); } } return dayIndices; } function setDayInputError(card, dayInput, message) { var dayInputs = card ? Array.prototype.slice.call(card.querySelectorAll('[data-schedule-rule-day]')) : []; var dayLabels = card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : []; var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null; var dayIndex = dayInputs.indexOf(dayInput); if (dayInput) { dayInput.setCustomValidity(message); dayInput.classList.add('is-invalid'); dayInput.setAttribute('aria-invalid', 'true'); } if (dayIndex !== -1 && dayLabels[dayIndex]) { dayLabels[dayIndex].classList.add('is-invalid'); dayLabels[dayIndex].setAttribute('aria-invalid', 'true'); } if (feedbackNode) { var messageNode = document.createElement('div'); messageNode.textContent = message; feedbackNode.appendChild(messageNode); feedbackNode.hidden = false; } } function appendRuleFeedbackMessage(card, message) { var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null; if (!feedbackNode) { return; } var messageNode = document.createElement('div'); messageNode.textContent = message; feedbackNode.appendChild(messageNode); feedbackNode.hidden = false; } function validateScheduleRuleCard(card) { var fields = getRuleFields(card); var dayInputs = Array.prototype.slice.call(fields.days || []); var dayLabels = getRuleDayLabels(card); var checkedDayInputs = dayInputs.filter(function (input) { return Boolean(input && input.checked); }); var checkedDayIndices = checkedDayInputs.map(function (input) { return Number(input && input.value); }); var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim(); var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim(); var startTime = String(fields.startTime && fields.startTime.value || '').trim(); var endTime = String(fields.endTime && fields.endTime.value || '').trim(); clearRuleValidity(card); if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) { setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.'); return false; } if ((startTime || endTime) && (!startTime || !endTime)) { setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.'); return false; } if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) { setRuleError(fields.endDatetime, 'End datetime must be after start datetime.'); return false; } if (startTime && endTime && endTime <= startTime) { setRuleError(fields.endTime, 'End time must be after start time.'); if (fields.startTime) { fields.startTime.classList.add('is-invalid'); fields.startTime.setAttribute('aria-invalid', 'true'); } return false; } if (startDatetime && endDatetime) { var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime); var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6]; var invalidDayInputs = checkedDayInputs.filter(function (input) { return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1; }); invalidDayInputs.forEach(function (input) { var dayIndex = dayInputs.indexOf(input); if (dayIndex !== -1 && dayLabels[dayIndex]) { dayLabels[dayIndex].classList.add('is-invalid'); dayLabels[dayIndex].setAttribute('aria-invalid', 'true'); } }); if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) { setDayInputError(card, invalidDayInputs[0] || checkedDayInputs[0] || dayInputs[0], 'Selected days must overlap the date range.'); return false; } if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) { setRuleError(fields.endTime, 'Start and end times must overlap the date range.'); if (fields.startTime) { fields.startTime.classList.add('is-invalid'); fields.startTime.setAttribute('aria-invalid', 'true'); } return false; } } return true; } function getScheduleRuleValidationIssue(card) { var fields = getRuleFields(card); var dayInputs = Array.prototype.slice.call(fields.days || []); var checkedDayInputs = dayInputs.filter(function (input) { return Boolean(input && input.checked); }); var checkedDayIndices = checkedDayInputs.map(function (input) { return Number(input && input.value); }); var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim(); var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim(); var startTime = String(fields.startTime && fields.startTime.value || '').trim(); var endTime = String(fields.endTime && fields.endTime.value || '').trim(); if (startDatetime || endDatetime) { if (!startDatetime || !endDatetime) { return { type: 'datetime-pair', target: !startDatetime ? fields.startDatetime : fields.endDatetime, message: 'Start and end datetimes must both be set for this rule.' }; } } if (startTime || endTime) { if (!startTime || !endTime) { return { type: 'time-pair', target: !startTime ? fields.startTime : fields.endTime, message: 'Start and end times must both be set for this rule.' }; } } if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) { return { type: 'datetime-order', target: fields.endDatetime, message: 'End datetime must be after start datetime.' }; } if (startTime && endTime && endTime <= startTime) { return { type: 'time-order', target: fields.endTime, message: 'End time must be after start time.' }; } if (startDatetime && endDatetime) { var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime); var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6]; var invalidDayInputs = checkedDayInputs.filter(function (input) { return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1; }); if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) { return { type: 'day-overlap', target: checkedDayInputs[0] || dayInputs[0] || fields.endDatetime, message: 'Selected days must overlap the date range.' }; } if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) { return { type: 'time-overlap', target: fields.endTime, secondaryTarget: fields.startTime, message: 'Start and end times must overlap the date range.' }; } } return null; } function applyScheduleRuleValidationIssue(card, issue) { var fields = getRuleFields(card); var dayLabels = getRuleDayLabels(card); clearRuleValidity(card); if (!issue) { return true; } if (issue.type === 'time-overlap') { setRuleError(issue.target || fields.endTime, issue.message); if (issue.secondaryTarget) { issue.secondaryTarget.classList.add('is-invalid'); issue.secondaryTarget.setAttribute('aria-invalid', 'true'); } return false; } if (issue.type === 'day-overlap') { var dayInputs = Array.prototype.slice.call(fields.days || []); var dayIndex = dayInputs.indexOf(issue.target); setRuleError(issue.target, issue.message); if (dayIndex !== -1 && dayLabels[dayIndex]) { dayLabels[dayIndex].classList.add('is-invalid'); dayLabels[dayIndex].setAttribute('aria-invalid', 'true'); } return false; } setRuleError(issue.target, issue.message); return false; } // Video duration helpers. function getVideoDurationButtonLabel(isPressed) { return isPressed ? 'Disable use video duration' : 'Use video duration'; } function buildVideoDurationButtonContent(isPressed) { return '' + '' + '' + getVideoDurationButtonLabel(isPressed) + ''; } function buildVideoDurationButtonMarkup(isPressed, enabled) { if (!enabled) { return ''; } return ''; } function getDisableAudioButtonLabel(isPressed) { return isPressed ? 'Enable audio' : 'Mute slide'; } function buildDisableAudioButtonContent(isPressed) { return '' + '' + '' + getDisableAudioButtonLabel(isPressed) + ''; } function buildDisableAudioButtonMarkup(isPressed, enabled) { if (!enabled) { return ''; } return ''; } function syncDisableAudioButtonState(button, isPressed) { if (!button) { return; } button.classList.toggle('btn-outline-secondary', Boolean(isPressed)); button.classList.toggle('btn-secondary', !Boolean(isPressed)); button.setAttribute('aria-pressed', isPressed ? 'true' : 'false'); button.setAttribute('aria-label', getDisableAudioButtonLabel(isPressed)); button.setAttribute('title', getDisableAudioButtonLabel(isPressed)); button.innerHTML = buildDisableAudioButtonContent(isPressed); } function syncVideoDurationButtonState(button, isPressed) { if (!button) { return; } button.classList.toggle('btn-outline-info', !Boolean(isPressed)); button.classList.toggle('btn-info', Boolean(isPressed)); button.setAttribute('aria-pressed', isPressed ? 'true' : 'false'); button.setAttribute('aria-label', getVideoDurationButtonLabel(isPressed)); button.setAttribute('title', getVideoDurationButtonLabel(isPressed)); button.innerHTML = buildVideoDurationButtonContent(isPressed); } // Slide picker helpers. function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) { if (!showAssignedSlides && assignedCount && visibleCount === 0) { return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.'; } return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.'; } function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) { if (!card) { return; } card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false'); card.classList.toggle('is-assigned', isAssigned); card.classList.toggle('is-mismatch', isCanvasMismatch); card.classList.toggle('is-hidden', false); } function createElementWithClass(tagName, className, textContent) { var element = document.createElement(tagName); if (className) { element.className = className; } if (textContent !== undefined) { element.textContent = textContent; } return element; } function escapeHtmlAttribute(value) { return String(value === undefined || value === null ? '' : value) .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } function buildScheduleSummaryChipMarkup(summary) { return '' + escapeHtmlAttribute(String(summary || 'Always visible')) + ''; } function buildSlidePickerThumbPlaceholder() { var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder'); var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon'); var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL); icon.setAttribute('aria-hidden', 'true'); placeholder.appendChild(icon); placeholder.appendChild(label); return placeholder; } function buildSlidePickerThumbImage(slide) { var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image'); image.loading = 'lazy'; image.alt = String(slide.title || 'Slide'); image.src = String(slide.thumbnail_path || ''); image.addEventListener('error', function () { if (image.parentNode) { image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image); } }); return image; } function buildSlidePickerMedia(slide) { var media = createElementWithClass('div', PICKER_MEDIA_CLASS); var check = createElementWithClass('span', PICKER_CHECK_CLASS); var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL); check.setAttribute('aria-hidden', 'true'); badge.setAttribute('aria-hidden', 'true'); media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder()); media.appendChild(check); media.appendChild(badge); return media; } // Playlist row helpers. function getPlaylistSlideRow(target) { return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null; } function getPlaylistSlideRowKey(row) { return String(row && row.getAttribute('data-row-key') || ''); } function isDurationControl(target) { return Boolean(target && target.closest && target.closest('.playlist-use-video-duration')); } function isAudioControl(target) { return Boolean(target && target.closest && target.closest('.playlist-disable-audio')); } function getDurationPointerState(target, type) { var row = getPlaylistSlideRow(target); var pointerType = type === 'button' ? 'button' : 'input'; if (!row) { return null; } return { type: pointerType, rowKey: getPlaylistSlideRowKey(row) }; } function buildPlaylistOrderCellMarkup() { return '' + '' + '
' + '
' + '' + '' + '' + '
' + '' + '
' + ''; } function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) { return '' + '' + '
' + '' + '
' + values.title + '
' + '
' + '' + '' + ''; } function buildPlaylistScheduleCellMarkup(values, rowKey) { return '' + '' + '
' + buildScheduleSummaryChipMarkup(values.summary || 'Always visible') + '
' + '
' + buildScheduleRulesMarkup(values.scheduleRules, rowKey, 'playlist-edit-form') + '
' + ''; } function buildPlaylistDurationCellMarkup(values, durationActionMarkup) { return '' + '
' + '
' + '' + '' + '
' + '' + '' + (values.useVideoDuration ? '' : '') + durationActionMarkup + '
'; } function buildPlaylistActionsCellMarkup(playlistId, rowKey) { var scheduleUrl = '/playlists/new/slides/0/config'; var scheduleButtonMarkup = ''; return '' + '
' + scheduleButtonMarkup + '' + '
'; } function collectScheduleParams(row, rowKey) { var params = new URLSearchParams(); var fields = row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : []; params.set('row_key', String(rowKey || '')); fields.forEach(function (field) { if (!field || !field.name) { return; } params.append(field.name, String(field.value || '')); }); return params; } function openScheduleModal(url) { var dialog = document.getElementById('slide-schedule-dialog'); var content = document.getElementById('slide-schedule-content'); if (!dialog || !content) { return; } content.innerHTML = '
Loading schedule...
'; if (typeof dialog.showModal === 'function') { dialog.showModal(); } else { dialog.setAttribute('open', 'open'); } fetch(url, { credentials: 'same-origin' }).then(function (response) { if (!response.ok) { return response.text().then(function (text) { throw new Error(text || 'Unable to open schedule editor.'); }); } return response.text(); }).then(function (html) { content.innerHTML = html; if (typeof window.initPlaylistScheduleForm === 'function') { window.initPlaylistScheduleForm(content); } }).catch(function (error) { content.innerHTML = '
' + escapeHtmlAttribute(String(error && error.message ? error.message : 'Unable to open schedule editor.')) + '
'; }); } function closeScheduleModal(options) { var dialog = document.getElementById('slide-schedule-dialog'); var content = document.getElementById('slide-schedule-content'); var shouldDiscardDraft = Boolean(options && options.discardDraft); var shouldPreserveChanges = Boolean(options && options.preserveChanges); if (shouldDiscardDraft && !shouldPreserveChanges && typeof window.restorePlaylistScheduleDraft === 'function') { window.restorePlaylistScheduleDraft(); } if (shouldDiscardDraft && typeof window.clearPlaylistScheduleDraft === 'function') { window.clearPlaylistScheduleDraft(); } if (content) { content.innerHTML = ''; } if (!dialog) { return; } if (typeof dialog.close === 'function') { dialog.close(); } else { dialog.removeAttribute('open'); } } function initPlaylistScheduleModal() { window.openScheduleModal = openScheduleModal; window.closeScheduleModal = closeScheduleModal; } // Keep schedule rule markup in sync with each rendered playlist row. function hydrateScheduleRowContainers(root) { var scope = root || document; var rows = Array.prototype.slice.call(scope.querySelectorAll('[data-playlist-slide-row]')); rows.forEach(function (row) { var fields = getScheduleRowFields(row); var data = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : ''; var rules = []; if (!fields.rulesContainer) { return; } if (!data && fields.rulesContainer.children.length) { return; } if (data) { try { rules = JSON.parse(data); } catch (_error) { rules = []; } } if (!fields.rulesContainer.children.length) { fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, row.getAttribute('data-row-key') || '', 'playlist-edit-form'); } }); } function setRowSchedule(row, values) { var rowKey = String(values && values.row_key ? values.row_key : row && row.getAttribute('data-row-key') || ''); var fields = getScheduleRowFields(row); var rules = Array.isArray(values && values.scheduleRules) ? values.scheduleRules : []; var summary = String(values && values.summary ? values.summary : formatScheduleRulesSummary(rules) || 'Always visible'); if (!row) { return; } if (fields.summaryNode) { fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(summary || 'Always visible'); } if (fields.rulesContainer) { fields.rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(rules).replace(/' + '
' + '
' + '

Rule ' + String(ruleIndex) + '

' + '
' + escapeHtmlAttribute(formatScheduleRuleSummary(normalizedRule)) + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '
' + DAY_NAMES.map(function (label, dayIndex) { return '' + '' + '' + ''; }).join('') + '
' + '
' + '
' + '' + '
' + ''; } // Modal form state, validation, and submit handling. function initPlaylistScheduleForm(root) { var scope = root || document; var form = scope.querySelector('form[action*="/config"], form[data-schedule-draft="true"]'); var scheduleRuleList = scope.querySelector('#schedule-rule-list'); var effectiveSummary = scope.querySelector('#schedule-effective-summary'); var addRuleButton = scope.querySelector('#schedule-add-rule'); var scheduleRuleTemplate = scope.querySelector('#schedule-rule-card-template'); if (!form || !scheduleRuleList) { return; } var isDraftMode = Boolean(form.getAttribute('data-schedule-draft') === 'true'); var rowKeyInput = form.querySelector('[name="row_key"]'); var draftStore = window.__playlistScheduleDraftStore = window.__playlistScheduleDraftStore || {}; var draftSnapshotStore = window.__playlistScheduleDraftSnapshotStore = window.__playlistScheduleDraftSnapshotStore || {}; var cancelButton = scope.querySelector('[data-schedule-cancel]'); // Draft state is keyed by the row being edited so cancel/save can restore it later. function getScheduleDraftKey() { return String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : '').trim(); } function readRuleDraftFromCard(card) { var fields = getRuleFields(card); var days = []; Array.prototype.forEach.call(fields.days || [], function (checkbox) { if (checkbox.checked) { days.push(Number(checkbox.value)); } }); days = Array.from(new Set(days)).sort(function (left, right) { return left - right; }); return { start_datetime: String(fields.startDatetime && fields.startDatetime.value || ''), end_datetime: String(fields.endDatetime && fields.endDatetime.value || ''), start_time: String(fields.startTime && fields.startTime.value || ''), end_time: String(fields.endTime && fields.endTime.value || ''), days: days }; } function captureScheduleDraftState() { return getRuleCards().map(function (card) { return { rule: readRuleDraftFromCard(card), isCollapsed: Boolean(card && card.classList && card.classList.contains('collapsed-card')) }; }); } function renderScheduleDraftState(ruleStates) { var states = Array.isArray(ruleStates) ? ruleStates : []; scheduleRuleList.innerHTML = ''; states.forEach(function (ruleState, index) { var card = createRuleCardElement(ruleState && ruleState.rule ? ruleState.rule : {}, index + 1, { isCollapsed: Boolean(ruleState && ruleState.isCollapsed) }); if (card) { scheduleRuleList.appendChild(card); } }); refreshRuleNumbers(); syncScheduleRulesField(); } function restoreScheduleDraft() { var draftKey = getScheduleDraftKey(); var snapshot = draftKey && Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey) ? draftSnapshotStore[draftKey] : null; if (!draftKey || !Array.isArray(snapshot)) { return; } renderScheduleDraftState(snapshot); } function getScheduleDraftRules() { var draftKey = getScheduleDraftKey(); if (!draftKey || !Object.prototype.hasOwnProperty.call(draftStore, draftKey)) { return null; } return Array.isArray(draftStore[draftKey]) ? draftStore[draftKey] : []; } function persistScheduleDraft() { var draftKey = getScheduleDraftKey(); if (!draftKey) { return; } draftStore[draftKey] = getRuleCards().map(function (card) { return { rule: readRuleDraftFromCard(card), isCollapsed: Boolean(card && card.classList && card.classList.contains('collapsed-card')) }; }); } function clearScheduleDraft() { var draftKey = getScheduleDraftKey(); if (draftKey && Object.prototype.hasOwnProperty.call(draftStore, draftKey)) { delete draftStore[draftKey]; } if (draftKey && Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey)) { delete draftSnapshotStore[draftKey]; } } window.clearPlaylistScheduleDraft = clearScheduleDraft; window.restorePlaylistScheduleDraft = restoreScheduleDraft; var draftKey = getScheduleDraftKey(); if (draftKey && !Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey)) { draftSnapshotStore[draftKey] = captureScheduleDraftState(); } function getRuleCards() { return Array.prototype.slice.call(scheduleRuleList.querySelectorAll('[data-schedule-rule-card]')); } function getRuleFields(card) { return { startDatetime: card ? card.querySelector('[data-schedule-rule-start-datetime]') : null, endDatetime: card ? card.querySelector('[data-schedule-rule-end-datetime]') : null, startTime: card ? card.querySelector('[data-schedule-rule-start-time]') : null, endTime: card ? card.querySelector('[data-schedule-rule-end-time]') : null, days: card ? card.querySelectorAll('[data-schedule-rule-day]') : [] }; } function getRuleSummaryNode(card) { return card ? card.querySelector('[data-schedule-rule-summary]') : null; } function getRuleNumberNode(card) { return card ? card.querySelector('[data-schedule-rule-number]') : null; } function getRuleRowKeyNode(card) { return card ? card.querySelector('[data-schedule-rule-row-key]') : null; } function getRulePositionNode(card) { return card ? card.querySelector('[data-schedule-rule-position]') : null; } function getRemoveRuleButton(card) { return card ? card.querySelector('[data-remove-schedule-rule]') : null; } function getRuleDayLabels(card) { return card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : []; } function getCollapseRuleButton(card) { return card ? card.querySelector('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null; } function getCollapseRuleIcon(card) { return card ? card.querySelector('[data-schedule-rule-collapse-icon]') : null; } function syncRuleCollapseButtonState(card) { var button = getCollapseRuleButton(card); var icon = getCollapseRuleIcon(card); var numberNode = getRuleNumberNode(card); var ruleIndex = numberNode ? String(numberNode.textContent || '') : ''; var isCollapsed = Boolean(card && card.classList && card.classList.contains('collapsed-card')); var label = (isCollapsed ? 'Expand' : 'Collapse') + ' rule ' + ruleIndex; if (!button) { return; } button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); button.setAttribute('aria-label', label); button.setAttribute('title', label); if (icon) { icon.classList.remove('bi-dash-lg', 'bi-plus-lg'); icon.classList.add(isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg'); } var visibleLabel = button.querySelector('.visually-hidden'); if (visibleLabel) { visibleLabel.textContent = label; } } function clearRuleValidity(card) { var fields = getRuleFields(card); var dayInputs = Array.prototype.slice.call(fields.days || []); var dayLabels = getRuleDayLabels(card); var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null; [fields.startDatetime, fields.endDatetime, fields.startTime, fields.endTime].forEach(function (input) { if (input) { input.setCustomValidity(''); input.classList.remove('is-invalid'); input.removeAttribute('aria-invalid'); } }); dayInputs.forEach(function (input) { input.setCustomValidity(''); input.classList.remove('is-invalid'); input.removeAttribute('aria-invalid'); }); dayLabels.forEach(function (label) { label.classList.remove('is-invalid'); label.removeAttribute('aria-invalid'); }); if (feedbackNode) { feedbackNode.innerHTML = ''; feedbackNode.hidden = true; } } function setRuleError(input, message) { if (!input) { return; } input.setCustomValidity(message); if (String(input.value || '').trim()) { input.classList.add('is-invalid'); input.setAttribute('aria-invalid', 'true'); } appendRuleFeedbackMessage(input.closest ? input.closest('[data-schedule-rule-card]') : null, message); } function validateScheduleRuleCard(card) { var fields = getRuleFields(card); var dayInputs = Array.prototype.slice.call(fields.days || []); var dayLabels = getRuleDayLabels(card); var checkedDayInputs = dayInputs.filter(function (input) { return Boolean(input && input.checked); }); var checkedDayIndices = checkedDayInputs.map(function (input) { return Number(input && input.value); }); var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim(); var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim(); var startTime = String(fields.startTime && fields.startTime.value || '').trim(); var endTime = String(fields.endTime && fields.endTime.value || '').trim(); var isValid = true; clearRuleValidity(card); if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) { setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.'); isValid = false; } if ((startTime || endTime) && (!startTime || !endTime)) { setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.'); isValid = false; } if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) { setRuleError(fields.endDatetime, 'End datetime must be after start datetime.'); isValid = false; } if (startTime && endTime && endTime <= startTime) { setRuleError(fields.endTime, 'End time must be after start time.'); if (fields.startTime) { fields.startTime.classList.add('is-invalid'); fields.startTime.setAttribute('aria-invalid', 'true'); } isValid = false; } if (startDatetime && endDatetime) { var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime); var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6]; var invalidDayInputs = checkedDayInputs.filter(function (input) { return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1; }); invalidDayInputs.forEach(function (input) { var dayIndex = dayInputs.indexOf(input); if (dayIndex !== -1 && dayLabels[dayIndex]) { dayLabels[dayIndex].classList.add('is-invalid'); dayLabels[dayIndex].setAttribute('aria-invalid', 'true'); } }); if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) { setDayInputError(card, checkedDayInputs[0] || dayInputs[0], 'Selected days must overlap the date range.'); isValid = false; } if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) { setRuleError(fields.endTime, 'Start and end times must overlap the date range.'); if (fields.startTime) { fields.startTime.classList.add('is-invalid'); fields.startTime.setAttribute('aria-invalid', 'true'); } isValid = false; } } return isValid; } function updateLiveRuleValidity(card) { validateScheduleRuleCard(card); } function validateRuleCard(card) { return validateScheduleRuleCard(card); } function readRuleFromCard(card) { var fields = getRuleFields(card); var days = []; Array.prototype.forEach.call(fields.days || [], function (checkbox) { if (checkbox.checked) { days.push(Number(checkbox.value)); } }); days = Array.from(new Set(days)).sort(function (left, right) { return left - right; }); return normalizeScheduleRuleValue({ start_datetime: fields.startDatetime && fields.startDatetime.value || '', end_datetime: fields.endDatetime && fields.endDatetime.value || '', start_time: fields.startTime && fields.startTime.value || '', end_time: fields.endTime && fields.endTime.value || '', days: days }); } function updateRuleCardSummary(card) { var summaryNode = getRuleSummaryNode(card); var rule = readRuleFromCard(card); if (summaryNode) { summaryNode.textContent = formatScheduleRuleSummary(rule); } return rule; } function refreshRuleNumbers() { getRuleCards().forEach(function (card, index) { var numberNode = getRuleNumberNode(card); var rowKeyNode = getRuleRowKeyNode(card); var positionNode = getRulePositionNode(card); var removeButton = getRemoveRuleButton(card); var ruleIndex = index + 1; if (numberNode) { numberNode.textContent = String(ruleIndex); } if (rowKeyNode) { rowKeyNode.value = String(getScheduleDraftKey() || ''); } if (positionNode) { positionNode.value = String(index); } if (removeButton) { removeButton.disabled = false; } card.setAttribute('data-schedule-rule-key', 'schedule-rule-' + ruleIndex); }); } function renderEffectiveSummary(rules) { if (!effectiveSummary) { return; } effectiveSummary.innerHTML = ''; if (!rules.length) { effectiveSummary.textContent = 'Always visible'; return; } var list = document.createElement('ul'); list.className = 'mb-0'; rules.forEach(function (rule, index) { var item = document.createElement('li'); item.textContent = 'Rule ' + (index + 1) + ': ' + formatScheduleRuleSummary(rule); list.appendChild(item); }); effectiveSummary.appendChild(list); } // Rebuild the hidden form fields and keep the live summary chip current. function syncScheduleRulesField() { var rules = []; getRuleCards().forEach(function (card) { var rule = readRuleFromCard(card); if (rule) { rules.push(rule); } }); renderEffectiveSummary(rules); persistScheduleDraft(); if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { window.parent.applyPlaylistScheduleConfig({ row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''), scheduleRules: rules, summary: formatScheduleRulesSummary(rules) }); } return rules; } function getRuleCardTemplate() { if (!scheduleRuleTemplate || !scheduleRuleTemplate.content) { return null; } return scheduleRuleTemplate.content.firstElementChild; } function updateRuleCardDayOptions(card, ruleKey, normalizedRule) { var dayInputs = card ? Array.prototype.slice.call(card.querySelectorAll('[data-schedule-rule-day]')) : []; var dayLabels = card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : []; dayInputs.forEach(function (input, dayIndex) { var label = dayLabels[dayIndex]; var isChecked = Array.isArray(normalizedRule.days) && normalizedRule.days.indexOf(dayIndex) !== -1; var inputId = ruleKey + '-day-' + dayIndex; input.id = inputId; input.value = String(dayIndex); input.checked = isChecked; input.autocomplete = 'off'; input.setAttribute('data-schedule-rule-day', ''); if (label) { label.setAttribute('for', inputId); } }); } function createRuleCardElement(rule, ruleIndex, options) { var ruleKey = 'schedule-rule-' + ruleIndex + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); var normalizedRule = normalizeScheduleRuleValue(rule) || {}; var cardTemplate = getRuleCardTemplate(); var isCollapsed = Boolean(options && options.isCollapsed); var card; if (!cardTemplate) { return null; } card = cardTemplate.cloneNode(true); card.setAttribute('data-schedule-rule-card', ''); card.setAttribute('data-schedule-rule-key', ruleKey); card.querySelector('[data-schedule-rule-number]').textContent = String(ruleIndex); card.querySelector('[data-schedule-rule-summary]').textContent = formatScheduleRuleSummary(normalizedRule); card.querySelector('[data-schedule-rule-row-key]').value = String(getScheduleDraftKey() || ''); card.querySelector('[data-schedule-rule-position]').value = String(Math.max(0, ruleIndex - 1)); card.querySelector('[data-schedule-rule-start-datetime]').value = String(normalizedRule.start_datetime || ''); card.querySelector('[data-schedule-rule-end-datetime]').value = String(normalizedRule.end_datetime || ''); card.querySelector('[data-schedule-rule-start-time]').value = String(normalizedRule.start_time || ''); card.querySelector('[data-schedule-rule-end-time]').value = String(normalizedRule.end_time || ''); card.querySelector('[data-remove-schedule-rule]').setAttribute('data-confirm-message', 'Remove this rule?'); updateRuleCardDayOptions(card, ruleKey, normalizedRule); if (isCollapsed) { card.classList.add('collapsed-card'); } else { card.classList.remove('collapsed-card'); } try { updateLiveRuleValidity(card); } catch (_error) { clearRuleValidity(card); } syncRuleCollapseButtonState(card); return card; } function addRule(rule, options) { var ruleCount = getRuleCards().length + 1; var card = createRuleCardElement(rule || {}, ruleCount, options); if (!card) { scheduleRuleList.insertAdjacentHTML('beforeend', buildScheduleRuleCardMarkup(rule || {}, ruleCount, Boolean(options && options.isCollapsed))); card = scheduleRuleList.lastElementChild; } if (!card) { return null; } if (card.parentNode !== scheduleRuleList) { scheduleRuleList.appendChild(card); } refreshRuleNumbers(); syncScheduleRulesField(); return card; } function handleCardChange(card) { updateLiveRuleValidity(card); updateRuleCardSummary(card); syncScheduleRulesField(); } function handleRuleRemoval(card) { if (!card) { return; } card.remove(); refreshRuleNumbers(); syncScheduleRulesField(); } // Block saves when any rule is incomplete or out of order. function validateScheduleForm() { var isValid = true; getRuleCards().forEach(function (card) { if (!validateRuleCard(card)) { isValid = false; } }); if (isValid) { syncScheduleRulesField(); } return isValid && form.checkValidity(); } // Persist the modal form back to the server for non-draft rows. function saveScheduleForm() { return fetch(form.action, { method: 'POST', credentials: 'same-origin', body: new URLSearchParams(new FormData(form)) }).then(function (response) { if (!response.ok) { return response.text().then(function (text) { throw new Error(text || 'Unable to save schedule.'); }); } return response; }); } scheduleRuleList.addEventListener('input', function (event) { var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null; if (!card) { return; } handleCardChange(card); }); scheduleRuleList.addEventListener('change', function (event) { var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null; if (!card) { return; } handleCardChange(card); }); scheduleRuleList.addEventListener('click', function (event) { var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null; var collapseButton = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null; if (collapseButton) { window.setTimeout(function () { syncRuleCollapseButtonState(collapseButton.closest('[data-schedule-rule-card]')); persistScheduleDraft(); }, 0); return; } if (!removeButton) { return; } event.preventDefault(); var confirmMessage = removeButton.getAttribute('data-confirm-message'); if (confirmMessage && !window.confirm(confirmMessage)) { return; } handleRuleRemoval(removeButton.closest('[data-schedule-rule-card]')); }); if (addRuleButton) { addRuleButton.addEventListener('click', function () { addRule({}, { isCollapsed: false }); }); } if (cancelButton) { cancelButton.addEventListener('click', function () { if (typeof window.closeScheduleModal === 'function') { window.closeScheduleModal({ discardDraft: true }); } }); } var draftRules = getScheduleDraftRules(); if (draftRules !== null) { renderScheduleDraftState(draftRules); } else if (!getRuleCards().length) { syncScheduleRulesField(); } else { refreshRuleNumbers(); getRuleCards().forEach(function (card) { syncRuleCollapseButtonState(card); updateLiveRuleValidity(card); updateRuleCardSummary(card); }); syncScheduleRulesField(); } form.addEventListener('submit', function (event) { event.preventDefault(); if (!validateScheduleForm()) { form.reportValidity(); return; } var rules = syncScheduleRulesField(); var values = { row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''), scheduleRules: rules, summary: '' }; values.summary = formatScheduleRulesSummary(values.scheduleRules); if (isDraftMode) { clearScheduleDraft(); if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { window.parent.applyPlaylistScheduleConfig(values); } if (typeof window.closeScheduleModal === 'function') { window.closeScheduleModal({ discardDraft: true, preserveChanges: true }); } return; } saveScheduleForm().then(function () { clearScheduleDraft(); if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { window.parent.applyPlaylistScheduleConfig(values); if (typeof window.parent.closeScheduleModal === 'function') { window.parent.closeScheduleModal({ discardDraft: true, preserveChanges: true }); } return; } if (typeof window.closeScheduleModal === 'function') { window.closeScheduleModal({ discardDraft: true, preserveChanges: true }); } }).catch(function (error) { window.alert(String(error && error.message ? error.message : 'Unable to save schedule.')); }); }); } // Playlist editor behavior. // Playlist table editing, picker modal, and row sync logic. function initPlaylistEditStaging() { var tbody = document.getElementById('playlist-items-body'); var form = document.getElementById('playlist-edit-form'); var addSlideModal = document.getElementById('playlist-add-slide-modal'); var addSlideGrid = document.getElementById('playlist-slide-picker-grid'); var addSlideSearch = document.getElementById('playlist-slide-picker-search'); var addSlideShowAssigned = document.getElementById('playlist-slide-picker-show-assigned'); var addSlideEmpty = document.getElementById('playlist-slide-picker-empty'); var addSlideConfirm = document.getElementById('playlist-confirm-add-slides'); var addSlideCount = document.getElementById('playlist-slide-picker-selected-count'); var addSlideData = document.getElementById('playlist-add-slide-data'); var addSlideOpenButton = document.getElementById('playlist-open-slide-modal'); var canvasSizeSelect = document.getElementById('playlist-canvas-size'); var canvasSizeLockInput = document.getElementById('playlist-canvas-size-lock'); var slidePickerCards = []; var slidePickerSelection = new Set(); var slidePickerSlides = []; var lastDurationPointerDown = null; var lastDurationPointerUp = null; if (!tbody || !form) { return; } if (addSlideData) { try { var parsedSlides = JSON.parse(addSlideData.textContent || '[]'); slidePickerSlides = Array.isArray(parsedSlides) ? parsedSlides : []; } catch (_error) { slidePickerSlides = []; } } // Selection state helpers for the picker modal. function setCardSelected(card, selected) { if (!card) { return; } card.classList.toggle('is-selected', Boolean(selected)); card.setAttribute('aria-pressed', Boolean(selected) ? 'true' : 'false'); } function setCardVisible(card, visible) { if (!card) { return; } card.classList.toggle('is-hidden', !visible); } function getCanvasSizeEditable() { return Boolean(canvasSizeSelect && String(canvasSizeSelect.getAttribute('data-playlist-canvas-editable') || '') === 'true'); } function getLockedCanvasId() { var canvasId = Number(canvasSizeSelect && canvasSizeSelect.value ? canvasSizeSelect.value : 0) || null; if (canvasId) { return canvasId; } var canvasIds = {}; getRows().forEach(function (row) { var rowCanvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; if (rowCanvasId) { canvasIds[rowCanvasId] = true; } }); var canvasKeys = Object.keys(canvasIds); if (canvasKeys.length === 1) { return Number(canvasKeys[0]) || null; } return null; } function syncPlaylistCanvasLock() { if (!canvasSizeSelect) { return; } var shouldLock = !getCanvasSizeEditable() || getRows().length > 0; var lockedCanvasId = shouldLock ? getLockedCanvasId() : null; if (shouldLock && !canvasSizeSelect.value && lockedCanvasId) { canvasSizeSelect.value = String(lockedCanvasId); } canvasSizeSelect.disabled = shouldLock; if (canvasSizeLockInput) { canvasSizeLockInput.disabled = !shouldLock; canvasSizeLockInput.value = shouldLock ? String(canvasSizeSelect.value || lockedCanvasId || '') : ''; } if (tbody && tbody.setAttribute) { tbody.setAttribute('data-playlist-canvas-id', String(shouldLock ? (canvasSizeSelect.value || lockedCanvasId || '') : getPlaylistCanvasId() || '')); } } function syncAddSlideButtonState() { if (!addSlideOpenButton) { return; } addSlideOpenButton.disabled = !Boolean(getPlaylistCanvasId()); } function clearModalSelection() { slidePickerSelection.clear(); slidePickerCards.forEach(function (card) { setCardSelected(card, false); }); if (addSlideSearch) { addSlideSearch.value = ''; } syncSlidePickerState(); } function attachSlideThumbFallbacks(root) { if (typeof window.attachSlideThumbFallbacks === 'function') { window.attachSlideThumbFallbacks(root || tbody); } } // Picker card rendering. function createPickerCard(slide) { var button = document.createElement('button'); var searchText = String(slide && slide.title ? slide.title : '').toLowerCase(); button.type = 'button'; button.className = PICKER_CARD_CLASS; button.setAttribute('aria-pressed', 'false'); button.setAttribute('data-slide-id', String(slide.id || '')); button.setAttribute('data-canvas-id', String(slide && slide.canvasSizeId ? slide.canvasSizeId : '')); button.setAttribute('data-search-text', searchText); button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false'); if (slide && slide.isAssigned) { button.classList.add('is-assigned'); } var media = buildSlidePickerMedia(slide); var title = document.createElement('div'); title.className = PICKER_TITLE_CLASS; title.textContent = String(slide && slide.title ? slide.title : 'Slide'); button.appendChild(media); button.appendChild(title); return button; } // Picker filtering and modal controls. function setShowAssignedState(showAssignedSlides) { if (!addSlideShowAssigned) { return; } addSlideShowAssigned.setAttribute('aria-pressed', showAssignedSlides ? 'true' : 'false'); addSlideShowAssigned.classList.toggle('btn-secondary', showAssignedSlides); addSlideShowAssigned.classList.toggle('btn-outline-secondary', !showAssignedSlides); } function renderSlidePicker() { if (!addSlideGrid) { return; } addSlideGrid.innerHTML = ''; slidePickerCards = slidePickerSlides.map(function (slide) { var card = createPickerCard(slide); addSlideGrid.appendChild(card); return card; }); syncSlidePickerState(); } function getPlaylistCanvasId() { if (canvasSizeSelect && canvasSizeSelect.value) { return Number(canvasSizeSelect.value) || null; } if (canvasSizeSelect && (!canvasSizeSelect.disabled || !canvasSizeSelect.value)) { return canvasSizeLockInput && canvasSizeLockInput.value ? Number(canvasSizeLockInput.value) || null : null; } if (canvasSizeLockInput && canvasSizeLockInput.value) { return Number(canvasSizeLockInput.value) || null; } return Number(tbody && tbody.getAttribute ? tbody.getAttribute('data-playlist-canvas-id') : 0) || null; } function syncPlaylistCanvasId() { if (tbody && tbody.setAttribute) { tbody.setAttribute('data-playlist-canvas-id', String(getPlaylistCanvasId() || '')); } syncAddSlideOptions(); syncSlidePickerState(); syncPlaylistCanvasLock(); syncAddSlideButtonState(); } function syncSlidePickerState() { var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase(); var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true'); var visibleCount = 0; var assignedCount = 0; var allowedCanvasId = getPlaylistCanvasId(); var activeCanvasIds = {}; if (!allowedCanvasId) { getRows().forEach(function (row) { var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; if (canvasId) { activeCanvasIds[canvasId] = true; } }); if (!Object.keys(activeCanvasIds).length) { slidePickerCards.forEach(function (card) { if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) { var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; if (selectedCanvasId) { activeCanvasIds[selectedCanvasId] = true; } } }); } var canvasKeys = Object.keys(activeCanvasIds); if (canvasKeys.length === 1) { allowedCanvasId = Number(canvasKeys[0]) || null; } } slidePickerCards.forEach(function (card) { var searchText = String(card.getAttribute('data-search-text') || ''); var isAssigned = card.getAttribute('data-is-assigned') === 'true'; var canvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId); var matchesSearch = !query || searchText.indexOf(query) !== -1; var matchesAssignment = showAssignedSlides || !isAssigned; var matches = matchesSearch && matchesAssignment && !isCanvasMismatch; updateSlidePickerCardState(card, isAssigned, isCanvasMismatch); setCardVisible(card, matches); if (isAssigned) { assignedCount += 1; } if (matches) { visibleCount += 1; } }); if (addSlideEmpty) { addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount); addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0); } if (addSlideCount) { addSlideCount.textContent = String(slidePickerSelection.size); } if (addSlideConfirm) { addSlideConfirm.disabled = slidePickerSelection.size === 0; } setShowAssignedState(showAssignedSlides); } function toggleCardSelection(card) { var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : ''); var selected; if (!slideId || String(card && card.getAttribute('data-is-assigned') || '') === 'true') { return; } selected = !slidePickerSelection.has(slideId); if (selected) { slidePickerSelection.add(slideId); } else { slidePickerSelection.delete(slideId); } setCardSelected(card, selected); syncSlidePickerState(); } function addSelectedSlides() { var currentTbody = document.getElementById('playlist-items-body') || tbody; var selectedSlides = slidePickerSlides.filter(function (slide) { return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned; }); if (!selectedSlides.length) { return; } if (currentTbody && currentTbody.querySelector('.playlist-empty-row')) { currentTbody.querySelector('.playlist-empty-row').remove(); } selectedSlides.forEach(function (slide) { var row = createRow({ row_key: 'new-' + Date.now() + '-' + slide.id, slide_id: String(slide.id), thumbnail_path: String(slide.thumbnail_path || ''), canvas_width: slide.canvas_width, canvas_height: slide.canvas_height, canvas_id: slide.canvasSizeId, showVideoDurationButton: slide.showVideoDurationButton, showMuteButton: slide.showMuteButton, videoSourcePath: slide.videoSourcePath, videoDurationSeconds: slide.videoDurationSeconds, disableAudio: slide.disableAudio, title: slide.title || 'Slide', duration_seconds: 10, durationSeconds: 10, scheduleRules: [], summary: 'Always visible' }); if (currentTbody) { currentTbody.appendChild(row); } }); attachSlideThumbFallbacks(currentTbody || tbody); updateRowOrder(true); if (!window.pulseModal || !window.pulseModal.hide(addSlideModal)) { addSlideModal.classList.remove('show'); addSlideModal.setAttribute('aria-hidden', 'true'); } } attachSlideThumbFallbacks(tbody); function getRows() { var currentTbody = document.getElementById('playlist-items-body') || tbody; return currentTbody ? Array.prototype.slice.call(currentTbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')) : []; } function findRowByKey(rowKey) { var rows = getRows(); for (var i = 0; i < rows.length; i += 1) { if (rows[i].getAttribute('data-row-key') === rowKey) { return rows[i]; } } return null; } 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 (placeholder) { placeholder.remove(); } return; } if (!placeholder) { if (currentTbody) { currentTbody.innerHTML = 'No slides assigned yet.'; } } } function markPlaylistDirty() { if (form && form.dataset) { form.dataset.dirty = 'true'; } } // Rebuild hidden schedule inputs after the async save swaps the playlist tbody. function syncScheduleRowInputs(row) { var fields = getScheduleRowFields(row); var rowKey = String(row && row.getAttribute('data-row-key') || '').trim(); var serializedRules = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : ''; var rules = []; if (!fields.rulesContainer || !rowKey) { return; } if (serializedRules) { try { rules = JSON.parse(serializedRules); } catch (_error) { rules = []; } } else { rules = []; } if (!Array.isArray(rules)) { rules = []; } fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, rowKey, 'playlist-edit-form'); if (fields.summaryNode) { fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(formatScheduleRulesSummary(rules) || 'Always visible'); } } function syncScheduleDraftRows() { getRows().forEach(function (row) { syncScheduleRowInputs(row); }); } // Filter the slide picker against the current playlist rows and canvas lock. function syncAddSlideOptions() { var activeSlideIds = {}; var activeCanvasIds = {}; var allowedCanvasId = getPlaylistCanvasId(); getRows().forEach(function (row) { var slideId = String(row.getAttribute('data-slide-id') || ''); var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; if (slideId) { activeSlideIds[slideId] = true; } if (!allowedCanvasId && canvasId) { activeCanvasIds[canvasId] = true; } }); if (!allowedCanvasId) { slidePickerCards.forEach(function (card) { if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) { var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; if (selectedCanvasId) { activeCanvasIds[selectedCanvasId] = true; } } }); var canvasKeys = Object.keys(activeCanvasIds); if (canvasKeys.length === 1) { allowedCanvasId = Number(canvasKeys[0]) || null; } } slidePickerSlides.forEach(function (slide) { var slideId = String(slide.id || ''); var card = slidePickerCards.find(function (item) { return String(item.getAttribute('data-slide-id') || '') === slideId; }); var canvasId = Number(slide.canvasSizeId || 0) || null; var isAssigned = Boolean(activeSlideIds[slideId]); var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId); slide.isAssigned = isAssigned; updateSlidePickerCardState(card, isAssigned, isCanvasMismatch); if (isAssigned) { slidePickerSelection.delete(slideId); if (card) { setCardSelected(card, false); } } }); syncSlidePickerState(); } // Recompute row numbers and derived picker state after drag/drop or adds. function updateRowOrder(markDirty) { var rows = getRows(); rows.forEach(function (row, index) { 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; if (orderNumber) { orderNumber.textContent = String(index + 1); } if (moveUpButton) { moveUpButton.disabled = isFirst; moveUpButton.setAttribute('aria-disabled', isFirst ? 'true' : 'false'); } if (moveUpIcon) { moveUpIcon.classList.toggle('bi-caret-up-fill', !isFirst); moveUpIcon.classList.toggle('bi-caret-up', isFirst); } if (moveDownButton) { moveDownButton.disabled = isLast; moveDownButton.setAttribute('aria-disabled', isLast ? 'true' : 'false'); } if (moveDownIcon) { moveDownIcon.classList.toggle('bi-caret-down-fill', !isLast); moveDownIcon.classList.toggle('bi-caret-down', isLast); } }); syncAddSlideOptions(); syncPlaylistCanvasLock(); syncAddSlideButtonState(); updateEmptyState(); if (markDirty) { markPlaylistDirty(); } } form.addEventListener('submit', function () { syncScheduleDraftRows(); }, true); function bindPlaylistTableDrag(tbodyElement) { if (!tbodyElement || typeof window.initPlaylistTableDrag !== 'function') { return; } window.initPlaylistTableDrag({ tbody: tbodyElement, onOrderChanged: updateRowOrder }); } function movePlaylistRowByOffset(row, offset) { var currentTbody = document.getElementById('playlist-items-body') || tbody; var rows = getRows(); var currentIndex = rows.indexOf(row); var targetIndex; var referenceRow; if (!currentTbody || !row || currentIndex === -1) { return false; } targetIndex = currentIndex + Number(offset || 0); if (targetIndex < 0 || targetIndex >= rows.length) { return false; } referenceRow = rows[targetIndex]; if (targetIndex > currentIndex) { referenceRow = referenceRow ? referenceRow.nextSibling : null; } if (referenceRow) { currentTbody.insertBefore(row, referenceRow); } else { currentTbody.appendChild(row); } updateRowOrder(true); return true; } function populatePlaylistSlideRow(row, values, rowKey) { var canvasWidth = Number(values.canvas_width); var canvasHeight = Number(values.canvas_height); var thumbnailPath = String(values.thumbnail_path || ''); var scheduleRules = Array.isArray(values.scheduleRules) ? values.scheduleRules : []; var useVideoDuration = Boolean(values.useVideoDuration); var showVideoDurationButton = Boolean(values.showVideoDurationButton); var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio); var showMuteButton = Boolean(values.showMuteButton); var durationSource = values.durationSeconds !== undefined && values.durationSeconds !== null ? values.durationSeconds : values.duration_seconds; var durationSeconds = String(durationSource !== undefined && durationSource !== null ? durationSource : 10); var summary = String(values.summary || formatScheduleRulesSummary(scheduleRules) || 'Always visible'); var durationButton = row ? row.querySelector('[data-use-video-duration-button]') : null; var muteButton = row ? row.querySelector('[data-disable-audio-button]') : null; var durationInput = row ? row.querySelector('[data-playlist-duration-input]') : null; var useVideoDurationInput = row ? row.querySelector('[data-playlist-use-video-duration-input]') : null; var disableAudioInput = row ? row.querySelector('[data-playlist-disable-audio-input]') : null; var durationMirror = row ? row.querySelector('[data-playlist-duration-mirror]') : null; var thumb = row ? row.querySelector('[data-playlist-slide-thumb]') : null; 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 scheduleSummaryChip = row ? row.querySelector('[data-schedule-summary-chip]') : null; var rulesContainer = row ? row.querySelector('[data-schedule-rules-container]') : null; if (!row) { return; } row.setAttribute('data-row-key', rowKey); 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 || '')); row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || '')); row.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false'); row.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false'); if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0 && thumb) { thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight); } if (thumb) { thumb.innerHTML = thumbnailPath ? '' : ''; } if (titleNode) { titleNode.textContent = String(values.title || 'Slide'); } if (slideIdInput) { slideIdInput.value = String(values.slide_id || ''); } if (rowKeyInput) { rowKeyInput.value = String(rowKey || ''); } if (scheduleSummaryChip) { scheduleSummaryChip.textContent = summary; } if (rulesContainer) { rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(scheduleRules).replace(/ 0) { input.value = String(storedDuration); } input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } function setDisableAudioToggleState(row, button, isPressed) { var flag = row ? row.querySelector('[name="disable_audio[]"]') : null; syncDisableAudioButtonState(button, isPressed); if (flag) { flag.value = isPressed ? '1' : '0'; } if (row) { row.setAttribute('data-disable-audio-state', isPressed ? 'true' : 'false'); } } function applyDisableAudioToRow(row, button) { if (!row) { return; } setDisableAudioToggleState(row, button, !(button && button.getAttribute('aria-pressed') === 'true')); markPlaylistDirty(); } // Central click handler for row actions so drag, remove, schedule, and duration toggle stay coordinated. document.addEventListener('click', function (event) { if (event.target && event.target.matches('[name="duration_seconds[]"]')) { event.stopPropagation(); 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'); var muteButton = event.target.closest('.playlist-disable-audio'); var row = getPlaylistSlideRow(event.target); if (!row) { return; } if (moveButton) { event.preventDefault(); event.stopPropagation(); movePlaylistRowByOffset(row, moveButton.getAttribute('data-playlist-row-move') === 'up' ? -1 : 1); return; } if (removeButton) { event.preventDefault(); row.remove(); updateRowOrder(true); return; } if (durationButton) { var rowKey = getPlaylistSlideRowKey(row); var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey; var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey; if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) { lastDurationPointerDown = null; lastDurationPointerUp = null; event.preventDefault(); return; } event.preventDefault(); void applyVideoDurationToRow(row, durationButton); lastDurationPointerDown = null; lastDurationPointerUp = null; return; } if (muteButton) { event.preventDefault(); void applyDisableAudioToRow(row, muteButton); return; } var scheduleButton = event.target.closest('[data-schedule-config]'); if (scheduleButton) { event.preventDefault(); if (typeof window.openScheduleModal === 'function') { var scheduleParams = collectScheduleParams(row, scheduleButton.getAttribute('data-schedule-config-row') || row.getAttribute('data-row-key') || ''); window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?' + scheduleParams.toString()); } } lastDurationPointerDown = null; lastDurationPointerUp = null; }); // Track the pointer source so clicks on the duration toggle do not fight text selection. document.addEventListener('mousedown', function (event) { var row = getPlaylistSlideRow(event.target); var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null; var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null; var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null; if (row && !dragHandle) { event.stopPropagation(); } if (durationInput) { lastDurationPointerDown = getDurationPointerState(event.target, 'input'); event.stopPropagation(); return; } if (durationToggle) { lastDurationPointerDown = getDurationPointerState(durationToggle, 'button'); return; } if (muteToggle) { event.stopPropagation(); return; } lastDurationPointerDown = null; }); // Mirror the pointer-up side of the duration toggle click detection. document.addEventListener('mouseup', function (event) { var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null; var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null; var row = getPlaylistSlideRow(event.target); if (durationInput) { lastDurationPointerUp = getDurationPointerState(row, 'input'); return; } if (durationToggle) { lastDurationPointerUp = getDurationPointerState(row, 'button'); return; } if (muteToggle) { lastDurationPointerUp = null; return; } lastDurationPointerUp = null; }); // Keep the row dirty state and mirrored duration input in sync while the user edits. document.addEventListener('input', function (event) { if (event.target && event.target.matches('[name="duration_seconds[]"]')) { var editedRow = getPlaylistSlideRow(event.target); var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null; var editedMuteButton = editedRow ? editedRow.querySelector('.playlist-disable-audio') : null; var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null; var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true'); var disableAudio = Boolean(editedMuteButton && editedMuteButton.getAttribute('aria-pressed') === 'true'); if (editedRow) { editedRow.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false'); editedRow.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false'); } if (useVideoDuration && editedMirror) { editedMirror.value = event.target.value; } if (editedMirror && !useVideoDuration) { editedMirror.parentNode.removeChild(editedMirror); } markPlaylistDirty(); } }); bindPlaylistTableDrag(tbody); hydrateScheduleRowContainers(tbody); initPlaylistScheduleModal(); if (addSlideModal && addSlideGrid) { renderSlidePicker(); addSlideGrid.addEventListener('click', function (event) { var card = event.target.closest('.playlist-slide-picker-card'); if (!card) { return; } event.preventDefault(); toggleCardSelection(card); }); if (addSlideSearch) { addSlideSearch.addEventListener('input', syncSlidePickerState); } if (addSlideShowAssigned) { addSlideShowAssigned.addEventListener('click', function () { var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true'; setShowAssignedState(nextState); syncSlidePickerState(); }); } if (addSlideConfirm) { addSlideConfirm.addEventListener('click', function () { addSelectedSlides(); }); } if (canvasSizeSelect) { canvasSizeSelect.addEventListener('change', function () { syncPlaylistCanvasId(); }); } addSlideModal.addEventListener('shown.bs.modal', function () { syncSlidePickerState(); if (addSlideSearch) { addSlideSearch.focus(); } }); addSlideModal.addEventListener('hidden.bs.modal', function () { clearModalSelection(); }); } window.rebindPlaylistTableDrag = function (tbodyElement) { bindPlaylistTableDrag(tbodyElement); }; updateRowOrder(); syncPlaylistCanvasLock(); syncAddSlideButtonState(); } window.initPlaylistScheduleForm = initPlaylistScheduleForm; initPlaylistScheduleForm(); initPlaylistEditStaging(); }());