This commit is contained in:
@@ -6,6 +6,26 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- No unreleased changes recorded yet.
|
||||
|
||||
## 1.5.3 - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails.
|
||||
- Reusable modal and table-pagination helpers for the admin UI.
|
||||
- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing.
|
||||
|
||||
### Changed
|
||||
|
||||
- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures.
|
||||
- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides.
|
||||
- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation.
|
||||
- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player upload sync and route handling now degrade more gracefully when backend services are unavailable.
|
||||
- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time.
|
||||
|
||||
## 1.5.2 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
Generated
+3516
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
@@ -16,14 +16,17 @@
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"hls.js": "^1.5.15",
|
||||
"handlebars": "^4.7.8",
|
||||
"hls.js": "^1.5.15",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,12 +9,19 @@ module.exports = {
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||
fetchSlidesPage: data.fetchSlidesPage,
|
||||
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||
fetchScreensPage: data.fetchScreensPage,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||
fetchRssFeedsData: data.fetchRssFeedsData,
|
||||
fetchRssFeedsPage: data.fetchRssFeedsPage,
|
||||
fetchRssFeedById: data.fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
||||
|
||||
+84
-4
@@ -1,5 +1,7 @@
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
@@ -10,7 +12,7 @@ async function fetchAdminData(pool) {
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
@@ -23,7 +25,7 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
@@ -33,6 +35,84 @@ async function fetchAdminData(pool) {
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
}
|
||||
|
||||
async function fetchPlaylistsPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
|
||||
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
||||
FROM playlists p
|
||||
ORDER BY p.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM playlists',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ playlists: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchSlidesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM slides',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ slides: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchTemplatesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ templates: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
||||
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
|
||||
FROM canvas_sizes
|
||||
ORDER BY width ASC, height ASC, name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchScreensPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM screens',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ screens: paged.rows }, paged);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAdminData
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
fetchTemplatesPage,
|
||||
fetchCanvasSizesPage,
|
||||
fetchScreensPage
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -14,6 +15,17 @@ async function fetchApiSourcesData(pool) {
|
||||
return { apiSources: apiSources };
|
||||
}
|
||||
|
||||
async function fetchApiSourcesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ apiSources: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchApiSourceById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?',
|
||||
@@ -144,6 +156,7 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
|
||||
module.exports = {
|
||||
fetchApiSourcesData: fetchApiSourcesData,
|
||||
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
return { canvasSizes };
|
||||
}
|
||||
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
@@ -28,6 +41,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
|
||||
module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizesPage,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
};
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
const { fetchAdminData } = require('./admin');
|
||||
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
@@ -13,12 +13,19 @@ module.exports = {
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
fetchTemplatesPage,
|
||||
fetchCanvasSizesPage,
|
||||
fetchScreensPage,
|
||||
fetchPlaylistById,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedsPage,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -14,6 +15,17 @@ async function fetchRssFeedsData(pool) {
|
||||
return { rssFeeds: rssFeeds };
|
||||
}
|
||||
|
||||
async function fetchRssFeedsPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ rssFeeds: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRssFeedById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
||||
@@ -285,6 +297,7 @@ function buildRssFeedPayload(req, existingRssFeed) {
|
||||
|
||||
module.exports = {
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedsPage,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ async function fetchScreenById(pool, id) {
|
||||
}
|
||||
|
||||
async function fetchScreenEditData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
return { playlists };
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ function sanitizeRichText(html) {
|
||||
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
@@ -90,7 +90,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
content[region.region_key] = {
|
||||
type: 'image',
|
||||
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
|
||||
@@ -154,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||
const backgroundImagePath = backgroundImage
|
||||
? `/media/${backgroundImage.filename}`
|
||||
? `/media/uploads/${backgroundImage.filename}`
|
||||
: removeBackgroundImage
|
||||
? null
|
||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||
|
||||
@@ -22,6 +22,38 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
async function fetchPagedRows(pool, options) {
|
||||
const selectSql = String(options && options.selectSql || '').trim();
|
||||
const countSql = String(options && options.countSql || '').trim();
|
||||
const params = Array.isArray(options && options.params) ? options.params : [];
|
||||
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
||||
const currentPage = normalizePageNumber(options && options.page);
|
||||
|
||||
if (!selectSql || !countSql) {
|
||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query(countSql, params);
|
||||
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const offset = (safeCurrentPage - 1) * pageSize;
|
||||
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
|
||||
|
||||
return {
|
||||
rows: rows || [],
|
||||
totalItems: totalItems,
|
||||
totalPages: totalPages,
|
||||
currentPage: safeCurrentPage,
|
||||
pageSize: pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||
const normalizedName = String(name || '').trim();
|
||||
if (!normalizedName) {
|
||||
@@ -44,5 +76,7 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray,
|
||||
normalizePageNumber,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
+4
-2
@@ -23,7 +23,7 @@ async function pruneStaleOnboardingDevices(pool) {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
async function ensureSchema(pool, options) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -43,6 +43,7 @@ async function ensureSchema(pool) {
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -102,6 +103,7 @@ async function ensureSchema(pool) {
|
||||
content_json JSON NULL,
|
||||
media_path VARCHAR(512) NULL,
|
||||
media_type VARCHAR(100) NULL,
|
||||
thumbnail_path VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -341,7 +343,7 @@ async function ensureSchema(pool) {
|
||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||
);
|
||||
|
||||
await migrations.runMigrations(pool);
|
||||
await migrations.runMigrations(pool, options || {});
|
||||
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||
|
||||
+235
-2
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { version: appVersion } = require('../../package.json');
|
||||
|
||||
function parseVersion(value) {
|
||||
@@ -260,6 +262,27 @@ function replaceUploadPrefixInValue(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function replaceLegacyMediaUploadPathInValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
|
||||
return value;
|
||||
}
|
||||
return '/media/uploads/' + path.basename(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (item) {
|
||||
return replaceLegacyMediaUploadPathInValue(item);
|
||||
});
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).reduce(function (result, key) {
|
||||
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
@@ -327,6 +350,187 @@ async function backfillLegacyMediaPaths(pool) {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFileIfMissing(sourcePath, targetPath) {
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
return false;
|
||||
} catch (_error) {
|
||||
// target does not exist
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(sourcePath, fs.constants.F_OK);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'EXDEV') {
|
||||
await fs.promises.copyFile(sourcePath, targetPath);
|
||||
await fs.promises.unlink(sourcePath);
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
|
||||
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||
if (!normalizedMediaDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const [slides] = await pool.query(`
|
||||
SELECT id, media_path, content_json
|
||||
FROM slides
|
||||
WHERE media_path LIKE '/media/%'
|
||||
OR content_json LIKE '%/media/%'
|
||||
`);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||
let contentJson = slide.content_json;
|
||||
let changed = false;
|
||||
|
||||
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||
const fileName = path.basename(mediaPath);
|
||||
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||
mediaPath = '/media/uploads/' + fileName;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonValue(contentJson);
|
||||
if (parsedContent && typeof parsedContent === 'object') {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||
contentJson = JSON.stringify(updatedContent);
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (updatedContent !== parsedContent) {
|
||||
contentJson = updatedContent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const [templates] = await pool.query(`
|
||||
SELECT id, background_image_path
|
||||
FROM slide_templates
|
||||
WHERE background_image_path LIKE '/media/%'
|
||||
`);
|
||||
|
||||
for (const template of templates || []) {
|
||||
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.basename(backgroundImagePath);
|
||||
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||
['/media/uploads/' + fileName, template.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
|
||||
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||
if (!normalizedMediaDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
|
||||
for (const entry of directoryEntries || []) {
|
||||
if (!entry || !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = String(entry.name || '').trim();
|
||||
if (!fileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(normalizedMediaDir, fileName);
|
||||
const targetPath = path.join(uploadsDir, fileName);
|
||||
await moveFileIfMissing(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(`
|
||||
SELECT id, media_path, content_json
|
||||
FROM slides
|
||||
WHERE media_path LIKE '/media/%'
|
||||
OR content_json LIKE '%/media/%'
|
||||
`);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||
let contentJson = slide.content_json;
|
||||
let changed = false;
|
||||
|
||||
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||
mediaPath = '/media/uploads/' + path.basename(mediaPath);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonValue(contentJson);
|
||||
if (parsedContent && typeof parsedContent === 'object') {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||
contentJson = JSON.stringify(updatedContent);
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (updatedContent !== parsedContent) {
|
||||
contentJson = updatedContent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const [templates] = await pool.query(`
|
||||
SELECT id, background_image_path
|
||||
FROM slide_templates
|
||||
WHERE background_image_path LIKE '/media/%'
|
||||
`);
|
||||
|
||||
for (const template of templates || []) {
|
||||
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.basename(backgroundImagePath);
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||
['/media/uploads/' + fileName, template.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMigrationTable(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -410,6 +614,7 @@ const migrations = [
|
||||
|
||||
// v1.4.6: playlists gained fade_between_slides plus audit fields.
|
||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'playlists');
|
||||
|
||||
@@ -433,6 +638,7 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slides');
|
||||
|
||||
@@ -517,6 +723,15 @@ const migrations = [
|
||||
await addAuditColumns(pool, 'auth_sessions');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-skip-unavailable-rtmp',
|
||||
version: appVersion,
|
||||
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
|
||||
order: 21,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-path-prefix-rename',
|
||||
version: appVersion,
|
||||
@@ -526,6 +741,24 @@ const migrations = [
|
||||
await backfillLegacyMediaPaths(pool);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-upload-subfolder-move',
|
||||
version: appVersion,
|
||||
comment: 'Move existing upload files and references from the media root into media/uploads.',
|
||||
order: 26,
|
||||
up: async function (pool, options) {
|
||||
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-upload-subfolder-rescue',
|
||||
version: appVersion,
|
||||
comment: 'Rescan loose media files and promote them into media/uploads.',
|
||||
order: 27,
|
||||
up: async function (pool, options) {
|
||||
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'background-tasks-table',
|
||||
version: appVersion,
|
||||
@@ -556,7 +789,7 @@ const migrations = [
|
||||
}
|
||||
];
|
||||
|
||||
async function runMigrations(pool) {
|
||||
async function runMigrations(pool, options) {
|
||||
const appliedRows = await getAppliedMigrationRows(pool);
|
||||
const appliedKeys = new Set((appliedRows || []).map(function (row) {
|
||||
return String(row.migration_key || '').trim();
|
||||
@@ -576,7 +809,7 @@ async function runMigrations(pool) {
|
||||
});
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
await migration.up(pool);
|
||||
await migration.up(pool, options || {});
|
||||
await recordMigration(pool, migration);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ async function start() {
|
||||
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
await common.ensureSchema(pool);
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await pruneStaleOnboardingDevices(pool);
|
||||
|
||||
@@ -6,7 +6,13 @@ const { spawn } = require('child_process');
|
||||
function createRtmpStreamService(options) {
|
||||
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
|
||||
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
|
||||
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
|
||||
const sessions = new Map();
|
||||
const watchedSources = new Set();
|
||||
const sourceStatusCache = new Map();
|
||||
const sourceProbePromises = new Map();
|
||||
let probeTimer = null;
|
||||
|
||||
if (!mediaDir) {
|
||||
throw new Error('mediaDir is required');
|
||||
@@ -48,6 +54,207 @@ function createRtmpStreamService(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function runCommand(command, args, timeoutMs) {
|
||||
return new Promise(function (resolve) {
|
||||
var child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
var stdout = '';
|
||||
var stderr = '';
|
||||
var finished = false;
|
||||
var timer = null;
|
||||
|
||||
function done(result) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
child.stdout.on('data', function (chunk) {
|
||||
stdout += String(chunk || '');
|
||||
});
|
||||
|
||||
child.stderr.on('data', function (chunk) {
|
||||
stderr += String(chunk || '');
|
||||
});
|
||||
|
||||
child.on('error', function (error) {
|
||||
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
|
||||
});
|
||||
|
||||
child.on('exit', function (code, signal) {
|
||||
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
|
||||
});
|
||||
|
||||
timer = setTimeout(function () {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (_error) {
|
||||
// ignore timeout cleanup errors
|
||||
}
|
||||
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
|
||||
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
|
||||
});
|
||||
}
|
||||
|
||||
async function probeSourceUrl(sourceUrl) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const result = await runCommand(ffprobePath, [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-rw_timeout', '3000000',
|
||||
'-show_streams',
|
||||
'-of', 'json',
|
||||
normalizedSource
|
||||
], 4000);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
live: false,
|
||||
timedOut: Boolean(result.timedOut),
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(result.stdout || '{}'));
|
||||
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
|
||||
return {
|
||||
live: streams.length > 0,
|
||||
timedOut: false,
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
live: false,
|
||||
timedOut: false,
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceStatus(sourceUrl) {
|
||||
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
|
||||
}
|
||||
|
||||
function setSourceStatus(sourceUrl, nextStatus) {
|
||||
const normalizedSource = String(sourceUrl || '').trim();
|
||||
if (!normalizedSource) {
|
||||
return null;
|
||||
}
|
||||
const status = Object.assign({
|
||||
live: false,
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: false,
|
||||
stderr: ''
|
||||
}, nextStatus || {});
|
||||
status.live = Boolean(status.live);
|
||||
status.probing = Boolean(status.probing);
|
||||
status.checkedAt = Number(status.checkedAt || Date.now());
|
||||
sourceStatusCache.set(normalizedSource, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
function registerSourceWatch(sourceUrl) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
watchedSources.add(normalizedSource);
|
||||
return normalizedSource;
|
||||
}
|
||||
|
||||
async function refreshSourceStatus(sourceUrl) {
|
||||
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
|
||||
probing: true
|
||||
}));
|
||||
|
||||
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
|
||||
return setSourceStatus(normalizedSource, {
|
||||
live: Boolean(probe.live),
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: Boolean(probe.timedOut),
|
||||
stderr: String(probe.stderr || '')
|
||||
});
|
||||
}).catch(function (error) {
|
||||
return setSourceStatus(normalizedSource, {
|
||||
live: false,
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: false,
|
||||
stderr: String(error && error.message ? error.message : '')
|
||||
});
|
||||
}).finally(function () {
|
||||
sourceProbePromises.delete(normalizedSource);
|
||||
});
|
||||
|
||||
sourceProbePromises.set(normalizedSource, probePromise);
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
function scheduleSourceRefresh(sourceUrl) {
|
||||
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||
const currentStatus = getSourceStatus(normalizedSource);
|
||||
if (currentStatus && currentStatus.probing) {
|
||||
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
|
||||
}
|
||||
return refreshSourceStatus(normalizedSource);
|
||||
}
|
||||
|
||||
function isSourceStatusFresh(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
|
||||
}
|
||||
|
||||
function sweepWatchedSources() {
|
||||
watchedSources.forEach(function (sourceUrl) {
|
||||
const status = getSourceStatus(sourceUrl);
|
||||
if (!status || !isSourceStatusFresh(status) || status.live === false) {
|
||||
scheduleSourceRefresh(sourceUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (probeIntervalMs > 0) {
|
||||
probeTimer = setInterval(function () {
|
||||
sweepWatchedSources();
|
||||
}, probeIntervalMs);
|
||||
if (probeTimer && typeof probeTimer.unref === 'function') {
|
||||
probeTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async function isSessionLive(session) {
|
||||
if (!session || !session.process) {
|
||||
return false;
|
||||
}
|
||||
if (session.exitCode !== undefined && session.exitCode !== null) {
|
||||
return false;
|
||||
}
|
||||
if (session.exitSignal !== undefined && session.exitSignal !== null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.stat(session.manifestPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaylistUrl(key) {
|
||||
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
|
||||
}
|
||||
@@ -56,14 +263,30 @@ function createRtmpStreamService(options) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const normalizedDisableAudio = Boolean(disableAudio);
|
||||
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
|
||||
|
||||
if (sessions.has(key)) {
|
||||
return sessions.get(key);
|
||||
}
|
||||
|
||||
const directory = path.join(cacheRoot, key);
|
||||
const manifestPath = path.join(directory, 'index.m3u8');
|
||||
|
||||
const existingSession = sessions.get(key) || null;
|
||||
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
if (existingSession) {
|
||||
sessions.delete(key);
|
||||
try {
|
||||
if (existingSession.process && typeof existingSession.process.kill === 'function') {
|
||||
existingSession.process.kill('SIGKILL');
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||
} catch (_error2) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDirectory(directory);
|
||||
|
||||
const args = [
|
||||
@@ -117,6 +340,8 @@ function createRtmpStreamService(options) {
|
||||
manifestPath: manifestPath,
|
||||
playlistUrl: buildPlaylistUrl(key),
|
||||
process: child,
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
ready: waitForFile(manifestPath, 5000)
|
||||
};
|
||||
|
||||
@@ -132,6 +357,51 @@ function createRtmpStreamService(options) {
|
||||
return session.playlistUrl + '?t=' + Date.now();
|
||||
}
|
||||
|
||||
async function getSessionStatus(sourceUrl, disableAudio) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const normalizedDisableAudio = Boolean(disableAudio);
|
||||
registerSourceWatch(normalizedSource);
|
||||
|
||||
let sourceStatus = getSourceStatus(normalizedSource);
|
||||
if (!sourceStatus) {
|
||||
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||
return null;
|
||||
});
|
||||
sourceStatus = getSourceStatus(normalizedSource);
|
||||
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
|
||||
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceStatus || !sourceStatus.live) {
|
||||
return {
|
||||
session: null,
|
||||
ready: false,
|
||||
live: false,
|
||||
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
|
||||
stderr: String(sourceStatus && sourceStatus.stderr || '')
|
||||
};
|
||||
}
|
||||
|
||||
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
|
||||
const ready = await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
|
||||
return {
|
||||
session: session,
|
||||
ready: ready,
|
||||
live: live,
|
||||
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||
timedOut: false,
|
||||
stderr: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionByKey(key) {
|
||||
return sessions.get(String(key || '').trim()) || null;
|
||||
}
|
||||
@@ -141,9 +411,13 @@ function createRtmpStreamService(options) {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
|
||||
return null;
|
||||
}
|
||||
const live = await isSessionLive(session);
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
return session.manifestPath;
|
||||
}
|
||||
|
||||
@@ -163,6 +437,11 @@ function createRtmpStreamService(options) {
|
||||
ensureSession: ensureSession,
|
||||
getPlaylistUrl: getPlaylistUrl,
|
||||
getSessionByKey: getSessionByKey,
|
||||
isSessionLive: isSessionLive,
|
||||
getSessionStatus: getSessionStatus,
|
||||
refreshSourceStatus: refreshSourceStatus,
|
||||
scheduleSourceRefresh: scheduleSourceRefresh,
|
||||
getSourceStatus: getSourceStatus,
|
||||
getManifestFilePath: getManifestFilePath,
|
||||
getSegmentFilePath: getSegmentFilePath
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
let currentPlaylistSignature = '';
|
||||
let currentPlaylistEtag = '';
|
||||
let currentPlaylistFadeBetweenSlides = false;
|
||||
let currentPlaylistSkipUnavailableRtmp = false;
|
||||
let pendingPlaylistUpdate = null;
|
||||
let activeSlidesCacheKey = '';
|
||||
let activeSlidesCacheValue = [];
|
||||
@@ -16,6 +17,7 @@
|
||||
let index = 0;
|
||||
let timer = null;
|
||||
let slideTransitionTimer = null;
|
||||
let slideRenderRequestId = 0;
|
||||
let preloadContainer = null;
|
||||
let preloadSignature = '';
|
||||
let commandSocket = null;
|
||||
@@ -154,16 +156,18 @@
|
||||
}
|
||||
if (initialData && initialData.playlist) {
|
||||
currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides);
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(initialData.playlist.skip_unavailable_rtmp);
|
||||
}
|
||||
savePlaylistSnapshot({
|
||||
slides: slides,
|
||||
signature: currentPlaylistSignature,
|
||||
fadeBetweenSlides: currentPlaylistFadeBetweenSlides,
|
||||
skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp,
|
||||
etag: currentPlaylistEtag
|
||||
});
|
||||
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
|
||||
if (typeof syncRtmpPreloads === 'function') {
|
||||
syncRtmpPreloads(getActiveSlidesFrom(slides), index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(getActiveSlidesFrom(slides), index);
|
||||
}
|
||||
syncBlackoutState();
|
||||
showCurrent();
|
||||
|
||||
@@ -73,7 +73,7 @@ function createPlayerPlaylistService(options) {
|
||||
return payloadWithoutPlaylist;
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
@@ -180,6 +180,7 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
|
||||
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
|
||||
@@ -397,6 +397,7 @@ body.screen-blackout #app {
|
||||
.template-region-rtmp-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.template-region-placeholder {
|
||||
|
||||
@@ -89,6 +89,12 @@ function scheduleSlideAdvance(delayMs) {
|
||||
}, holdDelayMs);
|
||||
}
|
||||
|
||||
// Add the fade time to a slide's hold duration so the configured duration remains visible.
|
||||
function getSlideHoldDelay(delayMs) {
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
function clearSlideTransitionTimer() {
|
||||
if (slideTransitionTimer) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Show or hide the offline status banner.
|
||||
function setOfflineBannerVisible(visible, message) {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
var normalizedVisible = Boolean(visible);
|
||||
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
||||
if (normalizedVisible) {
|
||||
@@ -38,6 +41,9 @@ function setOfflineBannerVisible(visible, message) {
|
||||
|
||||
// Update the offline banner based on connectivity or playlist availability.
|
||||
function syncOfflineBanner() {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
if (!window.navigator.onLine) {
|
||||
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
||||
return;
|
||||
@@ -57,6 +63,9 @@ function clearRefreshRetry() {
|
||||
|
||||
// Retry playlist refresh with a short backoff while the player is offline.
|
||||
function scheduleRefreshRetry() {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
if (refreshRetryTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Render the slide at the requested index within the active set.
|
||||
function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
@@ -11,18 +11,52 @@ function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
}
|
||||
|
||||
const slide = availableSlides[normalizedIndex];
|
||||
var slideCount = availableSlides.length;
|
||||
var currentIndex = normalizedIndex;
|
||||
var slide = null;
|
||||
var attempts = 0;
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
|
||||
while (attempts < slideCount) {
|
||||
slide = availableSlides[currentIndex];
|
||||
if (!slide) {
|
||||
break;
|
||||
}
|
||||
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
|
||||
var isAvailable = await probeRtmpSlideAvailability(slide);
|
||||
if (!isAvailable) {
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
return false;
|
||||
}
|
||||
currentIndex = (currentIndex + 1) % slideCount;
|
||||
attempts += 1;
|
||||
slide = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!slide) {
|
||||
if (currentPlaylistSkipUnavailableRtmp) {
|
||||
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||
scheduleRefreshRetry();
|
||||
return false;
|
||||
}
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
return false;
|
||||
}
|
||||
|
||||
index = normalizedIndex;
|
||||
index = currentIndex;
|
||||
var markup = buildSlideMarkup(slide);
|
||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||
sendCommandState(slide);
|
||||
if (!isPaused) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000);
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||
return true;
|
||||
@@ -36,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
||||
slides = pendingPlaylistUpdate.slides;
|
||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
|
||||
pendingPlaylistUpdate = null;
|
||||
clearActiveSlidesCache();
|
||||
slideMarkupCache = Object.create(null);
|
||||
@@ -53,18 +88,45 @@ function showCurrent() {
|
||||
applyPendingPlaylistUpdate();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
syncWebpagePreloads(activeSlides, index);
|
||||
if (typeof syncRtmpPreloads === 'function') {
|
||||
syncRtmpPreloads(activeSlides, index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(activeSlides, index);
|
||||
}
|
||||
if (!activeSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
return;
|
||||
}
|
||||
renderSlideAtIndex(activeSlides, index);
|
||||
void renderSlideAtIndex(activeSlides, index);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
||||
function handleRtmpPlaybackFailure(message, options) {
|
||||
if (!currentPlaylistSkipUnavailableRtmp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var silent = Boolean(options && options.silent);
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (!activeSlides.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearSlideTimer();
|
||||
if (!silent) {
|
||||
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
|
||||
}
|
||||
|
||||
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
|
||||
window.setTimeout(function () {
|
||||
if (!getCurrentActiveSlides().length) {
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch the latest playlist and queue any updates.
|
||||
function refresh() {
|
||||
var request = new XMLHttpRequest();
|
||||
@@ -85,7 +147,7 @@ function refresh() {
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -105,10 +167,12 @@ function refresh() {
|
||||
const nextSignature = getPlaylistRevision(data);
|
||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||
savePlaylistSnapshot({
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp,
|
||||
etag: responseEtag
|
||||
});
|
||||
markRefreshHealthy();
|
||||
@@ -121,6 +185,7 @@ function refresh() {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
@@ -128,18 +193,19 @@ function refresh() {
|
||||
}
|
||||
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
||||
if (typeof syncRtmpPreloads === 'function') {
|
||||
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
||||
}
|
||||
if (currentActiveSlides.length < 2) {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||
pendingPlaylistUpdate = null;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
@@ -149,7 +215,8 @@ function refresh() {
|
||||
pendingPlaylistUpdate = {
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
} catch (_error) {
|
||||
@@ -171,7 +238,7 @@ function refresh() {
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
};
|
||||
request.ontimeout = function () {
|
||||
@@ -183,7 +250,7 @@ function refresh() {
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
};
|
||||
request.send();
|
||||
|
||||
@@ -411,6 +411,22 @@ function getTemplateLayout(template) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Build a dark backdrop style for template and media canvases.
|
||||
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||
|
||||
if (backgroundImagePath) {
|
||||
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
|
||||
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
// Build the cache key for a template render plan.
|
||||
function getTemplateRenderPlanCacheKey(template) {
|
||||
return getTemplateLayoutCacheKey(template);
|
||||
@@ -469,7 +485,7 @@ function renderTemplateSlideMarkup(slide) {
|
||||
const regionContent = content[region.regionKey] || {};
|
||||
return plan.renderRegion(region, regionContent);
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
|
||||
}
|
||||
|
||||
@@ -486,14 +502,16 @@ function renderMediaSlideContent(slide) {
|
||||
function renderMediaSlideMarkup(slide) {
|
||||
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
||||
const media = renderMediaSlideContent(slide);
|
||||
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
|
||||
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
|
||||
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
|
||||
}
|
||||
|
||||
// Render the shared slide shell around slide-specific inner content.
|
||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
|
||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
||||
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
|
||||
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
||||
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
|
||||
}
|
||||
|
||||
// Build the cache key for rendered slide markup.
|
||||
|
||||
+707
-109
@@ -1,20 +1,296 @@
|
||||
function renderRtmpRegion(region, regionContent) {
|
||||
var url = String(regionContent.value || '').trim();
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
|
||||
if (!url) {
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
|
||||
}
|
||||
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
}
|
||||
|
||||
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
|
||||
}
|
||||
|
||||
var rtmpPreloadSignature = '';
|
||||
var rtmpAvailabilityCache = Object.create(null);
|
||||
var rtmpAvailabilityRetryMs = 5000;
|
||||
var rtmpWarmupTimers = Object.create(null);
|
||||
var rtmpBrowserReadyCache = Object.create(null);
|
||||
var rtmpBrowserWarmupTimers = Object.create(null);
|
||||
var rtmpBrowserWarmupContainer = null;
|
||||
var rtmpBrowserWarmupStates = Object.create(null);
|
||||
|
||||
function getRtmpPreloadSlides(sourceSlides, targetIndex) {
|
||||
function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
|
||||
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
|
||||
}
|
||||
|
||||
function setRtmpAvailability(sourceUrl, disableAudio, available) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
rtmpAvailabilityCache[key] = {
|
||||
available: available === null ? null : Boolean(available),
|
||||
pending: available === null,
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function getRtmpAvailability(sourceUrl, disableAudio) {
|
||||
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function isRtmpAvailabilityStale(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||
}
|
||||
|
||||
function isRtmpSlideUnavailable(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
return slide.template.regions.some(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
|
||||
});
|
||||
}
|
||||
|
||||
function clearRtmpAvailabilityForSlide(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
slide.template.regions.forEach(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||
});
|
||||
}
|
||||
|
||||
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
rtmpBrowserReadyCache[key] = {
|
||||
ready: ready === null ? null : Boolean(ready),
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function getRtmpBrowserReady(sourceUrl, disableAudio) {
|
||||
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function isRtmpBrowserReadyStale(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||
}
|
||||
|
||||
function ensureRtmpBrowserWarmupContainer() {
|
||||
if (rtmpBrowserWarmupContainer) {
|
||||
return rtmpBrowserWarmupContainer;
|
||||
}
|
||||
|
||||
rtmpBrowserWarmupContainer = document.createElement('div');
|
||||
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
|
||||
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(rtmpBrowserWarmupContainer);
|
||||
return rtmpBrowserWarmupContainer;
|
||||
}
|
||||
|
||||
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
var state = rtmpBrowserWarmupStates[key];
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
|
||||
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
|
||||
state.video.__rtmpBrowserWarmupTimer = null;
|
||||
}
|
||||
if (state.video && state.video.__rtmpHls) {
|
||||
try {
|
||||
state.video.__rtmpHls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
state.video.__rtmpHls = null;
|
||||
}
|
||||
if (state.wrapper && state.wrapper.parentNode) {
|
||||
state.wrapper.parentNode.removeChild(state.wrapper);
|
||||
}
|
||||
delete rtmpBrowserWarmupStates[key];
|
||||
}
|
||||
|
||||
function warmupRtmpBrowser(sourceUrl, disableAudio) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
var existing = rtmpBrowserWarmupStates[key];
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'rtmp-browser-warmup-entry';
|
||||
wrapper.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var video = document.createElement('video');
|
||||
video.className = 'template-region-rtmp-video';
|
||||
video.dataset.rtmpSource = sourceUrl;
|
||||
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
video.tabIndex = -1;
|
||||
video.disablePictureInPicture = true;
|
||||
video.muted = disableAudio;
|
||||
|
||||
wrapper.appendChild(video);
|
||||
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
|
||||
|
||||
var state = {
|
||||
wrapper: wrapper,
|
||||
video: video,
|
||||
sourceUrl: sourceUrl,
|
||||
disableAudio: disableAudio
|
||||
};
|
||||
rtmpBrowserWarmupStates[key] = state;
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||
|
||||
var markReady = function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, true);
|
||||
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||
};
|
||||
|
||||
var markNotReady = function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
video.addEventListener('error', markNotReady, { once: true });
|
||||
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 10,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 10
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
markNotReady();
|
||||
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||
return state;
|
||||
}
|
||||
|
||||
markNotReady();
|
||||
return state;
|
||||
}
|
||||
|
||||
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
var response = result.response;
|
||||
var payload = result.payload || null;
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503 && payload && payload.probing) {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||
if (video && !video.__rtmpBrowserWarmupTimer) {
|
||||
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
|
||||
video.__rtmpBrowserWarmupTimer = null;
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
|
||||
}, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
if (!playlistUrl) {
|
||||
throw new Error('RTMP playlist URL was not returned.');
|
||||
}
|
||||
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
|
||||
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
video.__rtmpHls.loadSource(playlistUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (video) {
|
||||
video.src = playlistUrl;
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}).catch(function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
if (typeof onFailure === 'function') {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
|
||||
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
return [];
|
||||
@@ -25,21 +301,21 @@ function getRtmpPreloadSlides(sourceSlides, targetIndex) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
var preloadSlides = [];
|
||||
var warmupSlides = [];
|
||||
var currentSlide = availableSlides[normalizedIndex];
|
||||
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
preloadSlides.push(currentSlide);
|
||||
warmupSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
preloadSlides.push(nextSlide);
|
||||
warmupSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return preloadSlides;
|
||||
return warmupSlides;
|
||||
}
|
||||
|
||||
function getRtmpPreloadEntries(sourceSlides) {
|
||||
function getRtmpWarmupEntries(sourceSlides) {
|
||||
var entries = [];
|
||||
var seen = Object.create(null);
|
||||
|
||||
@@ -50,20 +326,24 @@ function getRtmpPreloadEntries(sourceSlides) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var url = String(regionContent.value || '').trim();
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var key = url + '\n' + (disableAudio ? '1' : '0');
|
||||
var key = getRtmpAvailabilityKey(url, disableAudio);
|
||||
if (seen[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[key] = true;
|
||||
entries.push({
|
||||
url: url,
|
||||
disableAudio: disableAudio
|
||||
disableAudio: disableAudio,
|
||||
key: key
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,30 +351,397 @@ function getRtmpPreloadEntries(sourceSlides) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function syncRtmpPreloads(sourceSlides, targetIndex) {
|
||||
var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex));
|
||||
var signature = entries.map(function (entry) {
|
||||
return entry.url + '\n' + (entry.disableAudio ? '1' : '0');
|
||||
}).join('\n');
|
||||
|
||||
if (signature === rtmpPreloadSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
rtmpPreloadSignature = signature;
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
function syncRtmpWarmups(sourceSlides, targetIndex) {
|
||||
var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
|
||||
|
||||
entries.forEach(function (entry) {
|
||||
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).catch(function () {
|
||||
return null;
|
||||
});
|
||||
var status = getRtmpAvailability(entry.url, entry.disableAudio);
|
||||
if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rtmpWarmupTimers[entry.key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
function requestWarmup() {
|
||||
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
if (!result || !result.response) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.response.ok) {
|
||||
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
requestWarmup();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||
}).catch(function () {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
});
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
requestWarmup();
|
||||
});
|
||||
}
|
||||
|
||||
async function probeRtmpSlideAvailability(slide) {
|
||||
var entries = getRtmpWarmupEntries([slide]);
|
||||
if (!entries.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var results = await Promise.all(entries.map(function (entry) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result.response.ok) {
|
||||
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
return false;
|
||||
}
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||
return true;
|
||||
}).catch(function () {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
});
|
||||
}));
|
||||
|
||||
return results.every(function (value) {
|
||||
return Boolean(value);
|
||||
});
|
||||
}
|
||||
|
||||
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
function isRtmpBrowserUnavailable(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
return slide.template.regions.some(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
|
||||
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRtmpVideoPlaceholder(video, placeholder) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
var markReady = function () {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
if (video.readyState >= 2) {
|
||||
markReady();
|
||||
}
|
||||
}
|
||||
|
||||
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
|
||||
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
|
||||
var startupTimer = null;
|
||||
var probeRetryTimer = null;
|
||||
var webReceiveTimer = null;
|
||||
var webReceiveRetryUsed = false;
|
||||
|
||||
function failPlayback(message, silent) {
|
||||
if (video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
video.dataset.rtmpFailureHandled = '1';
|
||||
if (typeof onFailure === 'function') {
|
||||
onFailure(message, silent);
|
||||
return;
|
||||
}
|
||||
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||
return;
|
||||
}
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStartupTimer() {
|
||||
if (startupTimer) {
|
||||
window.clearTimeout(startupTimer);
|
||||
startupTimer = null;
|
||||
}
|
||||
if (video.__rtmpStartupTimer) {
|
||||
video.__rtmpStartupTimer = null;
|
||||
}
|
||||
if (probeRetryTimer) {
|
||||
window.clearTimeout(probeRetryTimer);
|
||||
probeRetryTimer = null;
|
||||
}
|
||||
if (video.__rtmpProbeRetryTimer) {
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
}
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function tryPlay() {
|
||||
if (video && typeof video.play === 'function') {
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markReceiving() {
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReceiveRetry() {
|
||||
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
if (webReceiveTimer) {
|
||||
return;
|
||||
}
|
||||
webReceiveTimer = window.setTimeout(function () {
|
||||
webReceiveTimer = null;
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
|
||||
return;
|
||||
}
|
||||
if (video.readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
webReceiveRetryUsed = true;
|
||||
clearStartupTimer();
|
||||
try {
|
||||
if (video.__rtmpHls) {
|
||||
video.__rtmpHls.destroy();
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
requestSession();
|
||||
}, 1200);
|
||||
video.__rtmpWebReceiveTimer = webReceiveTimer;
|
||||
}
|
||||
|
||||
function retryWebReceiveOrFail(message, silent) {
|
||||
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
if (!webReceiveRetryUsed) {
|
||||
webReceiveRetryUsed = true;
|
||||
clearStartupTimer();
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
requestSession();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
failPlayback(message, silent);
|
||||
}
|
||||
|
||||
video.dataset.rtmpInitialized = '1';
|
||||
video.muted = disableAudio;
|
||||
video.controls = false;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
|
||||
bindRtmpVideoPlaceholder(video, placeholder);
|
||||
video.addEventListener('canplay', markReceiving, { once: true });
|
||||
video.addEventListener('playing', markReceiving, { once: true });
|
||||
|
||||
if (startupTimeoutMs > 0) {
|
||||
startupTimer = window.setTimeout(function () {
|
||||
startupTimer = null;
|
||||
video.__rtmpStartupTimer = null;
|
||||
failPlayback('Unable to load RTMP stream.', true);
|
||||
}, startupTimeoutMs);
|
||||
video.__rtmpStartupTimer = startupTimer;
|
||||
}
|
||||
|
||||
function retryWhileProbing() {
|
||||
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
probeRetryTimer = window.setTimeout(function () {
|
||||
probeRetryTimer = null;
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
requestSession();
|
||||
}, 500);
|
||||
video.__rtmpProbeRetryTimer = probeRetryTimer;
|
||||
}
|
||||
|
||||
function requestSession() {
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
var response = result.response;
|
||||
var payload = result.payload || null;
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503 && payload && payload.probing) {
|
||||
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||
retryWhileProbing();
|
||||
return null;
|
||||
}
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||
return null;
|
||||
}
|
||||
|
||||
setRtmpAvailability(sourceUrl, disableAudio, true);
|
||||
if (!playlistUrl) {
|
||||
throw new Error('RTMP playlist URL was not returned.');
|
||||
}
|
||||
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 20,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 30
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||
hls.loadSource(playlistUrl);
|
||||
scheduleReceiveRetry();
|
||||
});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||
tryPlay();
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
clearStartupTimer();
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
retryWebReceiveOrFail('RTMP playback failed.', true);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = playlistUrl;
|
||||
tryPlay();
|
||||
scheduleReceiveRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
failPlayback('RTMP playback is not supported in this browser.', true);
|
||||
return null;
|
||||
}).catch(function () {
|
||||
clearStartupTimer();
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||
});
|
||||
}
|
||||
|
||||
requestSession();
|
||||
}
|
||||
|
||||
function syncRtmpRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
@@ -118,93 +765,32 @@ function syncRtmpRegions(root) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.dataset.rtmpInitialized = '1';
|
||||
video.muted = disableAudio;
|
||||
video.controls = false;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
|
||||
var startupTimer = null;
|
||||
|
||||
var markReady = function () {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
var tryPlay = function () {
|
||||
if (video && typeof video.play === 'function') {
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
if (!playlistUrl) {
|
||||
throw new Error('RTMP playlist URL was not returned.');
|
||||
}
|
||||
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 20,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 30
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||
hls.loadSource(playlistUrl);
|
||||
});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||
tryPlay();
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'RTMP playback failed.';
|
||||
}
|
||||
}
|
||||
});
|
||||
if (skipUnavailable) {
|
||||
var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||
if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
|
||||
failPlayback('Unable to load RTMP stream.', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = playlistUrl;
|
||||
tryPlay();
|
||||
function failPlayback(message, silent) {
|
||||
if (video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
video.dataset.rtmpFailureHandled = '1';
|
||||
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||
return;
|
||||
}
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'RTMP playback is not supported in this browser.';
|
||||
}
|
||||
}).catch(function (_error) {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'Unable to load RTMP stream.';
|
||||
}
|
||||
});
|
||||
startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,6 +801,18 @@ function destroyRtmpRegions(root) {
|
||||
|
||||
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (video.__rtmpStartupTimer) {
|
||||
window.clearTimeout(video.__rtmpStartupTimer);
|
||||
video.__rtmpStartupTimer = null;
|
||||
}
|
||||
if (video.__rtmpProbeRetryTimer) {
|
||||
window.clearTimeout(video.__rtmpProbeRetryTimer);
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
window.clearTimeout(video.__rtmpWebReceiveTimer);
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpHls) {
|
||||
try {
|
||||
video.__rtmpHls.destroy();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const Handlebars = require('handlebars');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||
|
||||
function renderPage(template, options) {
|
||||
@@ -117,7 +118,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+76
-15
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
|
||||
@@ -58,6 +59,21 @@ function registerPlayerRoutes(app, options) {
|
||||
next();
|
||||
}
|
||||
|
||||
function resolveMediaFilePath(fileName) {
|
||||
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedMediaDir = path.resolve(mediaDir);
|
||||
const resolvedFilePath = path.resolve(mediaDir, relativePath);
|
||||
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||
@@ -93,21 +109,21 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||
res.json({
|
||||
mediaDir: mediaDir
|
||||
mediaDir: mediaDir,
|
||||
uploadDir: path.join(mediaDir, 'uploads')
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
const filePath = resolveMediaFilePath(req.params.filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = require('path').join(mediaDir, filename);
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(mediaDir, { recursive: true });
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
res.json({ ok: true, filename: filename });
|
||||
res.json({ ok: true, filename: req.params.filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -115,11 +131,10 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
const filePath = resolveMediaFilePath(req.params.filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = require('path').join(mediaDir, filename);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
@@ -127,7 +142,7 @@ function registerPlayerRoutes(app, options) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, filename: filename });
|
||||
res.json({ ok: true, filename: req.params.filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -138,16 +153,25 @@ function registerPlayerRoutes(app, options) {
|
||||
const source = String(req.query.source || '').trim();
|
||||
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
|
||||
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
|
||||
const session = await rtmpStreamService.ensureSession(source, useMutedOutput);
|
||||
await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
|
||||
const session = status.session;
|
||||
const ready = Boolean(status.ready);
|
||||
const live = Boolean(status.live);
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
if (!ready || !live) {
|
||||
return res.status(503).json({
|
||||
ready: false,
|
||||
live: false,
|
||||
timedOut: Boolean(status.timedOut),
|
||||
stderr: String(status.stderr || '')
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
key: session.key,
|
||||
playlistUrl: session.playlistUrl,
|
||||
disableAudio: session.disableAudio,
|
||||
ready: true
|
||||
ready: true,
|
||||
live: true
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -192,6 +216,43 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = buildThumbnailPreviewData(slide);
|
||||
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||
return Object.assign({}, feed, {
|
||||
items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
})
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof common.fetchApiSourcesData === 'function') {
|
||||
const apiData = await common.fetchApiSourcesData(pool);
|
||||
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
function buildThumbnailPreviewData(slide) {
|
||||
return {
|
||||
thumbnailPreview: true,
|
||||
screen: {
|
||||
id: slide.id,
|
||||
name: slide.title,
|
||||
slug: 'slide-thumbnail-preview-' + slide.id,
|
||||
playlist_id: null
|
||||
},
|
||||
playlist: {
|
||||
fade_between_slides: false
|
||||
},
|
||||
slides: [slide],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
revision: String(slide.modified_at || slide.id || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
function createThumbnailPreviewBootstrapScript(initialData) {
|
||||
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildThumbnailPreviewData: buildThumbnailPreviewData,
|
||||
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
|
||||
};
|
||||
+87
-4
@@ -17,6 +17,7 @@ const registerAdminContentRoutes = require('./web/routes/admin/content');
|
||||
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
||||
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
||||
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
|
||||
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
|
||||
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||
const { createWebBootstrap } = require('./web/bootstrap');
|
||||
const { requirePermission } = require('./rbac');
|
||||
@@ -52,7 +53,11 @@ async function start() {
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.WEB_PORT || 3000);
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
|
||||
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
|
||||
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
||||
const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, '');
|
||||
const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||
pool: pool,
|
||||
maxConcurrent: 1
|
||||
@@ -83,7 +88,7 @@ async function start() {
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
uploadDir: MEDIA_DIR,
|
||||
uploadDir: UPLOADS_DIR,
|
||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
@@ -138,10 +143,29 @@ async function start() {
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: MEDIA_DIR,
|
||||
baseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/media', express.static(MEDIA_DIR));
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
@@ -153,7 +177,7 @@ async function start() {
|
||||
});
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout') {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -255,7 +279,7 @@ async function start() {
|
||||
common: common,
|
||||
pages: pages,
|
||||
upload: upload,
|
||||
uploadDir: MEDIA_DIR,
|
||||
uploadDir: UPLOADS_DIR,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
@@ -266,6 +290,7 @@ async function start() {
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||
@@ -340,7 +365,7 @@ async function start() {
|
||||
});
|
||||
|
||||
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||
await common.ensureSchema(pool);
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
@@ -381,7 +406,65 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
await syncRecurringRefreshes();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
||||
|
||||
@@ -600,22 +600,32 @@ function createBackgroundTaskQueue(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskSortTime(task) {
|
||||
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||
if (Number.isFinite(finishedTime)) {
|
||||
return finishedTime;
|
||||
}
|
||||
|
||||
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
if (Number.isFinite(createdTime)) {
|
||||
return createdTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const statusRank = {
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 2,
|
||||
completed: 3,
|
||||
canceled: 4
|
||||
};
|
||||
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
|
||||
return rightQueuedTime - leftQueuedTime;
|
||||
}
|
||||
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
|
||||
return Number.isFinite(leftQueuedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function buildQueryString(query) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.keys(query || {}).forEach(function (key) {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
||||
const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize));
|
||||
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
||||
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
||||
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
||||
const pages = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: buildQueryString(nextQuery)
|
||||
});
|
||||
}
|
||||
|
||||
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: Number(totalItems) || 0,
|
||||
hasMultiplePages: totalPages > 1,
|
||||
startItem: startIndex + 1,
|
||||
endItem: endIndex,
|
||||
hasPrevious: safeCurrentPage > 1,
|
||||
hasNext: safeCurrentPage < totalPages,
|
||||
previousUrl: buildQueryString(previousQuery),
|
||||
nextUrl: buildQueryString(nextQuery),
|
||||
pages: pages,
|
||||
pageSize: normalizedPageSize,
|
||||
pageParam: normalizedPageParam,
|
||||
itemLabel: String(itemLabel || 'items'),
|
||||
ariaLabel: String(ariaLabel || 'Pagination')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizePageNumber,
|
||||
buildQueryString,
|
||||
buildPagination
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
const { fetchPagedRows } = require('../../data/utils');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -27,6 +28,21 @@ async function fetchRoles(pool) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRolesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
ORDER BY r.name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ roles: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
@@ -106,6 +122,40 @@ async function fetchUsersWithRoles(pool) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
ORDER BY u.id ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
users: (paged.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
}),
|
||||
totalItems: paged.totalItems,
|
||||
totalPages: paged.totalPages,
|
||||
currentPage: paged.currentPage,
|
||||
pageSize: paged.pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
@@ -184,11 +234,13 @@ module.exports = {
|
||||
PERMISSIONS,
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
fetchRolesForUser,
|
||||
fetchUsersWithRoles,
|
||||
fetchUsersWithRolesPage,
|
||||
fetchUserWithRoles,
|
||||
syncUserRoles,
|
||||
syncRoleUsers,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const sharp = require('sharp');
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor
|
||||
} = require('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const PLAYER_VIEWPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
};
|
||||
const THUMBNAIL_MAX_SIZE = {
|
||||
width: 480,
|
||||
height: 270
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveAssetUrl(baseUrl, value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||
return raw;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return normalizedBaseUrl + raw;
|
||||
}
|
||||
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function getCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionContent(slide, region) {
|
||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||
if (!hasVisibleContent(renderedBody)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'rtmp') {
|
||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
function buildSlideHtml(slide, baseUrl) {
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111');
|
||||
const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : '';
|
||||
|
||||
if (!template) {
|
||||
const mediaPath = String(slide && slide.media_path || '').trim();
|
||||
const mediaUrl = resolveAssetUrl(baseUrl, mediaPath);
|
||||
const kind = mediaKind(mediaPath || slide.media_type || '');
|
||||
const mediaMarkup = kind === 'image' && mediaUrl
|
||||
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
|
||||
: '';
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) {
|
||||
return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
}) : [];
|
||||
|
||||
const regionsHtml = regions.map(function (region) {
|
||||
const content = getRegionContent(slide, region);
|
||||
const left = Math.max(0, Number(region.x || 0));
|
||||
const top = Math.max(0, Number(region.y || 0));
|
||||
const width = Math.max(1, Number(region.width || 1));
|
||||
const height = Math.max(1, Number(region.height || 1));
|
||||
const regionType = String(region.region_type || 'text').toLowerCase();
|
||||
const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
||||
const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
||||
const innerHtml = regionType === 'text'
|
||||
? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content)
|
||||
: buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl);
|
||||
|
||||
if (!innerHtml) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
|
||||
innerHtml,
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
|
||||
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
|
||||
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
|
||||
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
|
||||
regionsHtml,
|
||||
' </div>',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
if (usingSystemChromium) {
|
||||
return puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
],
|
||||
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||
executablePath: executablePath,
|
||||
headless: true
|
||||
});
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: 'shell'
|
||||
});
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||
const slideId = Number(options && options.slideId || 0);
|
||||
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||
|
||||
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath,
|
||||
body: {}
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true });
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
await page.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.png()
|
||||
.toFile(thumbnailTempPath);
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
slideId: slide.id,
|
||||
thumbnailPath: thumbnailPath,
|
||||
filePath: filePath,
|
||||
fullSizePath: fullSizePath,
|
||||
mediaKind: mediaKind(slide.media_path || '')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
};
|
||||
+45
-13
@@ -51,6 +51,32 @@ function createUploadSyncService(options) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUploadRelativePath(uploadPath) {
|
||||
const value = normalizeUploadReference(uploadPath);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.replace(/^\/media\//, '');
|
||||
}
|
||||
|
||||
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relativePath.startsWith('uploads/')) {
|
||||
return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length));
|
||||
}
|
||||
|
||||
return path.join(path.dirname(normalizedUploadDir), relativePath);
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromValue(value, refs) {
|
||||
if (!value) {
|
||||
return refs;
|
||||
@@ -134,7 +160,7 @@ function createUploadSyncService(options) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, path.basename(uploadPath));
|
||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
@@ -174,7 +200,7 @@ function createUploadSyncService(options) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
|
||||
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
@@ -232,8 +258,11 @@ function createUploadSyncService(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
const sourcePath = path.join(localUploadDir, filename);
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||
if (!relativePath || !sourcePath) {
|
||||
return false;
|
||||
}
|
||||
let fileBuffer = null;
|
||||
try {
|
||||
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||
@@ -247,10 +276,10 @@ function createUploadSyncService(options) {
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`,
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -259,12 +288,12 @@ function createUploadSyncService(options) {
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText);
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', filename, error);
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -274,13 +303,16 @@ function createUploadSyncService(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -288,12 +320,12 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText);
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', filename, error);
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,32 @@
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.card-header .card-tools > .btn,
|
||||
.card-header .card-tools > a.btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-header .card-tools .input-group {
|
||||
flex: 1 1 11rem;
|
||||
min-width: 8rem;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.background-tasks-task-tools {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.background-tasks-task-search {
|
||||
flex: 0 1 12rem;
|
||||
min-width: 9rem;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.background-tasks-task-status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-form-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1182,6 +1208,149 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-modal-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding-bottom: 0.25rem;
|
||||
background: var(--bs-modal-bg, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
.playlist-slide-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
max-height: min(64vh, 48rem);
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0.5rem 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card {
|
||||
appearance: none;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1.1rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card:hover,
|
||||
.playlist-slide-picker-card:focus-visible {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
transform: translateY(-1px);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-selected {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned,
|
||||
.playlist-slide-picker-card:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 1.05rem 1.05rem 0 0;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 100%),
|
||||
repeating-linear-gradient(45deg, rgba(108, 117, 125, 0.08), rgba(108, 117, 125, 0.08) 12px, rgba(108, 117, 125, 0.04) 12px, rgba(108, 117, 125, 0.04) 24px);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-thumb-placeholder-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-thumb-placeholder-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
min-height: 0;
|
||||
font-size: 0.98rem;
|
||||
padding: 0 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-check {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1;
|
||||
color: var(--bs-primary);
|
||||
opacity: 0;
|
||||
background: rgba(var(--bs-body-bg-rgb), 0.88);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.3rem;
|
||||
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-selected .playlist-slide-picker-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-empty.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
@@ -1242,10 +1411,60 @@ td[data-label="Actions"] > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playlist-duration-input {
|
||||
width: 6rem;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
table.table > :not(caption) > * > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.playlist-slide-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-slide-cell-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-slide-title {
|
||||
display: block;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.playlist-slide-thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 5.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-slide-thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-slide-thumb-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.playlist-order-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -434,6 +434,11 @@
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
|
||||
@@ -234,6 +234,12 @@
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(targetElement);
|
||||
}
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(targetElement);
|
||||
}
|
||||
if (typeof window.initTablePaginations === 'function') {
|
||||
window.initTablePaginations(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
@@ -434,6 +440,11 @@
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
@@ -577,6 +588,332 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initTablePaginations(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
|
||||
if (container.getAttribute('data-table-pagination-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var table = container.querySelector('[data-table-searchable]');
|
||||
var cardHeader = container.querySelector('.card-header');
|
||||
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
|
||||
var footer = container.querySelector('[data-table-pagination-controls]');
|
||||
var footerSummary = null;
|
||||
var footerList = null;
|
||||
var syncFrame = 0;
|
||||
var state = {
|
||||
currentPage: 1,
|
||||
pageSize: 1,
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
if (!table || !cardBody || !table.tBodies.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.setAttribute('data-table-pagination-bound', 'true');
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||
}
|
||||
|
||||
function isSearchMatched(row) {
|
||||
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
|
||||
}
|
||||
|
||||
function ensureFooter() {
|
||||
if (footer) {
|
||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||
return footer;
|
||||
}
|
||||
|
||||
footer = document.createElement('div');
|
||||
footer.className = 'card-footer d-none';
|
||||
footer.setAttribute('data-table-pagination-controls', 'true');
|
||||
footer.innerHTML = '' +
|
||||
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
|
||||
'<div class="text-muted small" data-table-pagination-summary></div>' +
|
||||
'<nav aria-label="Table pages">' +
|
||||
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
|
||||
'</nav>' +
|
||||
'</div>';
|
||||
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
|
||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||
return footer;
|
||||
}
|
||||
|
||||
function setRowVisible(row, visible) {
|
||||
row.hidden = !visible;
|
||||
row.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function getAvailableHeight() {
|
||||
var containerRect = container.getBoundingClientRect();
|
||||
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
|
||||
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
|
||||
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
|
||||
}
|
||||
|
||||
function renderFooter(totalItems) {
|
||||
var pageButtons = [];
|
||||
|
||||
ensureFooter();
|
||||
|
||||
if (state.totalPages <= 1 || totalItems <= 0) {
|
||||
footer.classList.add('d-none');
|
||||
footerSummary.textContent = '';
|
||||
footerList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
footer.classList.remove('d-none');
|
||||
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
|
||||
|
||||
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
|
||||
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
|
||||
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
|
||||
}
|
||||
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
|
||||
footerList.innerHTML = pageButtons.join('');
|
||||
}
|
||||
|
||||
function applyPagination(resetPage) {
|
||||
var rows = getRows();
|
||||
var matchedRows = rows.filter(isSearchMatched);
|
||||
var totalItems = matchedRows.length;
|
||||
var rowHeight = 48;
|
||||
var pageSize;
|
||||
var startIndex;
|
||||
var endIndex;
|
||||
|
||||
if (resetPage) {
|
||||
state.currentPage = 1;
|
||||
}
|
||||
|
||||
rows.forEach(function (row) {
|
||||
if (!isSearchMatched(row)) {
|
||||
setRowVisible(row, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (!totalItems) {
|
||||
state.pageSize = 1;
|
||||
state.totalPages = 1;
|
||||
state.currentPage = 1;
|
||||
renderFooter(0);
|
||||
return;
|
||||
}
|
||||
|
||||
matchedRows.forEach(function (row) {
|
||||
setRowVisible(row, true);
|
||||
});
|
||||
|
||||
if (matchedRows[0]) {
|
||||
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
|
||||
}
|
||||
|
||||
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
|
||||
|
||||
if (pageSize >= totalItems) {
|
||||
state.pageSize = totalItems;
|
||||
state.totalPages = 1;
|
||||
state.currentPage = 1;
|
||||
matchedRows.forEach(function (row) {
|
||||
setRowVisible(row, true);
|
||||
});
|
||||
renderFooter(totalItems);
|
||||
return;
|
||||
}
|
||||
|
||||
state.pageSize = pageSize;
|
||||
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
|
||||
startIndex = (state.currentPage - 1) * pageSize;
|
||||
endIndex = startIndex + pageSize;
|
||||
|
||||
matchedRows.forEach(function (row, index) {
|
||||
setRowVisible(row, index >= startIndex && index < endIndex);
|
||||
});
|
||||
|
||||
renderFooter(totalItems);
|
||||
}
|
||||
|
||||
function scheduleSync(options) {
|
||||
if (syncFrame) {
|
||||
window.cancelAnimationFrame(syncFrame);
|
||||
}
|
||||
|
||||
syncFrame = window.requestAnimationFrame(function () {
|
||||
syncFrame = 0;
|
||||
applyPagination(Boolean(options && options.resetPage));
|
||||
});
|
||||
}
|
||||
|
||||
ensureFooter();
|
||||
|
||||
if (footer) {
|
||||
footer.addEventListener('click', function (event) {
|
||||
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
|
||||
var pageValue;
|
||||
|
||||
if (!target || target.closest('.disabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
|
||||
state.currentPage = pageValue;
|
||||
scheduleSync();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
scheduleSync();
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
try {
|
||||
new ResizeObserver(function () {
|
||||
scheduleSync();
|
||||
}).observe(container);
|
||||
} catch (_error) {
|
||||
// Ignore observer setup failures and rely on window resize.
|
||||
}
|
||||
}
|
||||
|
||||
container.syncTablePagination = scheduleSync;
|
||||
scheduleSync({ resetPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
function initTableSearches(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
||||
if (input.getAttribute('data-table-search-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
|
||||
var table = container.querySelector('[data-table-searchable]');
|
||||
var emptyRow = null;
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.setAttribute('data-table-search-bound', 'true');
|
||||
|
||||
function getSearchableRows() {
|
||||
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||
}
|
||||
|
||||
function getDefaultEmptyRow() {
|
||||
return table.querySelector('tbody tr[data-table-search-empty-default]');
|
||||
}
|
||||
|
||||
function removeGeneratedEmptyRow() {
|
||||
if (emptyRow && emptyRow.parentNode) {
|
||||
emptyRow.parentNode.removeChild(emptyRow);
|
||||
}
|
||||
emptyRow = null;
|
||||
}
|
||||
|
||||
function ensureGeneratedEmptyRow(message) {
|
||||
var tbody = table.tBodies[0] || table.querySelector('tbody');
|
||||
var columnCount = 1;
|
||||
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emptyRow) {
|
||||
emptyRow = document.createElement('tr');
|
||||
emptyRow.setAttribute('data-table-search-empty-row', 'true');
|
||||
var cell = document.createElement('td');
|
||||
cell.className = 'empty';
|
||||
cell.setAttribute('data-table-search-empty-cell', 'true');
|
||||
emptyRow.appendChild(cell);
|
||||
}
|
||||
|
||||
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
|
||||
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
|
||||
emptyRow.firstElementChild.textContent = message;
|
||||
|
||||
if (!emptyRow.parentNode) {
|
||||
tbody.appendChild(emptyRow);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSearch() {
|
||||
var query = String(input.value || '').trim().toLowerCase();
|
||||
var rows = getSearchableRows();
|
||||
var visibleCount = 0;
|
||||
var defaultEmptyRow = getDefaultEmptyRow();
|
||||
|
||||
removeGeneratedEmptyRow();
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
|
||||
var visible = !query || haystack.indexOf(query) !== -1;
|
||||
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
|
||||
row.classList.toggle('d-none', !visible);
|
||||
row.hidden = !visible;
|
||||
if (visible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (defaultEmptyRow) {
|
||||
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
|
||||
defaultEmptyRow.hidden = Boolean(query);
|
||||
}
|
||||
|
||||
if (query && visibleCount === 0) {
|
||||
ensureGeneratedEmptyRow('No results match your search.');
|
||||
}
|
||||
|
||||
if (typeof window.syncTablePagination === 'function') {
|
||||
window.syncTablePagination(container, { resetPage: true });
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', syncSearch);
|
||||
syncSearch();
|
||||
});
|
||||
}
|
||||
|
||||
function createSlideThumbPlaceholder() {
|
||||
var placeholder = document.createElement('span');
|
||||
var icon = document.createElement('i');
|
||||
|
||||
placeholder.className = 'playlist-slide-thumb-placeholder';
|
||||
icon.className = 'bi bi-image';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
placeholder.appendChild(icon);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function attachSlideThumbFallbacks(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('.playlist-slide-thumb-image'), function (image) {
|
||||
if (image.getAttribute('data-slide-thumb-fallback-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
image.setAttribute('data-slide-thumb-fallback-bound', 'true');
|
||||
image.addEventListener('error', function () {
|
||||
if (image.parentNode) {
|
||||
image.parentNode.replaceChild(createSlideThumbPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
@@ -589,6 +926,20 @@
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
initTableSearches();
|
||||
initTablePaginations();
|
||||
attachSlideThumbFallbacks(document);
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
window.initTableSearches = initTableSearches;
|
||||
window.initTablePaginations = initTablePaginations;
|
||||
window.syncTablePagination = function (container, options) {
|
||||
if (!container || !container.syncTablePagination) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.syncTablePagination(options);
|
||||
};
|
||||
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
(function () {
|
||||
function getOrCreate(modalElement) {
|
||||
if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) {
|
||||
return null;
|
||||
}
|
||||
return window.bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||
}
|
||||
|
||||
function show(modalElement) {
|
||||
var modal = getOrCreate(modalElement);
|
||||
if (modal) {
|
||||
modal.show();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hide(modalElement) {
|
||||
var modal = getOrCreate(modalElement);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.pulseModal = {
|
||||
getOrCreate: getOrCreate,
|
||||
show: show,
|
||||
hide: hide
|
||||
};
|
||||
}());
|
||||
@@ -305,16 +305,239 @@
|
||||
function initPlaylistEditStaging() {
|
||||
var tbody = document.getElementById('playlist-items-body');
|
||||
var form = document.getElementById('playlist-edit-form');
|
||||
var addSection = document.getElementById('playlist-add-section');
|
||||
var addSlideForm = document.getElementById('playlist-add-slide-form');
|
||||
var addSlideButton = document.getElementById('playlist-add-slide-button');
|
||||
var addSlideSelect = document.getElementById('playlist-add-slide-select');
|
||||
var addSlideEmpty = document.getElementById('playlist-add-slide-empty');
|
||||
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 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 slidePickerCards = [];
|
||||
var slidePickerSelection = new Set();
|
||||
var slidePickerSlides = [];
|
||||
|
||||
if (!tbody || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addSlideData) {
|
||||
try {
|
||||
var parsedSlides = JSON.parse(addSlideData.textContent || '[]');
|
||||
slidePickerSlides = Array.isArray(parsedSlides) ? parsedSlides : [];
|
||||
} catch (_error) {
|
||||
slidePickerSlides = [];
|
||||
}
|
||||
}
|
||||
|
||||
function getModalInstance() {
|
||||
if (!window.pulseModal) {
|
||||
return null;
|
||||
}
|
||||
return window.pulseModal.getOrCreate(addSlideModal);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
function createPickerThumb(slide) {
|
||||
function createPlaceholder() {
|
||||
var placeholder = document.createElement('div');
|
||||
var icon = document.createElement('i');
|
||||
var label = document.createElement('span');
|
||||
|
||||
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
|
||||
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
label.className = 'playlist-slide-picker-thumb-placeholder-label';
|
||||
label.textContent = 'No thumbnail';
|
||||
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(label);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
if (slide && slide.thumbnail_path) {
|
||||
var image = document.createElement('img');
|
||||
image.className = '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(createPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
return createPlaceholder();
|
||||
}
|
||||
|
||||
function createPickerCard(slide) {
|
||||
var button = document.createElement('button');
|
||||
var media = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
var check = document.createElement('span');
|
||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||
|
||||
button.type = 'button';
|
||||
button.className = 'playlist-slide-picker-card';
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||
button.setAttribute('data-search-text', searchText);
|
||||
|
||||
if (slide && slide.isAssigned) {
|
||||
button.disabled = true;
|
||||
button.classList.add('is-assigned');
|
||||
}
|
||||
|
||||
media.className = 'playlist-slide-picker-media';
|
||||
media.appendChild(createPickerThumb(slide));
|
||||
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(check);
|
||||
|
||||
title.className = 'playlist-slide-picker-title';
|
||||
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||
|
||||
button.appendChild(media);
|
||||
button.appendChild(title);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderSlidePicker() {
|
||||
if (!addSlideGrid) {
|
||||
return;
|
||||
}
|
||||
addSlideGrid.innerHTML = '';
|
||||
slidePickerCards = slidePickerSlides.map(function (slide) {
|
||||
var card = createPickerCard(slide);
|
||||
addSlideGrid.appendChild(card);
|
||||
return card;
|
||||
});
|
||||
syncSlidePickerState();
|
||||
}
|
||||
|
||||
function syncSlidePickerState() {
|
||||
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
||||
var visibleCount = 0;
|
||||
|
||||
slidePickerCards.forEach(function (card) {
|
||||
var searchText = String(card.getAttribute('data-search-text') || '');
|
||||
var matches = !query || searchText.indexOf(query) !== -1;
|
||||
setCardVisible(card, matches);
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (addSlideEmpty) {
|
||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||
}
|
||||
|
||||
if (addSlideCount) {
|
||||
addSlideCount.textContent = String(slidePickerSelection.size);
|
||||
}
|
||||
|
||||
if (addSlideConfirm) {
|
||||
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCardSelection(card) {
|
||||
var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : '');
|
||||
var selected;
|
||||
|
||||
if (!slideId || card.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selected = !slidePickerSelection.has(slideId);
|
||||
if (selected) {
|
||||
slidePickerSelection.add(slideId);
|
||||
} else {
|
||||
slidePickerSelection.delete(slideId);
|
||||
}
|
||||
setCardSelected(card, selected);
|
||||
syncSlidePickerState();
|
||||
}
|
||||
|
||||
function addSelectedSlides() {
|
||||
var selectedSlides = slidePickerSlides.filter(function (slide) {
|
||||
return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned;
|
||||
});
|
||||
|
||||
if (!selectedSlides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tbody.querySelector('.playlist-empty-row')) {
|
||||
tbody.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_signature: String(slide.canvasSignature || ''),
|
||||
title: slide.title || 'Slide',
|
||||
duration_seconds: 10,
|
||||
schedule_mode: 'always',
|
||||
schedule_start_datetime: '',
|
||||
schedule_end_datetime: '',
|
||||
schedule_start_time: '',
|
||||
schedule_end_time: '',
|
||||
schedule_days_json: '[]',
|
||||
summary: 'Always visible'
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
attachSlideThumbFallbacks(tbody);
|
||||
|
||||
updateRowOrder(true);
|
||||
|
||||
if (!window.pulseModal || !window.pulseModal.hide(addSlideModal)) {
|
||||
addSlideModal.classList.remove('show');
|
||||
addSlideModal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
attachSlideThumbFallbacks(tbody);
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]'));
|
||||
}
|
||||
@@ -375,9 +598,6 @@
|
||||
}
|
||||
|
||||
function syncAddSlideOptions() {
|
||||
if (!addSlideSelect) {
|
||||
return;
|
||||
}
|
||||
var activeSlideIds = {};
|
||||
var activeCanvasSignatures = {};
|
||||
getRows().forEach(function (row) {
|
||||
@@ -397,51 +617,37 @@
|
||||
allowedCanvasSignature = canvasKeys[0];
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
||||
if (!option.value) {
|
||||
return;
|
||||
slidePickerSlides.forEach(function (slide) {
|
||||
var slideId = String(slide.id || '');
|
||||
var card = slidePickerCards.find(function (item) {
|
||||
return String(item.getAttribute('data-slide-id') || '') === slideId;
|
||||
});
|
||||
var canvasSignature = String(slide.canvasSignature || '');
|
||||
var isAssigned = Boolean(activeSlideIds[slideId]);
|
||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
|
||||
|
||||
if (card) {
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
card.classList.toggle('is-hidden', false);
|
||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||
}
|
||||
var optionCanvasSignature = String(option.getAttribute('data-canvas-signature') || '');
|
||||
var isActive = Boolean(activeSlideIds[String(option.value)]);
|
||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && optionCanvasSignature && optionCanvasSignature !== allowedCanvasSignature);
|
||||
option.disabled = isActive || isCanvasMismatch;
|
||||
option.hidden = isActive || isCanvasMismatch;
|
||||
});
|
||||
if (
|
||||
addSlideSelect.options[addSlideSelect.selectedIndex]
|
||||
&& (addSlideSelect.options[addSlideSelect.selectedIndex].disabled || addSlideSelect.options[addSlideSelect.selectedIndex].hidden)
|
||||
) {
|
||||
for (var i = 0; i < addSlideSelect.options.length; i += 1) {
|
||||
if (!addSlideSelect.options[i].disabled && !addSlideSelect.options[i].hidden) {
|
||||
addSlideSelect.selectedIndex = i;
|
||||
break;
|
||||
if (isAssigned) {
|
||||
slidePickerSelection.delete(slideId);
|
||||
if (card) {
|
||||
setCardSelected(card, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var availableCount = 0;
|
||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
||||
if (!option.value) {
|
||||
return;
|
||||
}
|
||||
if (!option.disabled && !option.hidden) {
|
||||
availableCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (addSlideButton) {
|
||||
addSlideButton.disabled = availableCount === 0;
|
||||
}
|
||||
if (addSlideSelect) {
|
||||
addSlideSelect.disabled = availableCount === 0;
|
||||
}
|
||||
if (addSlideEmpty) {
|
||||
addSlideEmpty.classList.toggle('is-hidden', availableCount !== 0);
|
||||
}
|
||||
if (addSection && addSlideForm && addSlideSelect && addSlideButton) {
|
||||
var controlsVisible = availableCount > 0;
|
||||
addSlideForm.classList.toggle('is-hidden', !controlsVisible);
|
||||
if (addSlideOpenButton) {
|
||||
addSlideOpenButton.disabled = slidePickerCards.filter(function (card) {
|
||||
return !card.disabled;
|
||||
}).length === 0;
|
||||
}
|
||||
|
||||
syncSlidePickerState();
|
||||
}
|
||||
|
||||
function updateRowOrder(markDirty) {
|
||||
@@ -530,6 +736,9 @@
|
||||
var row = document.createElement('tr');
|
||||
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
var playlistId = tbody.getAttribute('data-playlist-id') || '';
|
||||
var thumbnailMarkup = values.thumbnail_path
|
||||
? '<img class="playlist-slide-thumb-image" src="' + values.thumbnail_path + '" alt="" loading="lazy" />'
|
||||
: '<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>';
|
||||
row.setAttribute('data-playlist-slide-row', '');
|
||||
row.setAttribute('data-row-key', rowKey);
|
||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||
@@ -541,7 +750,12 @@
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true">' + 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" /></td>' +
|
||||
'<td>' +
|
||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||
@@ -551,7 +765,7 @@
|
||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||
'</td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
@@ -609,32 +823,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (addSlideButton && addSlideSelect) {
|
||||
addSlideButton.addEventListener('click', function () {
|
||||
var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex];
|
||||
if (!selectedOption || selectedOption.disabled) {
|
||||
if (addSlideModal && addSlideGrid) {
|
||||
renderSlidePicker();
|
||||
|
||||
addSlideGrid.addEventListener('click', function (event) {
|
||||
var card = event.target.closest('.playlist-slide-picker-card');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
var placeholder = tbody.querySelector('.playlist-empty-row');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
var row = createRow({
|
||||
row_key: 'new-' + Date.now(),
|
||||
slide_id: String(selectedOption.value),
|
||||
canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''),
|
||||
title: selectedOption.textContent || 'Slide',
|
||||
duration_seconds: 10,
|
||||
schedule_mode: 'always',
|
||||
schedule_start_datetime: '',
|
||||
schedule_end_datetime: '',
|
||||
schedule_start_time: '',
|
||||
schedule_end_time: '',
|
||||
schedule_days_json: '[]',
|
||||
summary: 'Always visible'
|
||||
event.preventDefault();
|
||||
toggleCardSelection(card);
|
||||
});
|
||||
|
||||
if (addSlideSearch) {
|
||||
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
||||
}
|
||||
|
||||
if (addSlideConfirm) {
|
||||
addSlideConfirm.addEventListener('click', function () {
|
||||
addSelectedSlides();
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
updateRowOrder(true);
|
||||
}
|
||||
|
||||
addSlideModal.addEventListener('shown.bs.modal', function () {
|
||||
syncSlidePickerState();
|
||||
if (addSlideSearch) {
|
||||
addSlideSearch.focus();
|
||||
}
|
||||
});
|
||||
|
||||
addSlideModal.addEventListener('hidden.bs.modal', function () {
|
||||
clearModalSelection();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,133 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function getPageUrl(href) {
|
||||
try {
|
||||
return new URL(href, window.location.href);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalDateTimes(root) {
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(root || document);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSectionFromDocument(sectionName, responseDocument) {
|
||||
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||
|
||||
if (!currentCard || !nextCard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentCard.outerHTML = nextCard.outerHTML;
|
||||
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceSummaryFromDocument(responseDocument) {
|
||||
var currentSummary = document.getElementById('background-tasks-summary');
|
||||
var nextSummary = responseDocument.getElementById('background-tasks-summary');
|
||||
|
||||
if (!currentSummary || !nextSummary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentSummary.outerHTML = nextSummary.outerHTML;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceFiltersFromDocument(responseDocument) {
|
||||
var currentFilters = document.getElementById('background-tasks-filters');
|
||||
var nextFilters = responseDocument.getElementById('background-tasks-filters');
|
||||
|
||||
if (!currentFilters || !nextFilters) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentFilters.outerHTML = nextFilters.outerHTML;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceStateFromDocument(responseDocument) {
|
||||
var currentState = document.getElementById('background-tasks-state');
|
||||
var nextState = responseDocument.getElementById('background-tasks-state');
|
||||
|
||||
if (!currentState || !nextState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentState.outerHTML = nextState.outerHTML;
|
||||
stateNode = document.getElementById('background-tasks-state');
|
||||
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPageResponse(responseDocument, sectionNames) {
|
||||
var updated = false;
|
||||
|
||||
if (replaceStateFromDocument(responseDocument)) {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (replaceSummaryFromDocument(responseDocument)) {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (replaceFiltersFromDocument(responseDocument)) {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
sectionNames.forEach(function (sectionName) {
|
||||
if (replaceSectionFromDocument(sectionName, responseDocument)) {
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function loadPage(nextUrl, options) {
|
||||
var url = getPageUrl(nextUrl);
|
||||
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
|
||||
var replaceHistory = Boolean(options && options.replaceHistory);
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/html, application/xhtml+xml',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load background tasks page.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (replaceHistory) {
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
} else {
|
||||
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
}).catch(function (_error) {
|
||||
window.location.assign(url.pathname + url.search + url.hash);
|
||||
});
|
||||
}
|
||||
|
||||
function stripMessageParameter() {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
@@ -21,6 +148,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
function findPaginationLink(target) {
|
||||
if (!target || !target.closest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var link = target.closest('.pagination .page-link');
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var href = String(link.getAttribute('href') || '').trim();
|
||||
if (!href || href === '#') {
|
||||
return null;
|
||||
}
|
||||
|
||||
var card = link.closest('[data-pagination-card]');
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
href: href,
|
||||
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function findFilterLink(target) {
|
||||
if (!target || !target.closest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var link = target.closest('a[data-background-tasks-nav]');
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var href = String(link.getAttribute('href') || '').trim();
|
||||
if (!href || href === '#') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
href: href
|
||||
};
|
||||
}
|
||||
|
||||
function initPaginationNavigation() {
|
||||
document.addEventListener('click', function (event) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
var paginationLink = findPaginationLink(event.target);
|
||||
if (paginationLink && paginationLink.sectionName) {
|
||||
var currentUrl = getPageUrl(window.location.href);
|
||||
var nextUrl = getPageUrl(paginationLink.href);
|
||||
if (!currentUrl || !nextUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
||||
return;
|
||||
}
|
||||
|
||||
var filterLink = findFilterLink(event.target);
|
||||
if (!filterLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
var filterUrl = getPageUrl(filterLink.href);
|
||||
if (!filterUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterUrl.pathname === window.location.pathname && filterUrl.search === window.location.search && filterUrl.hash === window.location.hash) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
loadPage(filterUrl.toString(), { sectionNames: ['tasks'] });
|
||||
}, true);
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
loadPage(window.location.href, { replaceHistory: true });
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPage() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
@@ -59,5 +280,6 @@
|
||||
}
|
||||
|
||||
stripMessageParameter();
|
||||
initPaginationNavigation();
|
||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
||||
}());
|
||||
@@ -543,7 +543,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function renderPreviewHtmlRegion(value) {
|
||||
@@ -551,7 +551,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!html) {
|
||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager"></iframe>';
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
|
||||
@@ -67,6 +67,34 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var sidebarTopOffset = 16;
|
||||
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
|
||||
|
||||
function applyBackdropStyle(element, backgroundColor, backgroundImagePath) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
element.style.backgroundColor = color;
|
||||
element.style.backgroundImage = backgroundImagePath
|
||||
? 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), url("' + encodeURI(String(backgroundImagePath)) + '")'
|
||||
: 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||
element.style.backgroundPosition = backgroundImagePath ? 'center, center' : 'center';
|
||||
element.style.backgroundSize = backgroundImagePath ? '100% 100%, cover' : '100% 100%';
|
||||
element.style.backgroundRepeat = backgroundImagePath ? 'no-repeat, no-repeat' : 'no-repeat';
|
||||
}
|
||||
|
||||
function applyPopupBackdropStyle(element, backgroundColor) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
element.style.backgroundColor = color;
|
||||
element.style.backgroundImage = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||
element.style.backgroundPosition = 'center';
|
||||
element.style.backgroundSize = '100% 100%';
|
||||
element.style.backgroundRepeat = 'no-repeat';
|
||||
}
|
||||
|
||||
class FontSizeInputUI extends Plugin {
|
||||
static get pluginName() {
|
||||
return 'FontSizeInputUI';
|
||||
@@ -408,7 +436,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function renderPreviewRtmpRegion(value, disableAudio) {
|
||||
@@ -425,7 +453,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!html) {
|
||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager"></iframe>';
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
@@ -814,7 +842,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
popupStage.style.backgroundColor = '#111111';
|
||||
applyBackdropStyle(popupCanvas, '#111111');
|
||||
popupCanvas.style.width = '';
|
||||
popupCanvas.style.height = '';
|
||||
popupCanvas.style.transform = '';
|
||||
@@ -837,7 +865,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
popupStage.style.backgroundColor = template.background_color || '#111111';
|
||||
applyBackdropStyle(popupCanvas, template.background_color || '#111111', template.background_image_path);
|
||||
popupCanvas.style.width = canvasWidth + 'px';
|
||||
popupCanvas.style.height = canvasHeight + 'px';
|
||||
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
|
||||
@@ -949,7 +977,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
slidePreviewOverlay.style.height = '';
|
||||
slidePreviewOverlay.style.transform = '';
|
||||
slidePreviewStage.style.aspectRatio = '16 / 9';
|
||||
slidePreviewStage.style.backgroundColor = '#111111';
|
||||
applyBackdropStyle(slidePreviewStage, '#111111');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -959,7 +987,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
currentPreviewCanvasWidth = canvasWidth;
|
||||
currentPreviewCanvasHeight = canvasHeight;
|
||||
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
||||
slidePreviewStage.style.backgroundColor = template.background_color || '#111111';
|
||||
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path);
|
||||
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
|
||||
if (!(template.regions || []).length) {
|
||||
slidePreviewEmpty.style.display = 'flex';
|
||||
|
||||
@@ -528,10 +528,10 @@
|
||||
}
|
||||
|
||||
function openAddRegionModal() {
|
||||
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
|
||||
if (!window.pulseModal) {
|
||||
return;
|
||||
}
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
|
||||
window.pulseModal.show(regionAddModal);
|
||||
}
|
||||
|
||||
function createDefaultRegion(type) {
|
||||
@@ -701,8 +701,8 @@
|
||||
button.addEventListener('click', function () {
|
||||
var regionType = button.getAttribute('data-add-region-type');
|
||||
addRegion(createDefaultRegion(regionType));
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
|
||||
if (window.pulseModal) {
|
||||
window.pulseModal.hide(regionAddModal);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,15 +13,37 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
|
||||
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
|
||||
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
|
||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: 'slide-thumbnail:' + Number(slideId),
|
||||
title: 'Refresh slide thumbnail',
|
||||
category: 'slides',
|
||||
taskType: 'slide-thumbnail-refresh',
|
||||
payload: {
|
||||
slideId: Number(slideId),
|
||||
previousThumbnailPath: String(previousThumbnailPath || '').trim()
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
async function canvasSizeExists(width, height, ignoreId) {
|
||||
const params = [width, height];
|
||||
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
|
||||
@@ -58,8 +80,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderSlidesPage({
|
||||
slides: data.slides || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -136,6 +162,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
localUploadDir: deps.uploadDir,
|
||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||
});
|
||||
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
@@ -176,6 +205,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
@@ -218,8 +250,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderTemplatesPage({
|
||||
templates: data.templates || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -355,8 +391,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderCanvasSizesPage({
|
||||
canvasSizes: data.canvasSizes || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
@@ -195,8 +198,21 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchApiSourcesData(pool);
|
||||
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE);
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : ''
|
||||
});
|
||||
});
|
||||
res.send(pages.renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -364,8 +380,12 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchRssFeedsData(pool);
|
||||
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderRssFeedsPage({
|
||||
rssFeeds: data.rssFeeds || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
@@ -83,7 +84,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
@@ -99,6 +100,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
@@ -220,7 +222,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]);
|
||||
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
|
||||
@@ -7,8 +7,11 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
@@ -158,8 +161,12 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
|
||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderRbacPage({
|
||||
roles: data.roles || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
@@ -53,8 +56,9 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
|
||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE);
|
||||
const mappedUsers = (data.users || []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
@@ -62,7 +66,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
res.send(pages.renderUsersPage({
|
||||
users: mappedUsers,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ module.exports = function renderApiSourcesPage(data, message, currentUser, forma
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSources: apiSources
|
||||
apiSources: apiSources,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -27,6 +27,7 @@ module.exports = function renderRssFeedsPage(data, message, currentUser) {
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeeds: rssFeeds
|
||||
rssFeeds: rssFeeds,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
|
||||
const TASKS_PER_PAGE = 10;
|
||||
const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']);
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
@@ -21,7 +22,40 @@ function parsePageNumber(value) {
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam) {
|
||||
function normalizeStatusFilter(value) {
|
||||
const status = String(value || '').trim().toLowerCase();
|
||||
return TASK_STATUSES.has(status) ? status : '';
|
||||
}
|
||||
|
||||
function normalizeSourceFilter(sourceType, sourceId) {
|
||||
const normalizedSourceType = String(sourceType || '').trim();
|
||||
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||
|
||||
if (!normalizedSourceType || !Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||||
return { sourceType: '', sourceId: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
sourceType: normalizedSourceType,
|
||||
sourceId: normalizedSourceId
|
||||
};
|
||||
}
|
||||
|
||||
function buildQueryString(query) {
|
||||
const searchParams = new URLSearchParams();
|
||||
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
|
||||
const value = query && query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
|
||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
@@ -30,13 +64,17 @@ function buildPagination(totalItems, currentPage, pageParam) {
|
||||
const pages = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: `?${normalizedPageParam}=${pageNumber}`
|
||||
url: buildQueryString(nextQuery)
|
||||
});
|
||||
}
|
||||
|
||||
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
@@ -46,23 +84,139 @@ function buildPagination(totalItems, currentPage, pageParam) {
|
||||
endItem: endIndex,
|
||||
hasPrevious: safeCurrentPage > 1,
|
||||
hasNext: safeCurrentPage < totalPages,
|
||||
previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`,
|
||||
nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`,
|
||||
previousUrl: buildQueryString(previousQuery),
|
||||
nextUrl: buildQueryString(nextQuery),
|
||||
pages: pages,
|
||||
pageSize: TASKS_PER_PAGE,
|
||||
pageParam: normalizedPageParam
|
||||
};
|
||||
}
|
||||
|
||||
function taskMatchesFilters(task, statusFilter, sourceFilter) {
|
||||
if (statusFilter && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadata = task.metadata || {};
|
||||
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status) {
|
||||
const normalizedStatus = String(status || '').trim().toLowerCase();
|
||||
if (!normalizedStatus) {
|
||||
return 'All tasks';
|
||||
}
|
||||
|
||||
return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1);
|
||||
}
|
||||
|
||||
function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
const metadata = task.metadata || {};
|
||||
const sourceType = String(metadata.sourceType || '').trim();
|
||||
const sourceId = Math.floor(Number(metadata.sourceId) || 0);
|
||||
|
||||
if (!sourceType || !Number.isFinite(sourceId) || sourceId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isActive = sourceFilter && sourceFilter.sourceType === sourceType && sourceFilter.sourceId === sourceId;
|
||||
const nextQuery = Object.assign({}, queryState, {
|
||||
page: 1,
|
||||
sourceType: isActive ? '' : sourceType,
|
||||
sourceId: isActive ? '' : sourceId
|
||||
});
|
||||
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
function buildStatusFilterUrl(queryState, statusFilter) {
|
||||
const nextQuery = Object.assign({}, queryState, {
|
||||
page: 1,
|
||||
status: statusFilter && statusFilter.status === statusFilter.targetStatus ? '' : statusFilter.targetStatus,
|
||||
sourceType: statusFilter && statusFilter.sourceType ? statusFilter.sourceType : '',
|
||||
sourceId: statusFilter && statusFilter.sourceId ? statusFilter.sourceId : ''
|
||||
});
|
||||
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const recurringTasks = (data && data.recurringTasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
||||
const pagination = buildPagination(tasks.length, parsePageNumber(data && data.page), 'page');
|
||||
const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage');
|
||||
const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||
const queryState = {
|
||||
page: parsePageNumber(data && data.page),
|
||||
recurringPage: parsePageNumber(data && data.recurringPage),
|
||||
status: normalizeStatusFilter(data && data.status),
|
||||
sourceType: '',
|
||||
sourceId: ''
|
||||
};
|
||||
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
||||
queryState.status = normalizeStatusFilter(data && data.status);
|
||||
queryState.sourceType = sourceFilter.sourceType;
|
||||
queryState.sourceId = sourceFilter.sourceId;
|
||||
const filteredTasks = tasks.filter(function (task) {
|
||||
return taskMatchesFilters(task, queryState.status, sourceFilter);
|
||||
});
|
||||
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
||||
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
||||
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
|
||||
const statusCards = [
|
||||
{
|
||||
status: 'queued',
|
||||
label: 'Queued',
|
||||
count: summary.counts.queued,
|
||||
variant: 'primary'
|
||||
},
|
||||
{
|
||||
status: 'running',
|
||||
label: 'Running',
|
||||
count: summary.counts.running,
|
||||
variant: 'info'
|
||||
},
|
||||
{
|
||||
status: 'failed',
|
||||
label: 'Failed',
|
||||
count: summary.counts.failed,
|
||||
variant: 'danger'
|
||||
},
|
||||
{
|
||||
status: 'completed',
|
||||
label: 'Finished',
|
||||
count: summary.counts.completed,
|
||||
variant: 'success'
|
||||
}
|
||||
].map(function (card) {
|
||||
const isActive = queryState.status === card.status;
|
||||
const statusFilter = {
|
||||
status: queryState.status,
|
||||
sourceType: queryState.sourceType,
|
||||
sourceId: queryState.sourceId,
|
||||
targetStatus: card.status
|
||||
};
|
||||
|
||||
return Object.assign({}, card, {
|
||||
active: isActive,
|
||||
href: buildStatusFilterUrl(queryState, statusFilter),
|
||||
ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}`
|
||||
});
|
||||
});
|
||||
|
||||
const visibleTasksWithSources = visibleTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
||||
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
|
||||
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
||||
});
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.status || queryState.sourceType || queryState.sourceId);
|
||||
|
||||
return renderView('settings/background-tasks/index', {
|
||||
title: 'Background tasks',
|
||||
@@ -71,13 +225,18 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||
tasks: visibleTasks,
|
||||
tasks: visibleTasksWithSources,
|
||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
statusCards: statusCards,
|
||||
activeStatusLabel: formatStatusLabel(queryState.status),
|
||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
||||
hasActiveFilters: hasActiveFilters,
|
||||
pagination: pagination,
|
||||
recurringPagination: recurringPagination,
|
||||
canManage: canManage,
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = function renderRbacPage(data, message, currentUser) {
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
roles: data.roles || []
|
||||
roles: data.roles || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,7 @@ module.exports = function renderUsersPage(data, message, currentUser) {
|
||||
active: 'users',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
users: data.users || []
|
||||
users: data.users || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -6,7 +6,9 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
||||
name: size.name,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
templateCount: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
|
||||
templateCount: size.template_count !== undefined
|
||||
? Number(size.template_count) || 0
|
||||
: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
|
||||
}));
|
||||
|
||||
return renderView('canvas-sizes/list', {
|
||||
@@ -14,6 +16,7 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSizes: canvasSizes
|
||||
canvasSizes: canvasSizes,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,19 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderCanvasSizesPage({
|
||||
canvasSizes: data.canvasSizes || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/dashboard/dashboard-page.js'],
|
||||
clients: data.clients || []
|
||||
clients: data.clients || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -2,12 +2,22 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const totalItems = Array.isArray(data.clients) ? data.clients.length : 0;
|
||||
const startIndex = (page - 1) * LIST_PAGE_SIZE;
|
||||
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
||||
res.send(pages.renderConnectedClientsPage({
|
||||
clients: clients,
|
||||
pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ module.exports = function renderPlaylistFormPage(message, currentUser) {
|
||||
title: 'Create playlist',
|
||||
active: 'playlists',
|
||||
playlist: {
|
||||
fade_between_slides: false
|
||||
fade_between_slides: false,
|
||||
skip_unavailable_rtmp: false
|
||||
},
|
||||
message: message,
|
||||
currentUser: currentUser || null
|
||||
|
||||
@@ -102,6 +102,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||
playlistSlides: playlistSlides,
|
||||
slides: data.slides || [],
|
||||
hasSlides: (data.slides || []).length > 0,
|
||||
@@ -110,6 +111,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
scripts: ['js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
|
||||
scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
const playlistSlides = data.playlistSlides || [];
|
||||
const playlists = (data.playlists || []).map((playlist) => {
|
||||
const slideCount = playlistSlides.filter((item) => item.playlist_id === playlist.id).length;
|
||||
const slideCount = playlist.slide_count !== undefined
|
||||
? Number(playlist.slide_count) || 0
|
||||
: (data.playlistSlides || []).filter((item) => item.playlist_id === playlist.id).length;
|
||||
return Object.assign({}, playlist, {
|
||||
slideCount: slideCount,
|
||||
slideCountIsOne: slideCount === 1
|
||||
@@ -15,7 +16,8 @@ module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
active: 'playlists',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: playlists
|
||||
playlists: playlists,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
@@ -13,15 +16,20 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
|
||||
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
if (req.query.edit) {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderPlaylistsPage({
|
||||
playlists: data.playlists || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,6 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
|
||||
...day,
|
||||
checked: selectedDays.has(day.value)
|
||||
})),
|
||||
scripts: ['js/playlists/playlist-schedule.js']
|
||||
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ module.exports = function renderScreensPage(data, message, currentUser) {
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screens: data.screens || []
|
||||
screens: data.screens || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
@@ -14,8 +17,8 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
|
||||
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
if (req.query.edit) {
|
||||
const data = await buildDashboardState(pool);
|
||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
@@ -23,7 +26,12 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderScreensPage({
|
||||
screens: data.screens || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ module.exports = function renderSlidesPage(data, message, currentUser) {
|
||||
active: 'slides',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
slides: data.slides || []
|
||||
slides: data.slides || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,19 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderSlidesPage({
|
||||
slides: data.slides || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas
|
||||
currentUser: currentUser || null,
|
||||
template: current,
|
||||
canvasSizes: canvasSizes || [],
|
||||
scripts: ['js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
|
||||
scripts: ['js/lib/modal.js', 'js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
name: template.name,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||
regionCount: template.region_count !== undefined
|
||||
? Number(template.region_count) || 0
|
||||
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||
}));
|
||||
|
||||
return renderView('templates/list', {
|
||||
@@ -14,6 +16,7 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
active: 'templates',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
templates: templates
|
||||
templates: templates,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,19 @@ module.exports = function registerTemplatesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
||||
res.send(pages.renderTemplatesPage({
|
||||
templates: data.templates || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
].join(''));
|
||||
});
|
||||
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8'));
|
||||
|
||||
function resolveTemplatePath(relativePath) {
|
||||
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
||||
if (SIGNAGE_VIEW_PREFIXES.has(firstSegment)) {
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved sources</h3>
|
||||
{{#if (hasPermission currentUser 'api-sources.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search API sources" aria-label="Search API sources" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -29,7 +33,7 @@
|
||||
<tbody>
|
||||
{{#if apiSources.length}}
|
||||
{{#each apiSources}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="API URL" class="text-break">{{api_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
@@ -69,9 +73,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/api-sources"}}
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved feeds</h3>
|
||||
{{#if (hasPermission currentUser 'rss-feeds.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search RSS feeds" aria-label="Search RSS feeds" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rss-feeds.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -28,7 +32,7 @@
|
||||
<tbody>
|
||||
{{#if rssFeeds.length}}
|
||||
{{#each rssFeeds}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
@@ -52,9 +56,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/rss-feeds"}}
|
||||
</div>
|
||||
|
||||
@@ -3,49 +3,44 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Background tasks</h2>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them. Click a status box or source name to filter the results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-primary h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Queued</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.queued}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-info h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Running</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.running}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-danger h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.failed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-success h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Finished</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.completed}}</div>
|
||||
</div>
|
||||
<div id="background-tasks-filters">
|
||||
{{#if hasActiveFilters}}
|
||||
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
|
||||
<div class="small text-muted">
|
||||
Showing results for {{#if activeStatusLabel}}{{activeStatusLabel}}{{else}}all tasks{{/if}}{{#if activeSourceLabel}} from {{activeSourceLabel}}{{/if}}.
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary mb-4">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<div id="background-tasks-summary" class="row g-3 mb-4">
|
||||
{{#each statusCards}}
|
||||
<div class="col-6 col-xl-3">
|
||||
<a href="{{href}}" class="d-block h-100 text-decoration-none text-body" data-background-tasks-nav="status" aria-label="{{ariaLabel}}"{{#if active}} aria-current="page"{{/if}}>
|
||||
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Task queue</h3>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
|
||||
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
|
||||
</div>
|
||||
{{#if canManage}}
|
||||
{{#if canClearFinished}}
|
||||
<form method="post" action="/settings/background-tasks/clear-finished">
|
||||
@@ -53,11 +48,10 @@
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<span class="text-muted small">Active workers: {{summary.activeCount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
@@ -73,7 +67,7 @@
|
||||
<tbody>
|
||||
{{#if tasks.length}}
|
||||
{{#each tasks}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if key}}
|
||||
@@ -147,7 +141,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr>
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
@@ -178,12 +172,18 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary">
|
||||
<div class="card-header">
|
||||
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Scheduled refreshes</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-recurring-tools">
|
||||
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
@@ -197,7 +197,7 @@
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
@@ -232,7 +232,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr>
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
@@ -7,17 +7,21 @@
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card card-outline card-primary admin-form-card" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing roles</h3>
|
||||
{{#if (hasPermission currentUser 'rbac.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search roles" aria-label="Search roles" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rbac.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 users-table">
|
||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
@@ -30,7 +34,7 @@
|
||||
<tbody>
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Role">
|
||||
<div class="user-cell">
|
||||
<div>
|
||||
@@ -68,11 +72,12 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="empty">No roles found.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No roles found.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/rbac"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing users</h3>
|
||||
{{#if (hasPermission currentUser 'users.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/users/new">Add user</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'users.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/users/new">Add user</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 users-table">
|
||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
@@ -27,7 +31,7 @@
|
||||
<tbody>
|
||||
{{#if users.length}}
|
||||
{{#each users}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="User">
|
||||
<div class="user-cell">
|
||||
<div>
|
||||
@@ -85,9 +89,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No users found.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/users"}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="modal fade" id="{{modalId}}" tabindex="-1" aria-labelledby="{{modalLabelId}}" aria-hidden="true"{{#if modalBackdropStatic}} data-bs-backdrop="static"{{/if}}{{#if modalKeyboardDisabled}} data-bs-keyboard="false"{{/if}}>
|
||||
<div class="modal-dialog{{#if modalDialogClass}} {{modalDialogClass}}{{/if}}">
|
||||
<div class="modal-content">
|
||||
{{> @partial-block }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
{{#if pagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
|
||||
</div>
|
||||
<nav aria-label="{{pagination.ariaLabel}}">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each pagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved sizes</h3>
|
||||
{{#if (hasPermission currentUser 'canvas-sizes.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add canvas size</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search canvas sizes" aria-label="Search canvas sizes" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'canvas-sizes.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add canvas size</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -27,7 +31,7 @@
|
||||
<tbody>
|
||||
{{#if canvasSizes.length}}
|
||||
{{#each canvasSizes}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Dimensions">{{width}}x{{height}}</td>
|
||||
<td data-label="Templates">{{templateCount}}</td>
|
||||
@@ -50,9 +54,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{> table-pagination pagination=pagination basePath="/canvas-sizes"}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Active connections</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search clients" aria-label="Search clients" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}">
|
||||
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
@@ -25,7 +31,7 @@
|
||||
<tbody id="dashboard-clients-table-body">
|
||||
{{#if clients.length}}
|
||||
{{#each clients}}
|
||||
<tr data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
@@ -95,9 +101,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/clients"}}
|
||||
</div>
|
||||
|
||||
@@ -12,51 +12,36 @@
|
||||
<h3 class="card-title">Playlist details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="playlist-edit-form" method="post" action="/playlists/{{playlist.id}}" data-async-save data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new">
|
||||
<div class="row g-3 align-items-end">
|
||||
<form id="playlist-edit-form" method="post" action="/playlists/{{playlist.id}}" data-async-save data-async-save-reload-on-success data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="playlist-name" class="form-label">Name</label>
|
||||
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 d-flex align-items-lg-end justify-content-lg-start">
|
||||
<div class="d-flex flex-column align-items-lg-start gap-1 w-100">
|
||||
<label class="form-label d-none d-lg-block invisible">Add slide</label>
|
||||
{{#if hasSlides}}
|
||||
{{#if hasSelectableSlides}}
|
||||
<button type="button" class="btn btn-primary w-100" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal">Add slide</button>
|
||||
{{else}}
|
||||
<div class="text-body-secondary small text-lg-start">
|
||||
{{#if playlistCanvasMismatch}}
|
||||
Remove mismatched slides before adding more.
|
||||
{{else if playlistCanvasSignature}}
|
||||
No slides match this playlist canvas size.
|
||||
{{else}}
|
||||
No slides are currently available for this playlist.
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="text-body-secondary small text-lg-start">Create slides first.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="row g-3 mt-1 align-items-start">
|
||||
<div class="col-12">
|
||||
<h4 class="h6 mb-2 text-uppercase text-muted">Add slide to playlist</h4>
|
||||
{{#if hasSlides}}
|
||||
{{#if hasSelectableSlides}}
|
||||
<form id="playlist-add-slide-form">
|
||||
<div class="row g-2 align-items-end playlist-add-slide-form">
|
||||
<div class="col-12 col-md-8">
|
||||
<select id="playlist-add-slide-select" class="form-select" required>
|
||||
{{#each selectableSlides}}
|
||||
<option value="{{id}}" data-canvas-signature="{{canvasSignature}}" {{#if isAssigned}}disabled{{/if}}>{{title}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 d-flex justify-content-md-end">
|
||||
<button type="button" class="btn btn-primary w-100 w-md-auto" id="playlist-add-slide-button">Add slide</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="playlist-add-slide-empty" class="empty mt-2 playlist-add-slide-empty is-hidden">No slides are currently available for this playlist.</div>
|
||||
{{else}}
|
||||
<div class="empty mb-0">
|
||||
{{#if playlistCanvasMismatch}}
|
||||
This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more.
|
||||
{{else if playlistCanvasSignature}}
|
||||
No slides match this playlist canvas size.
|
||||
{{else}}
|
||||
No slides are currently available for this playlist.
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="empty mb-0">Create slides first.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group playlist-actions-group" role="group" aria-label="Playlist actions">
|
||||
@@ -77,6 +62,10 @@
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="playlist-fade-between-slides" name="fade_between_slides" value="1" form="playlist-edit-form" {{#if playlistFadeBetweenSlides}}checked{{/if}} />
|
||||
<label class="form-check-label" for="playlist-fade-between-slides">Fade between slides</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-3 mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="playlist-skip-unavailable-rtmp" name="skip_unavailable_rtmp" value="1" form="playlist-edit-form" {{#if playlistSkipUnavailableRtmp}}checked{{/if}} />
|
||||
<label class="form-check-label" for="playlist-skip-unavailable-rtmp">Skip RTMP slides when stream is unavailable</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +99,18 @@
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Slide">
|
||||
{{title}}
|
||||
<div class="playlist-slide-cell">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true">
|
||||
{{#if thumbnail_path}}
|
||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||
{{else}}
|
||||
<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="playlist-slide-cell-content">
|
||||
<span class="playlist-slide-title">{{title}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="slide_id[]" value="{{slide_id}}" form="playlist-edit-form" />
|
||||
</td>
|
||||
<td data-label="Schedule">
|
||||
@@ -123,7 +123,7 @@
|
||||
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
||||
</td>
|
||||
<td data-label="Duration">
|
||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" />
|
||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions playlist-item-actions">
|
||||
@@ -143,5 +143,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="playlist-add-slide-data">{{json selectableSlides}}</script>
|
||||
|
||||
{{#> modal-shell modalId="playlist-add-slide-modal" modalLabelId="playlist-add-slide-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title fs-5" id="playlist-add-slide-modal-label">Add slides to playlist</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body playlist-slide-picker-modal-body">
|
||||
<div class="playlist-slide-picker-toolbar">
|
||||
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-slide-picker-empty empty is-hidden" id="playlist-slide-picker-empty">No slides match your search.</div>
|
||||
<div class="playlist-slide-picker-grid" id="playlist-slide-picker-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-end">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2 w-100">
|
||||
<div class="text-body-secondary small text-end" id="playlist-slide-picker-selected-count-wrap"><span id="playlist-slide-picker-selected-count">0</span> selected</div>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="playlist-confirm-add-slides" disabled>Add selected slides</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<form id="delete-playlist-form" method="post" action="/playlists/{{playlist.id}}/delete" data-confirm-message="Delete this playlist?"></form>
|
||||
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing playlists</h3>
|
||||
{{#if (hasPermission currentUser 'playlists.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/playlists/new">Create playlist</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search playlists" aria-label="Search playlists" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'playlists.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/playlists/new">Create playlist</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -26,7 +30,7 @@
|
||||
<tbody>
|
||||
{{#if playlists.length}}
|
||||
{{#each playlists}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}</span></td>
|
||||
<td data-label="Actions">
|
||||
@@ -48,10 +52,11 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="empty">No playlists yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="3" class="empty">No playlists yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/playlists"}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing screens</h3>
|
||||
{{#if (hasPermission currentUser 'screens.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search screens" aria-label="Search screens" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'screens.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -28,7 +32,7 @@
|
||||
<tbody>
|
||||
{{#if screens.length}}
|
||||
{{#each screens}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
||||
<td data-label="Playlist">{{playlist_name}}</td>
|
||||
@@ -58,10 +62,11 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/screens"}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,5 +72,5 @@
|
||||
{{/if}}
|
||||
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260724"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725"></script>
|
||||
|
||||
|
||||
@@ -5,19 +5,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing slides</h3>
|
||||
{{#if (hasPermission currentUser 'slides.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/slides/new">Create slide</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search slides" aria-label="Search slides" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'slides.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/slides/new">Create slide</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thumbnail</th>
|
||||
<th>Title</th>
|
||||
<th>Template</th>
|
||||
<th>Actions</th>
|
||||
@@ -26,7 +31,16 @@
|
||||
<tbody>
|
||||
{{#if slides.length}}
|
||||
{{#each slides}}
|
||||
<tr>
|
||||
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
|
||||
<td data-label="Thumbnail">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true">
|
||||
{{#if thumbnail_path}}
|
||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||
{{else}}
|
||||
<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Title">{{title}}</td>
|
||||
<td data-label="Template">{{template_name}}</td>
|
||||
<td data-label="Actions">
|
||||
@@ -48,10 +62,17 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="empty">No slides yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No slides yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/slides"}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof window.attachSlideThumbFallbacks === 'function') {
|
||||
window.attachSlideThumbFallbacks(document);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -106,28 +106,24 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="modal fade" id="region-add-modal" tabindex="-1" aria-labelledby="region-add-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="region-add-modal-label">Add region</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
{{#> modal-shell modalId="region-add-modal" modalLabelId="region-add-modal-label" modalDialogClass="modal-dialog-centered"}}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="region-add-modal-label">Add region</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<template id="region-card-template">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
||||
|
||||
@@ -113,28 +113,24 @@
|
||||
|
||||
<form id="delete-template-form" method="post" action="/templates/{{template.id}}/delete" data-confirm-message="Delete this template?"></form>
|
||||
|
||||
<div class="modal fade" id="region-add-modal" tabindex="-1" aria-labelledby="region-add-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="region-add-modal-label">Add region</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
{{#> modal-shell modalId="region-add-modal" modalLabelId="region-add-modal-label" modalDialogClass="modal-dialog-centered"}}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="region-add-modal-label">Add region</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<template id="region-card-template">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card card-outline card-primary" data-table-search-container>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing templates</h3>
|
||||
{{#if (hasPermission currentUser 'templates.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/templates/new">Create template</a>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search templates" aria-label="Search templates" data-table-search />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'templates.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/templates/new">Create template</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -27,7 +31,7 @@
|
||||
<tbody>
|
||||
{{#if templates.length}}
|
||||
{{#each templates}}
|
||||
<tr>
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
|
||||
<td data-label="Regions">{{regionCount}}</td>
|
||||
@@ -50,10 +54,11 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No templates yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No templates yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/templates"}}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user