Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e89892c94 | ||
|
|
9ee938fd2f |
@@ -6,6 +6,32 @@ 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
|
||||
|
||||
- Fixed player media upload sync so signed upload requests are verified after the request body has been parsed.
|
||||
|
||||
## 1.5.1 - 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
|
||||
|
||||
@@ -87,5 +87,6 @@ Outside Docker, the web app and player do not have to use the same upload locati
|
||||
|
||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||
Generated
+3516
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.1",
|
||||
"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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+77
-16
@@ -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', requireRequestAuth, express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
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