3852 lines
158 KiB
JavaScript
3852 lines
158 KiB
JavaScript
(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 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,
|
|
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 '' +
|
|
'<input type="hidden" name="schedule_rule_row_key[]" value="' + String(rowKey || '') + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_position[]" value="' + String(position || 0) + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_start_datetime[]" value="' + String(normalizedRule.start_datetime || '') + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_end_datetime[]" value="' + String(normalizedRule.end_datetime || '') + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_start_time[]" value="' + String(normalizedRule.start_time || '') + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_end_time[]" value="' + String(normalizedRule.end_time || '') + '"' + formAttr + ' />' +
|
|
'<input type="hidden" name="schedule_rule_days_csv[]" value="' + daysCsv + '"' + formAttr + ' />';
|
|
}
|
|
|
|
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 '' +
|
|
'<i class="bi ' + (isPressed ? 'bi-camera-video' : 'bi-camera-video-off') + '" aria-hidden="true" data-use-video-duration-icon></i>' +
|
|
'<span class="visually-hidden">' + getVideoDurationButtonLabel(isPressed) + '</span>';
|
|
}
|
|
|
|
function buildVideoDurationButtonMarkup(isPressed, enabled) {
|
|
if (!enabled) {
|
|
return '';
|
|
}
|
|
|
|
return '<button type="button" class="btn ' + (isPressed ? 'btn-info' : 'btn-outline-info') + ' btn-sm playlist-use-video-duration" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '" aria-label="' + getVideoDurationButtonLabel(isPressed) + '" title="' + getVideoDurationButtonLabel(isPressed) + '">' + buildVideoDurationButtonContent(isPressed) + '</button>';
|
|
}
|
|
|
|
function getDisableAudioButtonLabel(isPressed) {
|
|
return isPressed ? 'Enable audio' : 'Mute slide';
|
|
}
|
|
|
|
function buildDisableAudioButtonContent(isPressed) {
|
|
return '' +
|
|
'<i class="bi ' + (isPressed ? 'bi-volume-mute' : 'bi-volume-up') + '" aria-hidden="true" data-disable-audio-icon></i>' +
|
|
'<span class="visually-hidden">' + getDisableAudioButtonLabel(isPressed) + '</span>';
|
|
}
|
|
|
|
function buildDisableAudioButtonMarkup(isPressed, enabled) {
|
|
if (!enabled) {
|
|
return '';
|
|
}
|
|
|
|
return '<button type="button" class="btn ' + (isPressed ? 'btn-outline-secondary' : 'btn-secondary') + ' btn-sm playlist-disable-audio" data-disable-audio-button aria-pressed="' + (isPressed ? 'true' : 'false') + '" aria-label="' + getDisableAudioButtonLabel(isPressed) + '" title="' + getDisableAudioButtonLabel(isPressed) + '">' + buildDisableAudioButtonContent(isPressed) + '</button>';
|
|
}
|
|
|
|
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, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
function buildScheduleSummaryChipMarkup(summary) {
|
|
return '<span class="chip">' + escapeHtmlAttribute(String(summary || 'Always visible')) + '</span>';
|
|
}
|
|
|
|
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 '' +
|
|
'<td class="playlist-order-cell" data-label="Order">' +
|
|
'<div class="playlist-order-cell-inner">' +
|
|
'<div class="playlist-order-stepper" aria-label="Reorder slide controls">' +
|
|
'<button type="button" class="playlist-order-move playlist-order-move-up btn btn-link p-0 text-body-secondary" data-playlist-row-move="up" aria-label="Move slide up" title="Move slide up"><i class="bi bi-caret-up-fill" aria-hidden="true" data-playlist-row-move-icon="up"></i></button>' +
|
|
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><i class="bi bi-grip-horizontal"></i></span></button>' +
|
|
'<button type="button" class="playlist-order-move playlist-order-move-down btn btn-link p-0 text-body-secondary" data-playlist-row-move="down" aria-label="Move slide down" title="Move slide down"><i class="bi bi-caret-down-fill" aria-hidden="true" data-playlist-row-move-icon="down"></i></button>' +
|
|
'</div>' +
|
|
'<span class="playlist-order-number"></span>' +
|
|
'</div>' +
|
|
'</td>';
|
|
}
|
|
|
|
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 '' +
|
|
'<td data-label="Slide">' +
|
|
'<div class="playlist-slide-cell">' +
|
|
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
|
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
|
'</div>' +
|
|
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
|
|
'<input type="hidden" name="row_key[]" value="' + values.row_key + '" form="playlist-edit-form" />' +
|
|
'<input type="hidden" name="group_key[]" value="' + escapeHtmlAttribute(groupKey) + '" form="playlist-edit-form" data-playlist-group-key />' +
|
|
'<input type="hidden" name="group_name[]" value="' + groupName + '" form="playlist-edit-form" data-playlist-group-name-value />' +
|
|
'<input type="hidden" name="group_pick_count[]" value="' + String(groupPickCount) + '" form="playlist-edit-form" data-playlist-group-pick-count-value />' +
|
|
'<input type="hidden" name="group_shuffle[]" value="' + (values.groupShuffle ? '1' : '0') + '" form="playlist-edit-form" data-playlist-group-shuffle-value />' +
|
|
'<input type="hidden" name="group_mode[]" value="' + (values.groupMode === 'repeat' ? 'repeat' : 'select') + '" form="playlist-edit-form" data-playlist-group-mode-value />' +
|
|
'<input type="hidden" name="group_repeat_count[]" value="' + String(Math.min(100, Math.max(1, Number(values.groupRepeatCount) || 1))) + '" form="playlist-edit-form" data-playlist-group-repeat-count-value />' +
|
|
'<input type="hidden" name="group_schedule_rules_json[]" value="' + escapeHtmlAttribute(JSON.stringify(groupScheduleRules)) + '" form="playlist-edit-form" data-playlist-group-schedule-rules />' +
|
|
'</td>';
|
|
}
|
|
|
|
function buildPlaylistScheduleCellMarkup(values, rowKey) {
|
|
return '' +
|
|
'<td data-label="Rules">' +
|
|
'<div class="playlist-schedule-summary">' + buildScheduleSummaryChipMarkup(values.summary || 'Always visible') + '</div>' +
|
|
'<div class="d-none" data-schedule-rules-container data-schedule-rules="' + escapeHtmlAttribute(JSON.stringify(Array.isArray(values.scheduleRules) ? values.scheduleRules : []).replace(/</g, '\\u003c')) + '">' +
|
|
buildScheduleRulesMarkup(values.scheduleRules, rowKey, 'playlist-edit-form') +
|
|
'</div>' +
|
|
'</td>';
|
|
}
|
|
|
|
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
|
|
return '' +
|
|
'<td data-label="Playback"><div class="playlist-duration-field">' +
|
|
'<div class="input-group input-group-sm flex-nowrap playlist-duration-input-group">' +
|
|
'<span class="input-group-text playlist-duration-prefix text-body-secondary" aria-hidden="true"><i class="bi bi-clock"></i></span>' +
|
|
'<input name="duration_seconds[]" type="number" inputmode="decimal" min="1" step="any" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" data-limit-input-value' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
|
'</div>' +
|
|
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
|
'<input type="hidden" name="disable_audio[]" value="' + (values.disableAudio ? '1' : '0') + '" form="playlist-edit-form" data-playlist-disable-audio-input />' +
|
|
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
|
durationActionMarkup +
|
|
'</div></td>';
|
|
}
|
|
|
|
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
|
|
var scheduleUrl = '/playlists/new/slides/0/config';
|
|
var scheduleButtonMarkup = '<button type="button" class="btn btn-sm btn-primary" data-schedule-config="' + scheduleUrl + '" data-schedule-config-row="' + escapeHtmlAttribute(String(rowKey || '')) + '">Schedule</button>';
|
|
|
|
return '' +
|
|
'<td><div class="actions playlist-item-actions">' +
|
|
scheduleButtonMarkup +
|
|
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
|
'</div></td>';
|
|
}
|
|
|
|
function collectScheduleParams(row, rowKey, isGroupSchedule) {
|
|
var params = new URLSearchParams();
|
|
var fields = !isGroupSchedule && 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 || ''));
|
|
});
|
|
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;
|
|
}
|
|
|
|
function openScheduleModal(url) {
|
|
var dialog = document.getElementById('slide-schedule-dialog');
|
|
var content = document.getElementById('slide-schedule-content');
|
|
|
|
if (!dialog || !content) {
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = '<div class="card card-outline card-secondary mb-0"><div class="card-body py-4 text-center text-secondary">Loading schedule...</div></div>';
|
|
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 = '<div class="alert alert-danger m-3">' + escapeHtmlAttribute(String(error && error.message ? error.message : 'Unable to open schedule editor.')) + '</div>';
|
|
});
|
|
}
|
|
|
|
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(/</g, '\\u003c'));
|
|
fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, rowKey, 'playlist-edit-form');
|
|
}
|
|
if (typeof window.refreshPlaylistGroupScheduleWarnings === 'function') {
|
|
window.refreshPlaylistGroupScheduleWarnings();
|
|
}
|
|
}
|
|
|
|
window.applyPlaylistScheduleConfig = function (values) {
|
|
var rowKey = String(values && values.row_key || '');
|
|
if (rowKey.indexOf('group:') === 0) {
|
|
var groupKey = rowKey.slice(6);
|
|
var serializedRules = JSON.stringify(Array.isArray(values.scheduleRules) ? values.scheduleRules : []);
|
|
var groupHeader = document.querySelector('[data-playlist-group-row][data-group-key="' + groupKey + '"]');
|
|
var groupHeaderRules = groupHeader && groupHeader.querySelector('[data-playlist-group-schedule-rules]');
|
|
var groupHeaderSummary = groupHeader && groupHeader.querySelector('[data-group-schedule-summary]');
|
|
if (groupHeaderRules) groupHeaderRules.value = serializedRules;
|
|
if (groupHeaderSummary) groupHeaderSummary.textContent = formatScheduleRulesSummary(values.scheduleRules) || 'Always visible';
|
|
var groupRows = Array.prototype.slice.call(document.querySelectorAll('[data-playlist-slide-row]')).filter(function (item) {
|
|
var keyInput = item.querySelector('[data-playlist-group-key]');
|
|
return keyInput && keyInput.value === groupKey;
|
|
});
|
|
groupRows.forEach(function (groupRow) {
|
|
var rulesInput = groupRow.querySelector('[data-playlist-group-schedule-rules]');
|
|
if (rulesInput) {
|
|
rulesInput.value = serializedRules;
|
|
}
|
|
});
|
|
var playlistForm = document.getElementById('playlist-edit-form');
|
|
if (playlistForm && playlistForm.dataset) {
|
|
playlistForm.dataset.dirty = 'true';
|
|
}
|
|
if (typeof window.refreshPlaylistGroupScheduleWarnings === 'function') {
|
|
window.refreshPlaylistGroupScheduleWarnings();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var row = document.querySelector('[data-row-key="' + rowKey + '"]');
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
setRowSchedule(row, values);
|
|
};
|
|
|
|
// Schedule modal wiring.
|
|
function buildScheduleRuleCardMarkup(rule, index, isCollapsed) {
|
|
var normalizedRule = normalizeScheduleRuleValue(rule) || {};
|
|
var ruleIndex = index + 1;
|
|
var selectedDays = new Set(Array.isArray(normalizedRule.days) ? normalizedRule.days : []);
|
|
|
|
return '' +
|
|
'<div class="card card-widget card-outline card-secondary mb-3 schedule-rule-card' + (isCollapsed ? ' collapsed-card' : '') + '" data-schedule-rule-card>' +
|
|
'<div class="card-header py-2 d-flex align-items-center">' +
|
|
'<div class="flex-grow-1 min-w-0">' +
|
|
'<h4 class="h6 mb-0">Rule <span data-schedule-rule-number>' + String(ruleIndex) + '</span></h4>' +
|
|
'<div class="text-body-secondary small" data-schedule-rule-summary>' + escapeHtmlAttribute(formatScheduleRuleSummary(normalizedRule)) + '</div>' +
|
|
'</div>' +
|
|
'<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">' +
|
|
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>' +
|
|
'<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Collapse rule ' + String(ruleIndex) + '" title="Collapse rule ' + String(ruleIndex) + '">' +
|
|
'<i class="bi ' + (isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg') + '" aria-hidden="true" data-schedule-rule-collapse-icon></i>' +
|
|
'<span class="visually-hidden">Collapse rule ' + String(ruleIndex) + '</span>' +
|
|
'</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="card-body">' +
|
|
'<input type="hidden" name="schedule_rule_row_key[]" value="" data-schedule-rule-row-key />' +
|
|
'<input type="hidden" name="schedule_rule_position[]" value="' + String(index) + '" data-schedule-rule-position />' +
|
|
'<div class="row g-3">' +
|
|
'<div class="col-12 col-md-6">' +
|
|
'<label class="form-label">Start datetime</label>' +
|
|
'<input type="datetime-local" class="form-control" name="schedule_rule_start_datetime[]" value="' + escapeHtmlAttribute(normalizedRule.start_datetime || '') + '" data-schedule-rule-start-datetime />' +
|
|
'</div>' +
|
|
'<div class="col-12 col-md-6">' +
|
|
'<label class="form-label">End datetime</label>' +
|
|
'<input type="datetime-local" class="form-control" name="schedule_rule_end_datetime[]" value="' + escapeHtmlAttribute(normalizedRule.end_datetime || '') + '" data-schedule-rule-end-datetime />' +
|
|
'</div>' +
|
|
'<div class="col-12 col-md-6">' +
|
|
'<label class="form-label">Start time</label>' +
|
|
'<input type="time" class="form-control" name="schedule_rule_start_time[]" value="' + escapeHtmlAttribute(normalizedRule.start_time || '') + '" data-schedule-rule-start-time />' +
|
|
'</div>' +
|
|
'<div class="col-12 col-md-6">' +
|
|
'<label class="form-label">End time</label>' +
|
|
'<input type="time" class="form-control" name="schedule_rule_end_time[]" value="' + escapeHtmlAttribute(normalizedRule.end_time || '') + '" data-schedule-rule-end-time />' +
|
|
'</div>' +
|
|
'<div class="col-12">' +
|
|
'<div class="btn-group btn-group-sm w-100 schedule-day-grid" role="group" aria-label="Schedule days">' + DAY_NAMES.map(function (label, dayIndex) {
|
|
return '<span class="schedule-day-option">' +
|
|
'<input class="btn-check" type="checkbox" id="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '" value="' + String(dayIndex) + '" autocomplete="off"' + (selectedDays.has(dayIndex) ? ' checked' : '') + ' data-schedule-rule-day />' +
|
|
'<label class="btn btn-outline-primary flex-fill schedule-day-button" for="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '">' + label + '</label>' +
|
|
'</span>';
|
|
}).join('') + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="invalid-feedback d-block mt-2" data-schedule-rule-feedback aria-live="polite" hidden></div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// 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 createGroupButton = document.getElementById('playlist-create-group');
|
|
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;
|
|
var defaultSlideDuration = Number(tbody.getAttribute('data-default-slide-duration')) || 10;
|
|
|
|
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: defaultSlideDuration,
|
|
durationSeconds: defaultSlideDuration,
|
|
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 getGroupHeader(groupKey) {
|
|
return Array.prototype.slice.call((document.getElementById('playlist-items-body') || tbody).querySelectorAll('[data-playlist-group-row]')).find(function (header) {
|
|
return String(header.getAttribute('data-group-key') || '') === String(groupKey || '');
|
|
}) || null;
|
|
}
|
|
|
|
function getGroupRows(groupKey) {
|
|
return getRows().filter(function (row) {
|
|
var keyInput = row.querySelector('[data-playlist-group-key]');
|
|
return keyInput && String(keyInput.value || '') === String(groupKey || '');
|
|
});
|
|
}
|
|
|
|
function updateGroupScheduleWarnings() {
|
|
getRows().forEach(function (row) {
|
|
var groupKeyInput = row.querySelector('[data-playlist-group-key]');
|
|
var groupKey = String(groupKeyInput && groupKeyInput.value || '');
|
|
var scheduleContainer = row.querySelector('[data-schedule-rules-container]');
|
|
var scheduleSummary = row.querySelector('.playlist-schedule-summary');
|
|
var groupHeader = groupKey ? getGroupHeader(groupKey) : null;
|
|
var groupRulesInput = groupHeader && groupHeader.querySelector('[data-playlist-group-schedule-rules]');
|
|
var slideRules = parseScheduleRulesValue(scheduleContainer && scheduleContainer.getAttribute('data-schedule-rules') || '[]');
|
|
var groupRules = parseScheduleRulesValue(groupRulesInput && groupRulesInput.value || '[]');
|
|
var hasConflict = Boolean(groupKey && slideRules.length && groupRules.length
|
|
&& !scheduleRuleSetsCanOverlap(slideRules, groupRules));
|
|
var indicator = scheduleSummary && scheduleSummary.querySelector('[data-group-schedule-conflict]');
|
|
|
|
row.classList.toggle('playlist-schedule-conflict-row', hasConflict);
|
|
if (!scheduleSummary) {
|
|
return;
|
|
}
|
|
if (!hasConflict) {
|
|
if (indicator) indicator.remove();
|
|
return;
|
|
}
|
|
if (!indicator) {
|
|
indicator = document.createElement('span');
|
|
indicator.className = 'chip playlist-schedule-conflict-indicator';
|
|
indicator.setAttribute('data-group-schedule-conflict', '');
|
|
indicator.setAttribute('role', 'img');
|
|
scheduleSummary.appendChild(indicator);
|
|
}
|
|
indicator.setAttribute('aria-label', 'Never shown: slide and group schedules do not overlap');
|
|
indicator.title = 'This slide and its group have no overlapping schedule, so it will never display.';
|
|
indicator.innerHTML = '<i class="bi bi-exclamation-triangle-fill" aria-hidden="true"></i> Never shown';
|
|
});
|
|
}
|
|
|
|
window.refreshPlaylistGroupScheduleWarnings = updateGroupScheduleWarnings;
|
|
|
|
function getGroupHeaders() {
|
|
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
|
return currentTbody ? Array.prototype.slice.call(currentTbody.querySelectorAll('[data-playlist-group-row]')) : [];
|
|
}
|
|
|
|
function createGroupHeader(values) {
|
|
var group = values || {};
|
|
var groupKey = String(group.groupKey || '');
|
|
var groupName = String(group.groupName || 'New group');
|
|
var pickCount = Math.max(1, Number(group.groupPickCount) || 1);
|
|
var rules = Array.isArray(group.groupScheduleRules) ? group.groupScheduleRules : [];
|
|
var header = document.createElement('tr');
|
|
header.className = 'playlist-group-block-row';
|
|
header.setAttribute('data-playlist-group-row', '');
|
|
header.setAttribute('data-group-key', groupKey);
|
|
header.setAttribute('data-group-mode', group.groupMode === 'repeat' ? 'repeat' : 'select');
|
|
header.innerHTML = '' +
|
|
'<td class="playlist-order-cell" data-label="Order"><div class="playlist-order-cell-inner">' +
|
|
'<div class="playlist-order-stepper" aria-label="Reorder group controls">' +
|
|
'<button type="button" class="playlist-order-move playlist-order-move-up btn btn-link p-0 text-body-secondary" data-playlist-group-move="up" aria-label="Move group up" title="Move group up"><i class="bi bi-caret-up-fill" aria-hidden="true" data-playlist-group-move-icon="up"></i></button>' +
|
|
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-group-drag-handle aria-label="Drag group to reorder" title="Drag group to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><i class="bi bi-grip-horizontal"></i></span></button>' +
|
|
'<button type="button" class="playlist-order-move playlist-order-move-down btn btn-link p-0 text-body-secondary" data-playlist-group-move="down" aria-label="Move group down" title="Move group down"><i class="bi bi-caret-down-fill" aria-hidden="true" data-playlist-group-move-icon="down"></i></button>' +
|
|
'</div><span class="playlist-order-number" data-playlist-group-order></span>' +
|
|
'</div></td>' +
|
|
'<td data-label="Slide"><div class="playlist-group-block-slide">' +
|
|
'<i class="bi bi-collection-fill playlist-group-icon" aria-hidden="true"></i>' +
|
|
'<input class="form-control form-control-sm playlist-group-block-name" type="text" maxlength="128" aria-label="Group name" data-playlist-group-name />' +
|
|
'<div class="btn-group btn-group-sm playlist-group-mode" role="group" aria-label="Group playback mode"><button type="button" class="btn btn-secondary" data-playlist-group-mode="select" aria-pressed="true">Pick</button><button type="button" class="btn btn-outline-secondary" data-playlist-group-mode="repeat" aria-pressed="false">Repeat</button></div>' +
|
|
'</div></td>' +
|
|
'<td data-label="Rules"><div class="playlist-schedule-summary"><span class="chip" data-group-schedule-summary></span></div></td>' +
|
|
'<td data-label="Playback"><div class="playlist-duration-field" data-playlist-group-select-controls><div class="input-group input-group-sm playlist-group-block-quota-wrap"><span class="input-group-text">Pick</span><input class="form-control text-end playlist-group-block-quota" type="number" min="1" max="1" step="1" aria-label="Slides per pass" data-playlist-group-pick-count /></div><button type="button" class="btn btn-outline-secondary btn-sm" data-playlist-group-shuffle aria-pressed="false" aria-label="Enable shuffle" title="Enable shuffle"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></button></div><div class="input-group input-group-sm playlist-group-block-quota-wrap" data-playlist-group-repeat-controls hidden><span class="input-group-text">Repeat</span><input class="form-control text-end playlist-group-block-quota" type="number" min="1" max="100" step="1" aria-label="Group repetitions" data-playlist-group-repeat-count /></div></td>' +
|
|
'<td data-label="Actions"><div class="actions playlist-item-actions">' +
|
|
'<button type="button" class="btn btn-sm btn-primary" data-group-schedule-config="/playlists/new/slides/0/config">Schedule</button>' +
|
|
'<button type="button" class="btn btn-sm btn-danger" data-playlist-group-remove>Remove</button>' +
|
|
'</div><input type="hidden" data-playlist-group-schedule-rules /></td>';
|
|
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 = '<i class="bi bi-image" aria-hidden="true"></i>';
|
|
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 || getGroupHeaders().length) {
|
|
if (placeholder) {
|
|
placeholder.remove();
|
|
}
|
|
return;
|
|
}
|
|
if (!placeholder) {
|
|
if (currentTbody) {
|
|
currentTbody.innerHTML = '<tr class="playlist-empty-row"><td colspan="5" class="empty">No slides assigned yet.</td></tr>';
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 = '<td colspan="5" aria-label="Empty group drop area"></td>';
|
|
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();
|
|
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 = rowGroupKey ? siblingIndex === 0 : blockIndex === 0;
|
|
var isLast = rowGroupKey ? siblingIndex === siblingRows.length - 1 : blockIndex === blocks.length - 1;
|
|
|
|
if (orderNumber) {
|
|
orderNumber.textContent = String(blockIndex + 1) + (rowGroupKey ? '.' + String(siblingIndex + 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);
|
|
}
|
|
});
|
|
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();
|
|
if (markDirty) {
|
|
markPlaylistDirty();
|
|
}
|
|
}
|
|
|
|
form.addEventListener('submit', function () {
|
|
getGroupHeaders().forEach(syncGroupRowMetadata);
|
|
syncScheduleDraftRows();
|
|
}, true);
|
|
|
|
function bindPlaylistTableDrag(tbodyElement) {
|
|
if (!tbodyElement || typeof window.initPlaylistTableDrag !== 'function') {
|
|
return;
|
|
}
|
|
|
|
window.initPlaylistTableDrag({
|
|
tbody: tbodyElement,
|
|
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 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;
|
|
|
|
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 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;
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
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 || ''));
|
|
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
|
|
? '<img class="playlist-slide-thumb-image" src="' + escapeHtmlAttribute(thumbnailPath) + '" alt="" loading="lazy" data-playlist-slide-thumb-image />'
|
|
: '<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>';
|
|
}
|
|
if (titleNode) {
|
|
titleNode.textContent = String(values.title || 'Slide');
|
|
}
|
|
if (slideIdInput) {
|
|
slideIdInput.value = String(values.slide_id || '');
|
|
}
|
|
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;
|
|
}
|
|
if (rulesContainer) {
|
|
rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(scheduleRules).replace(/</g, '\u003c'));
|
|
rulesContainer.innerHTML = buildScheduleRulesMarkup(scheduleRules, rowKey, 'playlist-edit-form');
|
|
}
|
|
if (durationInput) {
|
|
durationInput.value = durationSeconds;
|
|
durationInput.disabled = useVideoDuration;
|
|
}
|
|
if (useVideoDurationInput) {
|
|
useVideoDurationInput.value = useVideoDuration ? '1' : '0';
|
|
}
|
|
if (disableAudioInput) {
|
|
disableAudioInput.value = disableAudio ? '1' : '0';
|
|
}
|
|
if (durationMirror) {
|
|
durationMirror.value = durationSeconds;
|
|
}
|
|
if (durationButton) {
|
|
durationButton.hidden = !showVideoDurationButton;
|
|
durationButton.innerHTML = buildVideoDurationButtonContent(useVideoDuration);
|
|
durationButton.setAttribute('aria-pressed', useVideoDuration ? 'true' : 'false');
|
|
durationButton.classList.toggle('active', useVideoDuration);
|
|
durationButton.setAttribute('aria-label', getVideoDurationButtonLabel(useVideoDuration));
|
|
durationButton.setAttribute('title', getVideoDurationButtonLabel(useVideoDuration));
|
|
}
|
|
if (muteButton) {
|
|
muteButton.hidden = !showMuteButton;
|
|
syncDisableAudioButtonState(muteButton, disableAudio);
|
|
}
|
|
}
|
|
|
|
function createRow(values) {
|
|
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
|
var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio);
|
|
var rowTemplate = document.getElementById('playlist-slide-row-template');
|
|
var fragment;
|
|
var row;
|
|
|
|
if (rowTemplate && rowTemplate.content) {
|
|
fragment = rowTemplate.content.cloneNode(true);
|
|
row = fragment.querySelector('[data-playlist-slide-row]');
|
|
if (row) {
|
|
populatePlaylistSlideRow(row, values, rowKey);
|
|
setRowSchedule(row, Object.assign({}, values, { row_key: rowKey }));
|
|
return row;
|
|
}
|
|
}
|
|
|
|
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 || ''));
|
|
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
|
row.setAttribute('data-use-video-duration-state', String(Boolean(values.useVideoDuration)));
|
|
row.setAttribute('data-disable-audio-state', String(disableAudio));
|
|
row.innerHTML = '' +
|
|
buildPlaylistOrderCellMarkup() +
|
|
buildPlaylistSlideCellMarkup(values, '', '') +
|
|
buildPlaylistScheduleCellMarkup(values, rowKey) +
|
|
buildPlaylistDurationCellMarkup(Object.assign({}, values, { disableAudio: disableAudio }), buildVideoDurationButtonMarkup(Boolean(values.useVideoDuration), Boolean(values.showVideoDurationButton)) + buildDisableAudioButtonMarkup(disableAudio, Boolean(values.showMuteButton))) +
|
|
buildPlaylistActionsCellMarkup(rowKey);
|
|
setRowSchedule(row, Object.assign({}, values, { row_key: rowKey }));
|
|
return row;
|
|
}
|
|
|
|
// Lock the editable duration input while preserving the prior value for a later restore.
|
|
function setVideoDurationToggleState(row, button, isPressed) {
|
|
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
|
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
|
|
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
|
var videoDuration = String(row && row.getAttribute('data-video-duration-seconds') || '').trim();
|
|
|
|
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
|
|
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
|
|
}
|
|
|
|
syncVideoDurationButtonState(button, isPressed);
|
|
|
|
if (input) {
|
|
if (!isPressed && row) {
|
|
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
|
|
if (previousValue) {
|
|
input.value = previousValue;
|
|
}
|
|
row.removeAttribute('data-video-duration-previous-value');
|
|
} else if (isPressed && videoDuration) {
|
|
input.value = videoDuration;
|
|
}
|
|
input.disabled = Boolean(isPressed);
|
|
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
|
|
}
|
|
|
|
if (flag) {
|
|
flag.value = isPressed ? '1' : '0';
|
|
}
|
|
|
|
if (row) {
|
|
row.setAttribute('data-use-video-duration-state', isPressed ? 'true' : 'false');
|
|
}
|
|
|
|
if (!row || !input) {
|
|
return;
|
|
}
|
|
|
|
if (isPressed) {
|
|
if (!mirror) {
|
|
mirror = document.createElement('input');
|
|
mirror.type = 'hidden';
|
|
mirror.setAttribute('data-video-duration-mirror', '');
|
|
input.parentNode.insertBefore(mirror, input.nextSibling);
|
|
}
|
|
mirror.name = input.name;
|
|
mirror.value = input.value;
|
|
if (input.getAttribute('form')) {
|
|
mirror.setAttribute('form', input.getAttribute('form'));
|
|
}
|
|
} else if (mirror && mirror.parentNode) {
|
|
mirror.parentNode.removeChild(mirror);
|
|
}
|
|
}
|
|
|
|
function applyVideoDurationToRow(row, button) {
|
|
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
|
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
|
|
|
|
if (!input) {
|
|
return;
|
|
}
|
|
|
|
if (button && button.getAttribute('aria-pressed') === 'true') {
|
|
setVideoDurationToggleState(row, button, false);
|
|
return;
|
|
}
|
|
|
|
setVideoDurationToggleState(row, button, true);
|
|
|
|
if (Number.isFinite(storedDuration) && storedDuration > 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;
|
|
}
|
|
|
|
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');
|
|
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();
|
|
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();
|
|
|
|
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) {
|
|
initializePlaylistGroups(tbodyElement);
|
|
bindGroupBlockDrag(tbodyElement);
|
|
bindPlaylistTableDrag(tbodyElement);
|
|
hydrateScheduleRowContainers(tbodyElement);
|
|
updateRowOrder();
|
|
};
|
|
|
|
updateRowOrder();
|
|
syncPlaylistCanvasLock();
|
|
syncAddSlideButtonState();
|
|
}
|
|
|
|
window.initPlaylistScheduleForm = initPlaylistScheduleForm;
|
|
|
|
initPlaylistScheduleForm();
|
|
initPlaylistEditStaging();
|
|
}());
|
|
|