Release v2.10.3
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { findAvailableClientName, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
|
||||
@@ -144,8 +144,8 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
}
|
||||
const screen = screenRows[0];
|
||||
|
||||
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
|
||||
if (!available) {
|
||||
const selectedClientName = await findAvailableClientName(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||
if (!selectedClientName) {
|
||||
const error = new Error('Client name already exists.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
@@ -153,9 +153,9 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
|
||||
await pool.query(
|
||||
`UPDATE d_onboarding_devices
|
||||
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||
SET client_name = ?, screen_id = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?`,
|
||||
[normalizedClientName, screen.id, normalizedDeviceId]
|
||||
[selectedClientName, screen.id, normalizedDeviceId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
||||
@@ -163,7 +163,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
||||
)`,
|
||||
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
|
||||
[normalizedDeviceId, selectedClientName, screen.id, normalizedDeviceId]
|
||||
);
|
||||
|
||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||
@@ -581,6 +581,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
if (response.ok) {
|
||||
pairingSessions.delete(deviceId);
|
||||
res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
|
||||
}
|
||||
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
|
||||
return res.json(payload);
|
||||
@@ -600,6 +601,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||
pairingSessions.delete(deviceId);
|
||||
res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
|
||||
res.json({
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : clientName,
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
|
||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
<!-- Player page bootstrap and browser-side playback lifecycle. -->
|
||||
|
||||
<script>
|
||||
const slug = {{SLUG_JSON}};
|
||||
let initialData = {{INITIAL_DATA_JSON}};
|
||||
window.slug = slug;
|
||||
window.initialData = initialData;
|
||||
const app = document.getElementById('app');
|
||||
(function () {
|
||||
var root = window;
|
||||
var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
|
||||
|
||||
function normalizeType(type) {
|
||||
return String(type || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function register(type, definition) {
|
||||
registry[normalizeType(type)] = definition || {};
|
||||
return registry[normalizeType(type)];
|
||||
}
|
||||
|
||||
function get(type) {
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
root.pulsePlayerRegionTypes = {
|
||||
register: register,
|
||||
get: get,
|
||||
list: list
|
||||
};
|
||||
}());
|
||||
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
|
||||
let currentPlaylistSignature = '';
|
||||
let currentPlaylistEtag = '';
|
||||
@@ -61,6 +31,7 @@
|
||||
let viewportRenderTimer = null;
|
||||
let refreshRetryTimer = null;
|
||||
let refreshRetryDelayMs = 0;
|
||||
let playlistRefreshTimer = null;
|
||||
let commandClientId = null;
|
||||
let screenWakeLock = null;
|
||||
let screenWakeLockRequestPromise = null;
|
||||
@@ -102,7 +73,12 @@
|
||||
|
||||
// Render the empty-state message into the player root.
|
||||
function renderEmpty(message) {
|
||||
app.innerHTML = '<div class="empty">' + escapeHtml(message) + '</div>';
|
||||
var markup = '<div class="empty">' + escapeHtml(message) + '</div>';
|
||||
if (currentPlaylistFadeBetweenSlides && typeof renderSlideMarkup === 'function') {
|
||||
renderSlideMarkup(markup, true);
|
||||
return;
|
||||
}
|
||||
app.innerHTML = markup;
|
||||
}
|
||||
|
||||
// Announce the player to the command websocket.
|
||||
@@ -204,10 +180,19 @@
|
||||
showCurrent();
|
||||
refresh();
|
||||
} else {
|
||||
if (initialPlaylistSnapshot && applyPlaylistSnapshot(initialPlaylistSnapshot)) {
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(initialPlaylistSnapshot.skipUnavailableRtmp);
|
||||
showCurrent();
|
||||
}
|
||||
syncBlackoutState();
|
||||
refresh();
|
||||
}
|
||||
syncOfflineBanner();
|
||||
syncScreenWakeLock();
|
||||
playlistRefreshTimer = window.setInterval(function () {
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
refresh();
|
||||
}
|
||||
}, 60 * 1000);
|
||||
connectCommandSocket();
|
||||
</script>
|
||||
@@ -22,21 +22,6 @@ body.onboarding-page #app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.thumbnail-preview *,
|
||||
body.thumbnail-preview *::before,
|
||||
body.thumbnail-preview *::after {
|
||||
animation: none !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body.thumbnail-preview .player-announcement-layer,
|
||||
body.thumbnail-preview .player-offline-banner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -7,6 +7,7 @@ function getCurrentViewport() {
|
||||
}
|
||||
|
||||
var slideOutroTimers = [];
|
||||
var commandHeartbeatTimer = null;
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
@@ -166,19 +167,11 @@ function getPlayerRegionModules() {
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function isThumbnailPreview() {
|
||||
return Boolean(window.__pulseThumbnailPreview);
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
@@ -276,9 +269,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
syncRtmpRegions(root);
|
||||
}
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(root);
|
||||
}
|
||||
initializeRegionInstances(root);
|
||||
|
||||
initializeRenderedVideoPlayback(root, delayMs);
|
||||
|
||||
@@ -330,13 +321,16 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell';
|
||||
nextShell.className = 'slide-shell slide-shell-entering';
|
||||
nextShell.style.zIndex = '0';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
app.appendChild(nextShell);
|
||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||
return nextShell;
|
||||
@@ -345,6 +339,9 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.classList.remove('is-visible');
|
||||
previousShell.classList.add('is-exiting');
|
||||
previousShell.style.zIndex = '1';
|
||||
previousShell.style.opacity = '1';
|
||||
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
||||
|
||||
@@ -352,6 +349,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||
});
|
||||
|
||||
@@ -360,7 +359,10 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.zIndex = '1';
|
||||
nextShell.style.opacity = '1';
|
||||
nextShell.classList.remove('slide-shell-entering');
|
||||
nextShell.classList.add('is-visible');
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeDurationMs);
|
||||
@@ -537,14 +539,28 @@ function handleCommandMessage(rawMessage) {
|
||||
handleClientIdConflict();
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-name-updated') {
|
||||
if (payload.clientName) {
|
||||
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'command') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.requestId && commandSocket && commandSocket.readyState === WebSocket.OPEN) {
|
||||
commandSocket.send(JSON.stringify({
|
||||
type: 'command-ack',
|
||||
requestId: payload.requestId,
|
||||
ok: true
|
||||
}));
|
||||
}
|
||||
|
||||
switch (payload.command) {
|
||||
case 'refresh':
|
||||
refresh();
|
||||
refresh(true);
|
||||
return;
|
||||
case 'setclientname':
|
||||
if (payload.clientName) {
|
||||
@@ -602,7 +618,6 @@ function handleCommandMessage(rawMessage) {
|
||||
function handleClientIdConflict() {
|
||||
var replacementClientId = regenerateCommandClientId();
|
||||
var onboardingUrl = new URL('/', window.location.origin);
|
||||
onboardingUrl.searchParams.set('clientId', replacementClientId);
|
||||
window.location.replace(onboardingUrl.toString());
|
||||
}
|
||||
|
||||
@@ -629,6 +644,12 @@ function connectCommandSocket() {
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
if (commandHeartbeatTimer) {
|
||||
window.clearInterval(commandHeartbeatTimer);
|
||||
}
|
||||
commandHeartbeatTimer = window.setInterval(function () {
|
||||
sendCommandState(lastRenderedSlide);
|
||||
}, 60 * 1000);
|
||||
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
||||
syncOnboardingClientNameFromServer(socket).then(function () {
|
||||
sendCommandState(socket);
|
||||
@@ -643,6 +664,13 @@ function connectCommandSocket() {
|
||||
};
|
||||
|
||||
socket.onclose = function (event) {
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug('Command websocket closed.', 'code=' + String(event && event.code || '') + ' reason=' + String(event && event.reason || ''), 'warn');
|
||||
}
|
||||
if (commandHeartbeatTimer) {
|
||||
window.clearInterval(commandHeartbeatTimer);
|
||||
commandHeartbeatTimer = null;
|
||||
}
|
||||
commandSocket = null;
|
||||
if (event && event.code === 4009) {
|
||||
handleClientIdConflict();
|
||||
@@ -651,7 +679,10 @@ function connectCommandSocket() {
|
||||
scheduleCommandReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
socket.onerror = function (error) {
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug('Command websocket error.', error && error.message ? String(error.message) : 'Websocket transport error.', 'error');
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// 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) {
|
||||
@@ -41,9 +38,6 @@ 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;
|
||||
@@ -63,9 +57,6 @@ 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.
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
|
||||
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.');
|
||||
@@ -53,7 +53,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
|
||||
index = currentIndex;
|
||||
var markup = buildSlideMarkup(slide);
|
||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||
renderSlideMarkup(markup, !(options && options.skipFade) && currentPlaylistFadeBetweenSlides);
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function applyPendingPlaylistUpdate() {
|
||||
}
|
||||
|
||||
// Render the current active slide or the empty state.
|
||||
function showCurrent() {
|
||||
function showCurrent(options) {
|
||||
clearSlideTimer();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
syncWebpagePreloads(activeSlides, index);
|
||||
@@ -106,7 +106,7 @@ function showCurrent() {
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(activeSlides, index);
|
||||
void renderSlideAtIndex(activeSlides, index, options);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function handleRtmpPlaybackFailure(message, options) {
|
||||
}
|
||||
|
||||
// Fetch the latest playlist and queue any updates.
|
||||
function refresh() {
|
||||
function refresh(applyImmediately) {
|
||||
var request = new XMLHttpRequest();
|
||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||
request.open('GET', url, true);
|
||||
@@ -167,6 +167,7 @@ function refresh() {
|
||||
return;
|
||||
}
|
||||
if (request.status === 304) {
|
||||
logDebug('Playlist refresh completed with no changes.');
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
@@ -192,6 +193,7 @@ function refresh() {
|
||||
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
||||
const data = JSON.parse(request.responseText || '{}');
|
||||
const nextSignature = getPlaylistRevision(data);
|
||||
logDebug('Playlist refresh completed.', 'Revision: ' + nextSignature);
|
||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||
@@ -254,6 +256,11 @@ function refresh() {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
@@ -268,6 +275,11 @@ function refresh() {
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
if (applyImmediately) {
|
||||
applyPendingPlaylistUpdate();
|
||||
showCurrent();
|
||||
return;
|
||||
}
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
} catch (_error) {
|
||||
logDebug(
|
||||
|
||||
@@ -129,10 +129,6 @@ function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
|
||||
}
|
||||
|
||||
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
|
||||
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
|
||||
return;
|
||||
@@ -236,6 +232,7 @@ function loadPlaylistSnapshot() {
|
||||
slides: parsed.slides.map(normalizeSlide),
|
||||
signature: String(parsed.signature || ''),
|
||||
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
|
||||
skipUnavailableRtmp: Boolean(parsed.skipUnavailableRtmp),
|
||||
etag: String(parsed.etag || '')
|
||||
};
|
||||
} catch (_error) {
|
||||
|
||||
@@ -227,7 +227,7 @@ function getRegionAnimationPhaseTimingMs(root, phase) {
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimings(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ function getRegionAnimationPhaseTimings(root, phase) {
|
||||
}
|
||||
|
||||
function playRegionAnimation(element, phase) {
|
||||
if (!element || isThumbnailPreview()) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ function playRegionAnimation(element, phase) {
|
||||
}
|
||||
|
||||
function playRegionAnimations(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -797,7 +797,7 @@ function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGrad
|
||||
var gradient = '';
|
||||
try {
|
||||
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
|
||||
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
|
||||
if (gradientData && gradientData.type === 'linear' && ((Array.isArray(gradientData.stops) && gradientData.stops.length >= 2) || (Array.isArray(gradientData.colors) && gradientData.colors.length >= 2))) {
|
||||
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
|
||||
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
||||
if (stops.length >= 2) {
|
||||
@@ -975,7 +975,7 @@ function notifyVideoRegionSourceReady() {
|
||||
}
|
||||
slideMarkupCache = Object.create(null);
|
||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||
showCurrent();
|
||||
showCurrent({ skipFade: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Registry used by the player runtime to discover independently loaded region modules.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
|
||||
|
||||
function normalizeType(type) {
|
||||
return String(type || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function register(type, definition) {
|
||||
registry[normalizeType(type)] = definition || {};
|
||||
return registry[normalizeType(type)];
|
||||
}
|
||||
|
||||
function get(type) {
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
root.pulsePlayerRegionTypes = {
|
||||
register: register,
|
||||
get: get,
|
||||
list: list
|
||||
};
|
||||
}());
|
||||
@@ -1,3 +1,5 @@
|
||||
// RTMP region markup and playback lifecycle hooks.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderRtmpRegion(region, regionContent) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Assemble player HTML and inline runtime scripts from the server-side templates.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
@@ -523,6 +525,32 @@ function getPlayerOnboardingFormScript() {
|
||||
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerRuntimeScripts() {
|
||||
function getScriptBody(value) {
|
||||
return String(value || '')
|
||||
.replace(/^\s*<script(?:\s[^>]*)?>/i, '')
|
||||
.replace(/<\/script>\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return [
|
||||
['region-registry', fs.readFileSync(path.join(__dirname, 'public', 'js', 'player-region-registry.js'), 'utf8').trim()],
|
||||
['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()],
|
||||
['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()],
|
||||
['client-name', getScriptBody(getPlayerClientNameScript()())],
|
||||
['offline', getScriptBody(getPlayerPageOfflineScript()())],
|
||||
['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
|
||||
['commands', getScriptBody(getPlayerPageCommandsScript()())],
|
||||
['rendering', getScriptBody(getPlayerPageRenderingScript()())],
|
||||
['playback', getScriptBody(getPlayerPagePlaybackScript()())],
|
||||
['announcement-icons', getAnnouncementIconsDataScript()],
|
||||
['announcement-data', getPlayerAnnouncementTemplatesDataScript()],
|
||||
['announcement-templates', getScriptBody(getPlayerAnnouncementTemplatesScript())]
|
||||
].concat(getPlayerRegionScriptPaths().map(function (filePath, index) {
|
||||
return ['region-' + index, fs.readFileSync(filePath, 'utf8').trim()];
|
||||
})).filter(function (entry) { return entry[1]; });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mediaKind: mediaKind,
|
||||
escapeHtml: escapeHtml,
|
||||
@@ -556,5 +584,6 @@ module.exports = {
|
||||
getPlayerPageScript: getPlayerPageScript,
|
||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
|
||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript,
|
||||
getPlayerRuntimeScripts: getPlayerRuntimeScripts
|
||||
};
|
||||
+8
-13
@@ -2,8 +2,7 @@
|
||||
|
||||
const Handlebars = require('handlebars');
|
||||
const path = require('path');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
@@ -110,30 +109,26 @@ function renderOnboardingFormScript(deviceId) {
|
||||
}
|
||||
|
||||
function renderPlayerPage(slug, initialData) {
|
||||
const onboardingScript = getPlayerClientNameScript()();
|
||||
const offlineScript = getPlayerPageOfflineScript()();
|
||||
const playlistScript = getPlayerPagePlaylistScript()();
|
||||
const commandScript = getPlayerPageCommandsScript()();
|
||||
const renderingScript = getPlayerPageRenderingScript()();
|
||||
const playbackScript = getPlayerPagePlaybackScript()();
|
||||
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||
const template = getPlayerPageTemplate();
|
||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
||||
const script = getPlayerPageScript()({
|
||||
const bootstrapScript = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
|
||||
REGION_SCRIPTS: ''
|
||||
});
|
||||
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
|
||||
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js"></script>';
|
||||
}).join('');
|
||||
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
bodyClass: bodyClass,
|
||||
bodyClass: '',
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+72
-64
@@ -5,7 +5,6 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { getPlayerPublicBaseUrl } = require('./onboarding');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
|
||||
@@ -24,6 +23,23 @@ function isBridgeFetchError(error) {
|
||||
));
|
||||
}
|
||||
|
||||
function getRequestClientId(req) {
|
||||
const headerClientId = String(req && req.headers && req.headers['x-pulse-client-id'] || '').trim();
|
||||
if (headerClientId) {
|
||||
return headerClientId;
|
||||
}
|
||||
const queryClientId = String(req && req.query && req.query.clientId || '').trim();
|
||||
if (queryClientId) {
|
||||
return queryClientId;
|
||||
}
|
||||
const cookieHeader = String(req && req.headers && req.headers.cookie || '');
|
||||
const cookie = cookieHeader.split(';').map(function (part) {
|
||||
const separator = part.indexOf('=');
|
||||
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
|
||||
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-player-client-id'; });
|
||||
return cookie ? decodeURIComponent(cookie[1]) : '';
|
||||
}
|
||||
|
||||
function registerPlayerRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
@@ -35,6 +51,7 @@ function registerPlayerRoutes(app, options) {
|
||||
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
||||
const snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : null;
|
||||
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
|
||||
|
||||
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||
@@ -71,6 +88,11 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
if (requestHeaders['x-pulse-client-id']) {
|
||||
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
|
||||
} else {
|
||||
const clientId = getRequestClientId(req);
|
||||
if (clientId) {
|
||||
headers['x-pulse-client-id'] = clientId;
|
||||
}
|
||||
}
|
||||
if (requestOptions.contentType) {
|
||||
headers['content-type'] = requestOptions.contentType;
|
||||
@@ -130,7 +152,7 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
|
||||
async function isClientAuthorizedForScreen(req, requestedSlug) {
|
||||
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
|
||||
const clientId = getRequestClientId(req);
|
||||
if (!clientId) {
|
||||
return false;
|
||||
}
|
||||
@@ -219,6 +241,32 @@ function registerPlayerRoutes(app, options) {
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
function getSnapshotFilePath(slug) {
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
return snapshotDir && normalizedSlug ? path.join(snapshotDir, `${normalizedSlug}.json`) : null;
|
||||
}
|
||||
|
||||
async function readPlaylistSnapshot(slug) {
|
||||
const filePath = getSnapshotFilePath(slug);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writePlaylistSnapshot(slug, payload) {
|
||||
const filePath = getSnapshotFilePath(slug);
|
||||
if (!filePath || !payload) {
|
||||
return;
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
@@ -371,7 +419,7 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
|
||||
app.get('/screen/:slug', async function (req, res, next) {
|
||||
if (onPlayerPublicBaseUrl) {
|
||||
if (onPlayerPublicBaseUrl && !bridgeBaseUrl) {
|
||||
try {
|
||||
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
|
||||
} catch (_error) {
|
||||
@@ -387,21 +435,25 @@ function registerPlayerRoutes(app, options) {
|
||||
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
||||
}).then(async function (response) {
|
||||
if (!response || response.status >= 400) {
|
||||
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
return res.send(common.renderPlayerPage(req.params.slug, snapshot));
|
||||
}
|
||||
const data = await readJsonResponse(response);
|
||||
if (!data) {
|
||||
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
return res.send(common.renderPlayerPage(req.params.slug, snapshot));
|
||||
}
|
||||
await writePlaylistSnapshot(req.params.slug, data);
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}).catch(function (error) {
|
||||
}).catch(async function (error) {
|
||||
if (!isBridgeFetchError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||
res.set('X-Player-Offline', '1');
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
res.send(common.renderPlayerPage(req.params.slug, snapshot));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -415,61 +467,6 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).send('Thin client unavailable');
|
||||
}
|
||||
res.status(response.status);
|
||||
res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
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 {
|
||||
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
|
||||
@@ -480,7 +477,13 @@ function registerPlayerRoutes(app, options) {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
const snapshot = await readPlaylistSnapshot(req.params.slug);
|
||||
if (!snapshot) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
}
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('ETag', '"' + String(snapshot.revision || '') + '"');
|
||||
return res.json(snapshot);
|
||||
}
|
||||
res.status(response.status);
|
||||
const etag = response.headers.get('etag');
|
||||
@@ -495,7 +498,12 @@ function registerPlayerRoutes(app, options) {
|
||||
return res.end();
|
||||
}
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(await response.text());
|
||||
const responseText = await response.text();
|
||||
try {
|
||||
await writePlaylistSnapshot(req.params.slug, JSON.parse(responseText));
|
||||
} catch (_error) {
|
||||
}
|
||||
return res.send(responseText);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
+129
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { findAvailableClientName, isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||
|
||||
@@ -22,6 +22,8 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
function createPlayerRuntime(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
|
||||
const persistClientName = typeof options.persistClientName === 'function' ? options.persistClientName : null;
|
||||
const touchClientLastSeen = typeof options.touchClientLastSeen === 'function' ? options.touchClientLastSeen : null;
|
||||
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||
? options.normalizeDeviceId
|
||||
: function (value) {
|
||||
@@ -30,7 +32,11 @@ function createPlayerRuntime(options) {
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const announcementListenersBySlug = new Map();
|
||||
const pendingCommandAcks = new Map();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const staleConnectionMs = Number(options && options.staleConnectionMs) > 0
|
||||
? Number(options.staleConnectionMs)
|
||||
: 3 * 60 * 1000;
|
||||
|
||||
function hasActiveClientId(clientId, currentConnection) {
|
||||
const normalizedClientId = String(clientId || '').trim();
|
||||
@@ -180,6 +186,57 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleConnections() {
|
||||
const cutoff = Date.now() - staleConnectionMs;
|
||||
for (const [slug, bucket] of connectionsBySlug.entries()) {
|
||||
for (const [connectionId, connection] of bucket.entries()) {
|
||||
if (connection.lastSeenAt && connection.lastSeenAt.getTime() > cutoff) {
|
||||
continue;
|
||||
}
|
||||
removeConnection(slug, connectionId);
|
||||
try {
|
||||
connection.socket.close(1000, 'Connection heartbeat expired.');
|
||||
} catch (_error) {
|
||||
}
|
||||
broadcastConnectionSnapshot(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const staleConnectionSweep = setInterval(removeStaleConnections, Math.min(staleConnectionMs, 60 * 1000));
|
||||
if (typeof staleConnectionSweep.unref === 'function') {
|
||||
staleConnectionSweep.unref();
|
||||
}
|
||||
|
||||
function checkWebsocketHealth() {
|
||||
for (const [slug, bucket] of connectionsBySlug.entries()) {
|
||||
for (const [connectionId, connection] of bucket.entries()) {
|
||||
if (!connection.isAlive) {
|
||||
removeConnection(slug, connectionId);
|
||||
try {
|
||||
connection.socket.terminate();
|
||||
} catch (_error) {
|
||||
}
|
||||
broadcastConnectionSnapshot(slug);
|
||||
continue;
|
||||
}
|
||||
|
||||
connection.isAlive = false;
|
||||
try {
|
||||
connection.socket.ping();
|
||||
} catch (_error) {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const websocketHealthCheck = setInterval(checkWebsocketHealth, 30 * 1000);
|
||||
if (typeof websocketHealthCheck.unref === 'function') {
|
||||
websocketHealthCheck.unref();
|
||||
}
|
||||
|
||||
function getDashboardListenerBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
@@ -370,7 +427,10 @@ function createPlayerRuntime(options) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const target = bucket.get(String(connectionId || '').trim());
|
||||
const normalizedConnectionId = String(connectionId || '').trim();
|
||||
const target = bucket.get(normalizedConnectionId) || Array.from(bucket.values()).find(function (connection) {
|
||||
return String(connection && connection.clientId || '').trim() === normalizedConnectionId;
|
||||
});
|
||||
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||
return 0;
|
||||
}
|
||||
@@ -382,8 +442,28 @@ function createPlayerRuntime(options) {
|
||||
payload.targetConnectionId = target.id;
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
const requestId = String(payload.requestId || '').trim();
|
||||
if (!requestId) {
|
||||
target.socket.send(JSON.stringify(payload));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const acknowledgement = new Promise(function (resolve) {
|
||||
const timeout = setTimeout(function () {
|
||||
pendingCommandAcks.delete(requestId);
|
||||
resolve(0);
|
||||
}, 5000);
|
||||
pendingCommandAcks.set(requestId, {
|
||||
connection: target,
|
||||
resolve: function (acknowledged) {
|
||||
clearTimeout(timeout);
|
||||
pendingCommandAcks.delete(requestId);
|
||||
resolve(acknowledged ? 1 : 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
target.socket.send(JSON.stringify(payload));
|
||||
return 1;
|
||||
return await acknowledgement;
|
||||
}
|
||||
|
||||
async function broadcastCommand(slug, commandOrPayload) {
|
||||
@@ -527,6 +607,7 @@ function createPlayerRuntime(options) {
|
||||
connectedAt: new Date(),
|
||||
lastSeenAt: new Date()
|
||||
};
|
||||
connection.isAlive = true;
|
||||
const bucket = getConnectionBucket(slug);
|
||||
|
||||
if (!bucket) {
|
||||
@@ -536,7 +617,11 @@ function createPlayerRuntime(options) {
|
||||
|
||||
bucket.set(connectionId, connection);
|
||||
|
||||
socket.on('message', function (rawMessage) {
|
||||
socket.on('pong', function () {
|
||||
connection.isAlive = true;
|
||||
});
|
||||
|
||||
socket.on('message', async function (rawMessage) {
|
||||
connection.lastSeenAt = new Date();
|
||||
let payload = null;
|
||||
try {
|
||||
@@ -546,6 +631,13 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'state') {
|
||||
if (payload && payload.type === 'command-ack') {
|
||||
const requestId = String(payload.requestId || '').trim();
|
||||
const pendingAck = pendingCommandAcks.get(requestId);
|
||||
if (pendingAck && pendingAck.connection === connection) {
|
||||
pendingAck.resolve(payload.ok !== false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -561,6 +653,26 @@ function createPlayerRuntime(options) {
|
||||
if (!connection.clientName && connection.clientId) {
|
||||
connection.clientName = connection.clientId;
|
||||
}
|
||||
if (connection.clientName) {
|
||||
const connectionIdentity = connection.deviceId || connection.clientId;
|
||||
const selectedClientName = await findAvailableClientName(pool, connection.clientName, connectionIdentity, snapshotAllConnections());
|
||||
if (selectedClientName && selectedClientName !== connection.clientName) {
|
||||
connection.clientName = selectedClientName;
|
||||
if (persistClientName) {
|
||||
await persistClientName(connection.deviceId, selectedClientName);
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'client-name-updated', clientName: selectedClientName }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (touchClientLastSeen) {
|
||||
try {
|
||||
await touchClientLastSeen(connection.clientId);
|
||||
} catch (_error) {
|
||||
// A heartbeat failure must not interrupt playback or websocket state.
|
||||
}
|
||||
}
|
||||
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||
@@ -581,12 +693,24 @@ function createPlayerRuntime(options) {
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
socket.on('close', function (code, reason) {
|
||||
pendingCommandAcks.forEach(function (pendingAck, requestId) {
|
||||
if (pendingAck.connection === connection) {
|
||||
pendingAck.resolve(false);
|
||||
pendingCommandAcks.delete(requestId);
|
||||
}
|
||||
});
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
pendingCommandAcks.forEach(function (pendingAck, requestId) {
|
||||
if (pendingAck.connection === connection) {
|
||||
pendingAck.resolve(false);
|
||||
pendingCommandAcks.delete(requestId);
|
||||
}
|
||||
});
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
|
||||
|
||||
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: [],
|
||||
timetableGroups: [],
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user