2654 lines
98 KiB
JavaScript
2654 lines
98 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 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) {
|
|
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" />' +
|
|
'</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="text" inputmode="decimal" min="1" 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) {
|
|
var params = new URLSearchParams();
|
|
var fields = row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : [];
|
|
|
|
params.set('row_key', String(rowKey || ''));
|
|
fields.forEach(function (field) {
|
|
if (!field || !field.name) {
|
|
return;
|
|
}
|
|
params.append(field.name, String(field.value || ''));
|
|
});
|
|
return params;
|
|
}
|
|
|
|
function openScheduleModal(url) {
|
|
var dialog = document.getElementById('slide-schedule-dialog');
|
|
var content = document.getElementById('slide-schedule-content');
|
|
|
|
if (!dialog || !content) {
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = '<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');
|
|
}
|
|
}
|
|
|
|
window.applyPlaylistScheduleConfig = function (values) {
|
|
var row = document.querySelector('[data-row-key="' + String(values && values.row_key ? values.row_key : '') + '"]');
|
|
|
|
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 canvasSizeSelect = document.getElementById('playlist-canvas-size');
|
|
var canvasSizeLockInput = document.getElementById('playlist-canvas-size-lock');
|
|
var slidePickerCards = [];
|
|
var slidePickerSelection = new Set();
|
|
var slidePickerSlides = [];
|
|
var lastDurationPointerDown = null;
|
|
var lastDurationPointerUp = null;
|
|
|
|
if (!tbody || !form) {
|
|
return;
|
|
}
|
|
|
|
if (addSlideData) {
|
|
try {
|
|
var parsedSlides = JSON.parse(addSlideData.textContent || '[]');
|
|
slidePickerSlides = Array.isArray(parsedSlides) ? parsedSlides : [];
|
|
} catch (_error) {
|
|
slidePickerSlides = [];
|
|
}
|
|
}
|
|
|
|
// Selection state helpers for the picker modal.
|
|
function setCardSelected(card, selected) {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
card.classList.toggle('is-selected', Boolean(selected));
|
|
card.setAttribute('aria-pressed', Boolean(selected) ? 'true' : 'false');
|
|
}
|
|
|
|
function setCardVisible(card, visible) {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
card.classList.toggle('is-hidden', !visible);
|
|
}
|
|
|
|
function getCanvasSizeEditable() {
|
|
return Boolean(canvasSizeSelect && String(canvasSizeSelect.getAttribute('data-playlist-canvas-editable') || '') === 'true');
|
|
}
|
|
|
|
function getLockedCanvasId() {
|
|
var canvasId = Number(canvasSizeSelect && canvasSizeSelect.value ? canvasSizeSelect.value : 0) || null;
|
|
|
|
if (canvasId) {
|
|
return canvasId;
|
|
}
|
|
|
|
var canvasIds = {};
|
|
getRows().forEach(function (row) {
|
|
var rowCanvasId = Number(row.getAttribute('data-canvas-id') || 0) || null;
|
|
if (rowCanvasId) {
|
|
canvasIds[rowCanvasId] = true;
|
|
}
|
|
});
|
|
|
|
var canvasKeys = Object.keys(canvasIds);
|
|
if (canvasKeys.length === 1) {
|
|
return Number(canvasKeys[0]) || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function syncPlaylistCanvasLock() {
|
|
if (!canvasSizeSelect) {
|
|
return;
|
|
}
|
|
|
|
var shouldLock = !getCanvasSizeEditable() || getRows().length > 0;
|
|
var lockedCanvasId = shouldLock ? getLockedCanvasId() : null;
|
|
|
|
if (shouldLock && !canvasSizeSelect.value && lockedCanvasId) {
|
|
canvasSizeSelect.value = String(lockedCanvasId);
|
|
}
|
|
|
|
canvasSizeSelect.disabled = shouldLock;
|
|
|
|
if (canvasSizeLockInput) {
|
|
canvasSizeLockInput.disabled = !shouldLock;
|
|
canvasSizeLockInput.value = shouldLock ? String(canvasSizeSelect.value || lockedCanvasId || '') : '';
|
|
}
|
|
|
|
if (tbody && tbody.setAttribute) {
|
|
tbody.setAttribute('data-playlist-canvas-id', String(shouldLock ? (canvasSizeSelect.value || lockedCanvasId || '') : getPlaylistCanvasId() || ''));
|
|
}
|
|
}
|
|
|
|
function syncAddSlideButtonState() {
|
|
if (!addSlideOpenButton) {
|
|
return;
|
|
}
|
|
|
|
addSlideOpenButton.disabled = !Boolean(getPlaylistCanvasId());
|
|
}
|
|
|
|
function clearModalSelection() {
|
|
slidePickerSelection.clear();
|
|
slidePickerCards.forEach(function (card) {
|
|
setCardSelected(card, false);
|
|
});
|
|
if (addSlideSearch) {
|
|
addSlideSearch.value = '';
|
|
}
|
|
syncSlidePickerState();
|
|
}
|
|
|
|
function attachSlideThumbFallbacks(root) {
|
|
if (typeof window.attachSlideThumbFallbacks === 'function') {
|
|
window.attachSlideThumbFallbacks(root || tbody);
|
|
}
|
|
}
|
|
|
|
// Picker card rendering.
|
|
function createPickerCard(slide) {
|
|
var button = document.createElement('button');
|
|
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
|
|
|
button.type = 'button';
|
|
button.className = PICKER_CARD_CLASS;
|
|
button.setAttribute('aria-pressed', 'false');
|
|
button.setAttribute('data-slide-id', String(slide.id || ''));
|
|
button.setAttribute('data-canvas-id', String(slide && slide.canvasSizeId ? slide.canvasSizeId : ''));
|
|
button.setAttribute('data-search-text', searchText);
|
|
button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false');
|
|
|
|
if (slide && slide.isAssigned) {
|
|
button.classList.add('is-assigned');
|
|
}
|
|
|
|
var media = buildSlidePickerMedia(slide);
|
|
var title = document.createElement('div');
|
|
title.className = PICKER_TITLE_CLASS;
|
|
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
|
|
|
button.appendChild(media);
|
|
button.appendChild(title);
|
|
|
|
return button;
|
|
}
|
|
|
|
// Picker filtering and modal controls.
|
|
function setShowAssignedState(showAssignedSlides) {
|
|
if (!addSlideShowAssigned) {
|
|
return;
|
|
}
|
|
|
|
addSlideShowAssigned.setAttribute('aria-pressed', showAssignedSlides ? 'true' : 'false');
|
|
addSlideShowAssigned.classList.toggle('btn-secondary', showAssignedSlides);
|
|
addSlideShowAssigned.classList.toggle('btn-outline-secondary', !showAssignedSlides);
|
|
}
|
|
|
|
function renderSlidePicker() {
|
|
if (!addSlideGrid) {
|
|
return;
|
|
}
|
|
addSlideGrid.innerHTML = '';
|
|
slidePickerCards = slidePickerSlides.map(function (slide) {
|
|
var card = createPickerCard(slide);
|
|
addSlideGrid.appendChild(card);
|
|
return card;
|
|
});
|
|
syncSlidePickerState();
|
|
}
|
|
|
|
function getPlaylistCanvasId() {
|
|
if (canvasSizeSelect && canvasSizeSelect.value) {
|
|
return Number(canvasSizeSelect.value) || null;
|
|
}
|
|
|
|
if (canvasSizeSelect && (!canvasSizeSelect.disabled || !canvasSizeSelect.value)) {
|
|
return canvasSizeLockInput && canvasSizeLockInput.value ? Number(canvasSizeLockInput.value) || null : null;
|
|
}
|
|
|
|
if (canvasSizeLockInput && canvasSizeLockInput.value) {
|
|
return Number(canvasSizeLockInput.value) || null;
|
|
}
|
|
|
|
return Number(tbody && tbody.getAttribute ? tbody.getAttribute('data-playlist-canvas-id') : 0) || null;
|
|
}
|
|
|
|
function syncPlaylistCanvasId() {
|
|
if (tbody && tbody.setAttribute) {
|
|
tbody.setAttribute('data-playlist-canvas-id', String(getPlaylistCanvasId() || ''));
|
|
}
|
|
syncAddSlideOptions();
|
|
syncSlidePickerState();
|
|
syncPlaylistCanvasLock();
|
|
syncAddSlideButtonState();
|
|
}
|
|
|
|
function syncSlidePickerState() {
|
|
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
|
var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true');
|
|
var visibleCount = 0;
|
|
var assignedCount = 0;
|
|
var allowedCanvasId = getPlaylistCanvasId();
|
|
var activeCanvasIds = {};
|
|
|
|
if (!allowedCanvasId) {
|
|
getRows().forEach(function (row) {
|
|
var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null;
|
|
if (canvasId) {
|
|
activeCanvasIds[canvasId] = true;
|
|
}
|
|
});
|
|
|
|
if (!Object.keys(activeCanvasIds).length) {
|
|
slidePickerCards.forEach(function (card) {
|
|
if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) {
|
|
var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null;
|
|
if (selectedCanvasId) {
|
|
activeCanvasIds[selectedCanvasId] = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
var canvasKeys = Object.keys(activeCanvasIds);
|
|
if (canvasKeys.length === 1) {
|
|
allowedCanvasId = Number(canvasKeys[0]) || null;
|
|
}
|
|
}
|
|
|
|
slidePickerCards.forEach(function (card) {
|
|
var searchText = String(card.getAttribute('data-search-text') || '');
|
|
var isAssigned = card.getAttribute('data-is-assigned') === 'true';
|
|
var canvasId = Number(card.getAttribute('data-canvas-id') || 0) || null;
|
|
var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId);
|
|
var matchesSearch = !query || searchText.indexOf(query) !== -1;
|
|
var matchesAssignment = showAssignedSlides || !isAssigned;
|
|
var matches = matchesSearch && matchesAssignment && !isCanvasMismatch;
|
|
updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
|
|
setCardVisible(card, matches);
|
|
if (isAssigned) {
|
|
assignedCount += 1;
|
|
}
|
|
if (matches) {
|
|
visibleCount += 1;
|
|
}
|
|
});
|
|
|
|
if (addSlideEmpty) {
|
|
addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
|
|
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
|
}
|
|
|
|
if (addSlideCount) {
|
|
addSlideCount.textContent = String(slidePickerSelection.size);
|
|
}
|
|
|
|
if (addSlideConfirm) {
|
|
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
|
}
|
|
|
|
setShowAssignedState(showAssignedSlides);
|
|
}
|
|
|
|
function toggleCardSelection(card) {
|
|
var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : '');
|
|
var selected;
|
|
|
|
if (!slideId || String(card && card.getAttribute('data-is-assigned') || '') === 'true') {
|
|
return;
|
|
}
|
|
|
|
selected = !slidePickerSelection.has(slideId);
|
|
if (selected) {
|
|
slidePickerSelection.add(slideId);
|
|
} else {
|
|
slidePickerSelection.delete(slideId);
|
|
}
|
|
setCardSelected(card, selected);
|
|
syncSlidePickerState();
|
|
}
|
|
|
|
function addSelectedSlides() {
|
|
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
|
var selectedSlides = slidePickerSlides.filter(function (slide) {
|
|
return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned;
|
|
});
|
|
|
|
if (!selectedSlides.length) {
|
|
return;
|
|
}
|
|
|
|
if (currentTbody && currentTbody.querySelector('.playlist-empty-row')) {
|
|
currentTbody.querySelector('.playlist-empty-row').remove();
|
|
}
|
|
|
|
selectedSlides.forEach(function (slide) {
|
|
var row = createRow({
|
|
row_key: 'new-' + Date.now() + '-' + slide.id,
|
|
slide_id: String(slide.id),
|
|
thumbnail_path: String(slide.thumbnail_path || ''),
|
|
canvas_width: slide.canvas_width,
|
|
canvas_height: slide.canvas_height,
|
|
canvas_id: slide.canvasSizeId,
|
|
showVideoDurationButton: slide.showVideoDurationButton,
|
|
showMuteButton: slide.showMuteButton,
|
|
videoSourcePath: slide.videoSourcePath,
|
|
videoDurationSeconds: slide.videoDurationSeconds,
|
|
disableAudio: slide.disableAudio,
|
|
title: slide.title || 'Slide',
|
|
duration_seconds: 10,
|
|
durationSeconds: 10,
|
|
scheduleRules: [],
|
|
summary: 'Always visible'
|
|
});
|
|
if (currentTbody) {
|
|
currentTbody.appendChild(row);
|
|
}
|
|
});
|
|
|
|
attachSlideThumbFallbacks(currentTbody || tbody);
|
|
|
|
updateRowOrder(true);
|
|
|
|
if (!window.pulseModal || !window.pulseModal.hide(addSlideModal)) {
|
|
addSlideModal.classList.remove('show');
|
|
addSlideModal.setAttribute('aria-hidden', 'true');
|
|
}
|
|
}
|
|
|
|
attachSlideThumbFallbacks(tbody);
|
|
|
|
function getRows() {
|
|
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
|
return currentTbody ? Array.prototype.slice.call(currentTbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')) : [];
|
|
}
|
|
|
|
function findRowByKey(rowKey) {
|
|
var rows = getRows();
|
|
for (var i = 0; i < rows.length; i += 1) {
|
|
if (rows[i].getAttribute('data-row-key') === rowKey) {
|
|
return rows[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateEmptyState() {
|
|
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
|
var rows = getRows();
|
|
var placeholder = currentTbody ? currentTbody.querySelector('.playlist-empty-row') : null;
|
|
if (rows.length) {
|
|
if (placeholder) {
|
|
placeholder.remove();
|
|
}
|
|
return;
|
|
}
|
|
if (!placeholder) {
|
|
if (currentTbody) {
|
|
currentTbody.innerHTML = '<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();
|
|
}
|
|
|
|
// Recompute row numbers and derived picker state after drag/drop or adds.
|
|
function updateRowOrder(markDirty) {
|
|
var rows = getRows();
|
|
rows.forEach(function (row, index) {
|
|
var orderNumber = row.querySelector('.playlist-order-number');
|
|
var moveUpButton = row.querySelector('[data-playlist-row-move="up"]');
|
|
var moveDownButton = row.querySelector('[data-playlist-row-move="down"]');
|
|
var moveUpIcon = row.querySelector('[data-playlist-row-move-icon="up"]');
|
|
var moveDownIcon = row.querySelector('[data-playlist-row-move-icon="down"]');
|
|
var isFirst = index === 0;
|
|
var isLast = index === rows.length - 1;
|
|
|
|
if (orderNumber) {
|
|
orderNumber.textContent = String(index + 1);
|
|
}
|
|
|
|
if (moveUpButton) {
|
|
moveUpButton.disabled = isFirst;
|
|
moveUpButton.setAttribute('aria-disabled', isFirst ? 'true' : 'false');
|
|
}
|
|
|
|
if (moveUpIcon) {
|
|
moveUpIcon.classList.toggle('bi-caret-up-fill', !isFirst);
|
|
moveUpIcon.classList.toggle('bi-caret-up', isFirst);
|
|
}
|
|
|
|
if (moveDownButton) {
|
|
moveDownButton.disabled = isLast;
|
|
moveDownButton.setAttribute('aria-disabled', isLast ? 'true' : 'false');
|
|
}
|
|
|
|
if (moveDownIcon) {
|
|
moveDownIcon.classList.toggle('bi-caret-down-fill', !isLast);
|
|
moveDownIcon.classList.toggle('bi-caret-down', isLast);
|
|
}
|
|
});
|
|
syncAddSlideOptions();
|
|
syncPlaylistCanvasLock();
|
|
syncAddSlideButtonState();
|
|
updateEmptyState();
|
|
if (markDirty) {
|
|
markPlaylistDirty();
|
|
}
|
|
}
|
|
|
|
form.addEventListener('submit', function () {
|
|
syncScheduleDraftRows();
|
|
}, true);
|
|
|
|
function bindPlaylistTableDrag(tbodyElement) {
|
|
if (!tbodyElement || typeof window.initPlaylistTableDrag !== 'function') {
|
|
return;
|
|
}
|
|
|
|
window.initPlaylistTableDrag({
|
|
tbody: tbodyElement,
|
|
onOrderChanged: updateRowOrder
|
|
});
|
|
}
|
|
|
|
function movePlaylistRowByOffset(row, offset) {
|
|
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
|
var rows = getRows();
|
|
var currentIndex = rows.indexOf(row);
|
|
var targetIndex;
|
|
var referenceRow;
|
|
|
|
if (!currentTbody || !row || currentIndex === -1) {
|
|
return false;
|
|
}
|
|
|
|
targetIndex = currentIndex + Number(offset || 0);
|
|
if (targetIndex < 0 || targetIndex >= rows.length) {
|
|
return false;
|
|
}
|
|
|
|
referenceRow = rows[targetIndex];
|
|
|
|
if (targetIndex > currentIndex) {
|
|
referenceRow = referenceRow ? referenceRow.nextSibling : null;
|
|
}
|
|
|
|
if (referenceRow) {
|
|
currentTbody.insertBefore(row, referenceRow);
|
|
} else {
|
|
currentTbody.appendChild(row);
|
|
}
|
|
|
|
updateRowOrder(true);
|
|
return true;
|
|
}
|
|
|
|
function populatePlaylistSlideRow(row, values, rowKey) {
|
|
var canvasWidth = Number(values.canvas_width);
|
|
var canvasHeight = Number(values.canvas_height);
|
|
var thumbnailPath = String(values.thumbnail_path || '');
|
|
var scheduleRules = Array.isArray(values.scheduleRules) ? values.scheduleRules : [];
|
|
var useVideoDuration = Boolean(values.useVideoDuration);
|
|
var showVideoDurationButton = Boolean(values.showVideoDurationButton);
|
|
var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio);
|
|
var showMuteButton = Boolean(values.showMuteButton);
|
|
var durationSource = values.durationSeconds !== undefined && values.durationSeconds !== null ? values.durationSeconds : values.duration_seconds;
|
|
var durationSeconds = String(durationSource !== undefined && durationSource !== null ? durationSource : 10);
|
|
var summary = String(values.summary || formatScheduleRulesSummary(scheduleRules) || 'Always visible');
|
|
var durationButton = row ? row.querySelector('[data-use-video-duration-button]') : null;
|
|
var muteButton = row ? row.querySelector('[data-disable-audio-button]') : null;
|
|
var durationInput = row ? row.querySelector('[data-playlist-duration-input]') : null;
|
|
var useVideoDurationInput = row ? row.querySelector('[data-playlist-use-video-duration-input]') : null;
|
|
var disableAudioInput = row ? row.querySelector('[data-playlist-disable-audio-input]') : null;
|
|
var durationMirror = row ? row.querySelector('[data-playlist-duration-mirror]') : null;
|
|
var thumb = row ? row.querySelector('[data-playlist-slide-thumb]') : null;
|
|
var titleNode = row ? row.querySelector('[data-playlist-slide-title]') : null;
|
|
var slideIdInput = row ? row.querySelector('[data-playlist-slide-id-input]') : null;
|
|
var rowKeyInput = row ? row.querySelector('[data-playlist-row-key-input]') : null;
|
|
var scheduleSummaryChip = row ? row.querySelector('[data-schedule-summary-chip]') : null;
|
|
var rulesContainer = row ? row.querySelector('[data-schedule-rules-container]') : null;
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
row.setAttribute('data-row-key', rowKey);
|
|
row.setAttribute('data-slide-id', String(values.slide_id || ''));
|
|
row.setAttribute('data-canvas-id', String(values.canvas_id || ''));
|
|
row.setAttribute('data-video-source-path', String(values.videoSourcePath || ''));
|
|
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
|
row.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false');
|
|
row.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false');
|
|
|
|
if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0 && thumb) {
|
|
thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight);
|
|
}
|
|
if (thumb) {
|
|
thumb.innerHTML = thumbnailPath
|
|
? '<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 (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-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;
|
|
}
|
|
|
|
var moveButton = event.target.closest('[data-playlist-row-move]');
|
|
var removeButton = event.target.closest('[data-playlist-remove-row]');
|
|
var durationButton = event.target.closest('.playlist-use-video-duration');
|
|
var muteButton = event.target.closest('.playlist-disable-audio');
|
|
var row = getPlaylistSlideRow(event.target);
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
if (moveButton) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
movePlaylistRowByOffset(row, moveButton.getAttribute('data-playlist-row-move') === 'up' ? -1 : 1);
|
|
return;
|
|
}
|
|
|
|
if (removeButton) {
|
|
event.preventDefault();
|
|
row.remove();
|
|
updateRowOrder(true);
|
|
return;
|
|
}
|
|
|
|
if (durationButton) {
|
|
var rowKey = getPlaylistSlideRowKey(row);
|
|
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
|
|
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
|
|
|
|
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
|
|
lastDurationPointerDown = null;
|
|
lastDurationPointerUp = null;
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
void applyVideoDurationToRow(row, durationButton);
|
|
lastDurationPointerDown = null;
|
|
lastDurationPointerUp = null;
|
|
return;
|
|
}
|
|
|
|
if (muteButton) {
|
|
event.preventDefault();
|
|
void applyDisableAudioToRow(row, muteButton);
|
|
return;
|
|
}
|
|
|
|
var scheduleButton = event.target.closest('[data-schedule-config]');
|
|
if (scheduleButton) {
|
|
event.preventDefault();
|
|
if (typeof window.openScheduleModal === 'function') {
|
|
var scheduleParams = collectScheduleParams(row, scheduleButton.getAttribute('data-schedule-config-row') || row.getAttribute('data-row-key') || '');
|
|
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?' + scheduleParams.toString());
|
|
}
|
|
}
|
|
|
|
lastDurationPointerDown = null;
|
|
lastDurationPointerUp = null;
|
|
});
|
|
|
|
// Track the pointer source so clicks on the duration toggle do not fight text selection.
|
|
document.addEventListener('mousedown', function (event) {
|
|
var row = getPlaylistSlideRow(event.target);
|
|
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
|
|
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
|
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
|
var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null;
|
|
|
|
if (row && !dragHandle) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
if (durationInput) {
|
|
lastDurationPointerDown = getDurationPointerState(event.target, 'input');
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
if (durationToggle) {
|
|
lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
|
|
return;
|
|
}
|
|
|
|
if (muteToggle) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
lastDurationPointerDown = null;
|
|
});
|
|
|
|
// Mirror the pointer-up side of the duration toggle click detection.
|
|
document.addEventListener('mouseup', function (event) {
|
|
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
|
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
|
var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null;
|
|
var row = getPlaylistSlideRow(event.target);
|
|
|
|
if (durationInput) {
|
|
lastDurationPointerUp = getDurationPointerState(row, 'input');
|
|
return;
|
|
}
|
|
|
|
if (durationToggle) {
|
|
lastDurationPointerUp = getDurationPointerState(row, 'button');
|
|
return;
|
|
}
|
|
|
|
if (muteToggle) {
|
|
lastDurationPointerUp = null;
|
|
return;
|
|
}
|
|
|
|
lastDurationPointerUp = null;
|
|
});
|
|
|
|
// Keep the row dirty state and mirrored duration input in sync while the user edits.
|
|
document.addEventListener('input', function (event) {
|
|
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
|
|
var editedRow = getPlaylistSlideRow(event.target);
|
|
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
|
|
var editedMuteButton = editedRow ? editedRow.querySelector('.playlist-disable-audio') : null;
|
|
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
|
|
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
|
|
var disableAudio = Boolean(editedMuteButton && editedMuteButton.getAttribute('aria-pressed') === 'true');
|
|
|
|
if (editedRow) {
|
|
editedRow.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false');
|
|
editedRow.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false');
|
|
}
|
|
|
|
if (useVideoDuration && editedMirror) {
|
|
editedMirror.value = event.target.value;
|
|
}
|
|
|
|
if (editedMirror && !useVideoDuration) {
|
|
editedMirror.parentNode.removeChild(editedMirror);
|
|
}
|
|
markPlaylistDirty();
|
|
}
|
|
});
|
|
|
|
bindPlaylistTableDrag(tbody);
|
|
hydrateScheduleRowContainers(tbody);
|
|
initPlaylistScheduleModal();
|
|
|
|
if (addSlideModal && addSlideGrid) {
|
|
renderSlidePicker();
|
|
|
|
addSlideGrid.addEventListener('click', function (event) {
|
|
var card = event.target.closest('.playlist-slide-picker-card');
|
|
if (!card) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
toggleCardSelection(card);
|
|
});
|
|
|
|
if (addSlideSearch) {
|
|
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
|
}
|
|
|
|
if (addSlideShowAssigned) {
|
|
addSlideShowAssigned.addEventListener('click', function () {
|
|
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
|
|
setShowAssignedState(nextState);
|
|
syncSlidePickerState();
|
|
});
|
|
}
|
|
|
|
if (addSlideConfirm) {
|
|
addSlideConfirm.addEventListener('click', function () {
|
|
addSelectedSlides();
|
|
});
|
|
}
|
|
|
|
if (canvasSizeSelect) {
|
|
canvasSizeSelect.addEventListener('change', function () {
|
|
syncPlaylistCanvasId();
|
|
});
|
|
}
|
|
|
|
addSlideModal.addEventListener('shown.bs.modal', function () {
|
|
syncSlidePickerState();
|
|
if (addSlideSearch) {
|
|
addSlideSearch.focus();
|
|
}
|
|
});
|
|
|
|
addSlideModal.addEventListener('hidden.bs.modal', function () {
|
|
clearModalSelection();
|
|
});
|
|
}
|
|
|
|
window.rebindPlaylistTableDrag = function (tbodyElement) {
|
|
bindPlaylistTableDrag(tbodyElement);
|
|
};
|
|
|
|
updateRowOrder();
|
|
syncPlaylistCanvasLock();
|
|
syncAddSlideButtonState();
|
|
}
|
|
|
|
window.initPlaylistScheduleForm = initPlaylistScheduleForm;
|
|
|
|
initPlaylistScheduleForm();
|
|
initPlaylistEditStaging();
|
|
}());
|
|
|