diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5fb0b..6b82016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,32 @@ All notable changes to this project will be documented in this file. +## 2.10.3 - 2026-08-28 + +### Fixed + +- Fixed the weather edit preview so the daily forecast is shown initially, the 24-hour forecast is hidden until selected, and the redundant hourly heading is removed. +- Fixed onboarding cleanup so incomplete pairings are retained for the 15-minute pairing-code lifetime while inactive completed bindings are pruned after 24 hours. +- Fixed client-name handling so offline names can be reused, active collisions receive numeric suffixes, and reconnecting players resolve duplicate names consistently. +- Fixed an internal server error when renaming clients by forwarding the available-name resolver to the client command routes. +- Fixed remote client moves to resolve the paired browser client binding before changing its target screen. +- Fixed remote player heartbeats to update the central onboarding bindings for active browser clients without treating the physical player registry ID as a client binding. +- Fixed targeted commands after browser reconnects by falling back to the stable client ID when a transient connection ID is stale. +- Fixed stale browser command connections remaining active indefinitely when the physical player heartbeat was still healthy. +- Added periodic playlist polling so remote screens recover from missed refresh commands after bridge reconnects. +- Added cached playlist snapshots so screens can continue displaying their last known playlist while the bridge or web service is temporarily unavailable. +- Fixed media synchronization so generated player caches are excluded while remote image caches remain available to players. +- Fixed weather screen notifications so changes in the fetched data or the current forecast hour trigger a refresh. +- Fixed player runtime script loading and slide rendering so region modules, transitions, and cached playlists initialize consistently. +- Fixed command delivery reporting to require acknowledgement from the receiving browser tab. +- Fixed playlist refresh notifications to target the physical player hosting each screen instead of always targeting the local player. + ## 2.10.2 - 2026-08-28 ### Fixed - Fixed the weather forecast preview to show only its first-fetch message until a successful forecast is available. +- Added persisted onboarding client heartbeats so records inactive for 24 hours can be pruned without relying on player connectivity after the fact. ## 2.10.1 - 2026-08-28 diff --git a/build/package.player.json b/build/package.player.json index 4887222..c7450ef 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.10.2", + "version": "2.10.3", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 896232f..90998d3 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.10.2", + "version": "2.10.3", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index 44001ff..298436e 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -6,7 +6,7 @@ PULSE_SIGNAGE_SHARED_SECRET="" # Remote player PLAYER_IDENTIFIER="player-remote" -PLAYER_PUBLIC_URL="http://localhost:8081" +PLAYER_PUBLIC_URL="http://remote-player.example.com:8081" PLAYER_AGENT_RECONNECT_DELAY_MS=5000 # Remote bridge connectivity diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml index fc46ada..05b0cc5 100644 --- a/docker-compose/docker-compose.remote.yml +++ b/docker-compose/docker-compose.remote.yml @@ -11,8 +11,8 @@ services: - "8081:8081" environment: PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote} - PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} - BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-} + PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:?PLAYER_PUBLIC_URL must be set to a routable remote-player URL} + BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:?BRIDGE_PUBLIC_URL must be set to a routable bridge URL} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000} volumes: diff --git a/package-lock.json b/package-lock.json index 5e10b51..e2345d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.10.2", + "version": "2.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.10.2", + "version": "2.10.3", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 0afcf0d..13f0403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.10.2", + "version": "2.10.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/common.js b/src/common.js index 081967b..4075c17 100644 --- a/src/common.js +++ b/src/common.js @@ -28,10 +28,13 @@ const data = require('#src/data'); const player = require('#src/player/render'); const listQuery = require('#src/web/lib/list-query'); const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers'); +const { findAvailableClientName } = require('#src/data/client-name-check'); module.exports = { createPool: dbCommon.createPool, pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices, + touchOnboardingDeviceLastSeen: dbCommon.touchOnboardingDeviceLastSeen, + findAvailableClientName: findAvailableClientName, ensureSchema: db.ensureSchema, bootstrapDatabase: dbBootstrap.bootstrapDatabase, slugify: data.slugify, diff --git a/src/data/announcement-icons.js b/src/data/announcement-icons.js index 57c4026..e421366 100644 --- a/src/data/announcement-icons.js +++ b/src/data/announcement-icons.js @@ -1,3 +1,5 @@ +// Load and expose the announcement icon catalog used by the editor and player. + const fs = require('fs'); const path = require('path'); diff --git a/src/data/app-settings.js b/src/data/app-settings.js index d0aa329..261e39c 100644 --- a/src/data/app-settings.js +++ b/src/data/app-settings.js @@ -1,3 +1,5 @@ +// Application-wide settings defaults and persistence helpers. + const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons'); const SETTING_DEFINITIONS = [ @@ -35,13 +37,13 @@ const SETTING_DEFINITIONS = [ { key: 'announcements.suggested_icons', type: 'string_array', defaultValue: DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice() }, { key: 'player.default_slide_duration_seconds', type: 'integer', min: 1, defaultValue: 10 }, { key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true }, - { key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true } - ,{ key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 } - ,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' } - ,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 } - ,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' } - ,{ key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' } - ,{ key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' } + { key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true }, + { key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }, + { key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }, + { key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }, + { key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }, + { key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' }, + { key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' } ]; const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) { diff --git a/src/data/audit-log.js b/src/data/audit-log.js index d892e7e..a91f643 100644 --- a/src/data/audit-log.js +++ b/src/data/audit-log.js @@ -1,3 +1,5 @@ +// Audit event definitions and data access helpers for administrative activity. + const AUDIT_EVENT_CATEGORIES = Object.freeze({ AUTHENTICATION: 'authentication', SECURITY: 'security', diff --git a/src/data/client-name-check.js b/src/data/client-name-check.js index 531c122..f48e902 100644 --- a/src/data/client-name-check.js +++ b/src/data/client-name-check.js @@ -23,6 +23,9 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn const normalizedDeviceId = normalizeDeviceId(excludeDeviceId); const live = collectLiveConnections(liveConnections); const lowerName = normalizedName.toLowerCase(); + const liveDeviceIds = new Set(live.map(function (connection) { + return normalizeDeviceId(connection && (connection.deviceId || connection.clientId)); + }).filter(Boolean)); try { if (pool) { @@ -32,12 +35,13 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn WHERE client_name IS NOT NULL AND TRIM(client_name) <> '' AND LOWER(TRIM(client_name)) = LOWER(TRIM(?)) - AND device_id <> ? - LIMIT 1`, + AND device_id <> ?`, [normalizedName, normalizedDeviceId] ); - if (deviceRows.length) { + if ((deviceRows || []).some(function (row) { + return liveDeviceIds.has(normalizeDeviceId(row && row.device_id)); + })) { return false; } } @@ -72,6 +76,22 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn } } +async function findAvailableClientName(pool, clientName, excludeDeviceId, liveConnections) { + const normalizedName = normalizeClientName(clientName); + if (!normalizedName) { + return ''; + } + + for (let suffix = 0; suffix < 1000; suffix += 1) { + const candidate = suffix === 0 ? normalizedName : `${normalizedName} (${suffix})`; + if (await isClientNameAvailable(pool, candidate, excludeDeviceId, liveConnections)) { + return candidate; + } + } + + return ''; +} + function buildClientNameLockName(clientName) { return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`; } @@ -116,5 +136,6 @@ module.exports = { normalizeDeviceId: normalizeDeviceId, collectLiveConnections: collectLiveConnections, isClientNameAvailable: isClientNameAvailable, + findAvailableClientName: findAvailableClientName, withClientNameReservation: withClientNameReservation }; \ No newline at end of file diff --git a/src/data/player-registry.js b/src/data/player-registry.js index 94477de..e84b3ad 100644 --- a/src/data/player-registry.js +++ b/src/data/player-registry.js @@ -1,3 +1,5 @@ +// Persistent player registration and heartbeat helpers shared by the web app and bridge. + function normalizeDeviceId(value) { return String(value || '') .trim() diff --git a/src/data/qr-code.js b/src/data/qr-code.js index f1889b3..6090a36 100644 --- a/src/data/qr-code.js +++ b/src/data/qr-code.js @@ -1,3 +1,5 @@ +// QR code generation helpers for player onboarding and administrative links. + const path = require('path'); const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js')); const QR_PNG_WIDTH = 2048; diff --git a/src/db/common.js b/src/db/common.js index 7ff71fe..0e5cd18 100644 --- a/src/db/common.js +++ b/src/db/common.js @@ -1,3 +1,5 @@ +// Database pool setup and lifecycle maintenance for persisted onboarding devices. + const mysql = require('mysql2/promise'); function createPool() { @@ -15,14 +17,34 @@ function createPool() { } async function pruneStaleOnboardingDevices(pool) { + // Uncompleted pairings expire quickly; completed bindings use their persisted heartbeat instead. await pool.query( `DELETE FROM d_onboarding_devices - WHERE screen_id IS NULL - AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` + WHERE (screen_id IS NULL + AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 15 MINUTE)) + OR (last_seen_at IS NOT NULL + AND last_seen_at < (CURRENT_TIMESTAMP - INTERVAL 24 HOUR))` + ); +} + +async function touchOnboardingDeviceLastSeen(pool, deviceId) { + const deviceIds = (Array.isArray(deviceId) ? deviceId : [deviceId]) + .map(function (value) { return String(value || '').trim(); }) + .filter(Boolean); + if (!deviceIds.length) { + return; + } + + await pool.query( + `UPDATE d_onboarding_devices + SET last_seen_at = CURRENT_TIMESTAMP + WHERE device_id IN (${deviceIds.map(function () { return '?'; }).join(', ')})`, + deviceIds ); } module.exports = { createPool, - pruneStaleOnboardingDevices + pruneStaleOnboardingDevices, + touchOnboardingDeviceLastSeen }; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js index 9da60a6..7ecb867 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,3 +1,5 @@ +// Schema initialization, migration execution, and database bootstrap helpers. + const { version: appVersion } = require('#root/package.json'); const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations'); @@ -331,6 +333,7 @@ async function ensureSchema(pool, options) { created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, + last_seen_at TIMESTAMP NULL, CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); diff --git a/src/db/migrations.js b/src/db/migrations.js index 8a5f0e9..4d1b882 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,3 +1,5 @@ +// Ordered database migrations kept independent from the application version. + const { version: appVersion } = require('#root/package.json'); const TIMETABLE_TIME_ZONE = 'Europe/London'; const APP_STATE_TABLE = 'o_app_state'; @@ -552,6 +554,13 @@ const VERSIONED_MIGRATIONS = [ run: async function (pool) { await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color'); } + }, + { + version: '2.10.2', + label: 'v2.10.2 onboarding client last-seen schema', + run: async function (pool) { + await ensureColumn(pool, 'd_onboarding_devices', 'last_seen_at', 'TIMESTAMP NULL', 'screen_id'); + } } ]; diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 942394d..aea954c 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -1,3 +1,5 @@ +// Thin-client bridge for player registration, snapshots, commands, and heartbeats. + const express = require('express'); const crypto = require('crypto'); const fs = require('fs'); @@ -8,7 +10,6 @@ const common = require('../common'); const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry'); const { createPlayerPlaylistService } = require('../player/playlist'); -const { buildThumbnailPreviewData } = require('../player/thumbnail-preview'); const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding'); const { createStyledQrCodeSvg } = require('../data/qr-code'); const { verifyPageAuthToken } = require('#src/request-auth'); @@ -458,7 +459,21 @@ async function start() { function storeScreenSnapshot(slug, connections, deviceIds) { const key = String(slug || '').trim(); - const normalizedConnections = Array.isArray(connections) ? connections : []; + const normalizedConnections = []; + const connectionIndexes = new Map(); + (Array.isArray(connections) ? connections : []).forEach(function (connection) { + const identity = connection && typeof connection === 'object' + ? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|') + : ''; + if (!identity || !connectionIndexes.has(identity)) { + if (identity) { + connectionIndexes.set(identity, normalizedConnections.length); + } + normalizedConnections.push(connection); + return; + } + normalizedConnections[connectionIndexes.get(identity)] = connection; + }); const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) { return normalizeDeviceId(value); }).filter(Boolean) : []; @@ -654,6 +669,16 @@ async function start() { }, response && typeof response === 'object' ? response : {}); })); + logBridge('Screen command result', { + screenSlug: slug, + command: command, + connectionId: connectionId || null, + targets: targetPlayers.map(function (target) { return target.deviceId; }), + results: results.map(function (result) { + return { playerIdentifier: result.playerIdentifier, ok: result.ok, status: result.status, error: result.error || null }; + }) + }); + res.json({ ok: true, screenSlug: slug, @@ -882,48 +907,6 @@ async function start() { } }); - 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 - }); - }); - } - - 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.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.params.deviceId); @@ -989,7 +972,14 @@ async function start() { return; } - setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []); + const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, ''); + const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) { + if (!connection || typeof connection !== 'object' || !snapshotPlayerPublicBaseUrl) { + return connection; + } + return Object.assign({}, connection, { playerPublicBaseUrl: snapshotPlayerPublicBaseUrl }); + }) : []; + setScreenSnapshotSource(slug, deviceId, connections); return; } @@ -1021,6 +1011,12 @@ async function start() { publicBaseUrl: payload.publicBaseUrl, internalBaseUrl: payload.internalBaseUrl }); + if (typeof common.touchOnboardingDeviceLastSeen === 'function') { + const onboardingClientIds = (Array.isArray(payload.connections) ? payload.connections : []).map(function (connection) { + return connection && connection.clientId; + }); + await common.touchOnboardingDeviceLastSeen(pool, onboardingClientIds); + } socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player })); return; diff --git a/src/player.js b/src/player.js index e8197ef..cda699a 100644 --- a/src/player.js +++ b/src/player.js @@ -1,3 +1,5 @@ +// Player application bootstrap, media routes, websocket runtime, and onboarding wiring. + const express = require('express'); const fs = require('fs'); const http = require('http'); @@ -10,8 +12,10 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { createOnboardingStore } = require('./player/onboarding/store'); const { registerPlayerRoutes } = require('./player/routes'); +const { getPlayerRuntimeScripts } = require('./player/render-helpers'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); const { createRequestAuthHeaders } = require('#src/request-auth'); +const { withClientNameReservation } = require('#src/data/client-name-check'); const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry'); @@ -52,11 +56,28 @@ async function start() { thinClientSocket.send(JSON.stringify({ type: 'snapshot', deviceId: PLAYER_DEVICE_ID, + playerPublicBaseUrl: getPlayerPublicBaseUrl(), slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '', connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : [] })); } catch (_error) { } + }, + persistClientName: async function (deviceId, clientName) { + if (!pool || !deviceId || !clientName) { + return; + } + await withClientNameReservation(pool, clientName, async function () { + await pool.query( + `UPDATE d_onboarding_devices + SET client_name = ?, modified_at = CURRENT_TIMESTAMP + WHERE device_id = ?`, + [clientName, deviceId] + ); + }); + }, + touchClientLastSeen: async function (deviceId) { + await common.touchOnboardingDeviceLastSeen(pool, deviceId); } }); const playerPlaylistService = isRemotePlayer @@ -320,6 +341,14 @@ async function start() { } } }); + app.get('/assets/player-script/:name.js', function (req, res) { + const script = getPlayerRuntimeScripts().find(function (entry) { return entry[0] === req.params.name; }); + if (!script) { + return res.sendStatus(404); + } + res.set('Cache-Control', 'no-cache'); + return res.type('application/javascript').send(script[1]); + }); registerPlayerRoutes(app, { pool: pool, common: common, @@ -328,6 +357,7 @@ async function start() { playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, rtmpStreamService: rtmpStreamService, + snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists'), playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID, @@ -391,7 +421,8 @@ async function start() { internalBaseUrl: PLAYER_INTERNAL_URL, pairingCode: activePairingCode, pairingCodes: activePairingCodes, - pairingSessions: activePairingSessions + pairingSessions: activePairingSessions, + connections: playerRuntime.snapshotAllConnections() })); } @@ -439,6 +470,7 @@ async function start() { socket.send(JSON.stringify({ type: 'snapshot', deviceId: PLAYER_DEVICE_ID, + playerPublicBaseUrl: getPlayerPublicBaseUrl(), slug: String(slug || '').trim(), connections: playerRuntime.snapshotConnections(slug) })); @@ -565,10 +597,6 @@ async function start() { console.error(error); }); - if (playerRuntime.snapshotAllConnections().length > 0) { - await common.pruneStaleOnboardingDevices(pool); - } - await onboardingStore.flushBindings(function (entry) { return commitDeviceBinding( pool, diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index f43ed15..22a57e3 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -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, diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 4b6a4fe..ec5ad9e 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -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; diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index abb43be..f34fe56 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -1,41 +1,11 @@ + + \ No newline at end of file diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 43cfbaf..1f4e5a2 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -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%; diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index 54be74b..e76f78e 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -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) { diff --git a/src/player/public/js/player-page-offline.js b/src/player/public/js/player-page-offline.js index 439f114..d7c97eb 100644 --- a/src/player/public/js/player-page-offline.js +++ b/src/player/public/js/player-page-offline.js @@ -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; } diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index d49bda1..54bcaa3 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -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( diff --git a/src/player/public/js/player-page-playlist.js b/src/player/public/js/player-page-playlist.js index 574e41e..73706d2 100644 --- a/src/player/public/js/player-page-playlist.js +++ b/src/player/public/js/player-page-playlist.js @@ -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) { diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 32a4731..3813e6b 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -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 }); } } diff --git a/src/player/public/js/player-region-registry.js b/src/player/public/js/player-region-registry.js new file mode 100644 index 0000000..2f6c914 --- /dev/null +++ b/src/player/public/js/player-region-registry.js @@ -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 + }; +}()); \ No newline at end of file diff --git a/src/player/regions/rtmp.js b/src/player/regions/rtmp.js index 1c70235..f9a2b57 100644 --- a/src/player/regions/rtmp.js +++ b/src/player/regions/rtmp.js @@ -1,3 +1,5 @@ +// RTMP region markup and playback lifecycle hooks. + var registry = window.pulsePlayerRegionTypes; function renderRtmpRegion(region, regionContent) { diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js index 4b3632f..a9fdc6f 100644 --- a/src/player/render-helpers.js +++ b/src/player/render-helpers.js @@ -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*]*)?>/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 }; \ No newline at end of file diff --git a/src/player/render.js b/src/player/render.js index 7513bfe..a7470a3 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -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 = ''; 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 ''; + }).join(''); return renderPage(template, { title: 'Screen ' + slug, - bodyClass: bodyClass, + bodyClass: '', body: '
Loading screen...
', stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [], - script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + '' + '' + '' + onboardingScript + script + '' + script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '' }); } diff --git a/src/player/routes.js b/src/player/routes.js index a866fcb..f710e33 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -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'); diff --git a/src/player/runtime.js b/src/player/runtime.js index 6e966c0..509cd17 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -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); }); diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js deleted file mode 100644 index b50d790..0000000 --- a/src/player/thumbnail-preview.js +++ /dev/null @@ -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 ? '' : ''; -} - -module.exports = { - buildThumbnailPreviewData: buildThumbnailPreviewData, - createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript -}; \ No newline at end of file diff --git a/src/web.js b/src/web.js index c22396f..64e1458 100644 --- a/src/web.js +++ b/src/web.js @@ -67,7 +67,12 @@ async function start() { playerInternalBaseUrl: webConfig.playerInternalUrl, bridgeInternalBaseUrl: webConfig.bridgeInternalUrl }); - const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); + const notifyPlayerScreens = createNotifyPlayerScreens( + playerActionService.forwardPlayerCommand, + playerActionService.getScreenConnections, + playerActionService.forwardPlayerCommandToBaseUrl, + playerActionService.resolvePlayerBaseUrlForPublicUrl + ); // Centralized dashboard/player bootstrap. const webBootstrap = createWebBootstrap({ @@ -165,6 +170,7 @@ async function start() { playerActionService: playerActionService, playerInternalBaseUrl: webConfig.playerInternalUrl, isClientNameAvailable: isClientNameAvailable, + findAvailableClientName: common.findAvailableClientName, withClientNameReservation: withClientNameReservation, requirePermission: function (permissionKey, options) { return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options)); diff --git a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js index b64945a..815bb9e 100644 --- a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js +++ b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js @@ -68,7 +68,7 @@ function registerRecurringDataSourceRefreshes(options) { category: TASK.category, intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit), metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name }, - run: function () { return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); } + run: function () { return refreshWeatherLocation(pool, common, location.id, null, options.notifyPlayerScreens); } }); }); })(); diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js index 0063ccc..f7c8c36 100644 --- a/src/web/lib/data-source-refresh.js +++ b/src/web/lib/data-source-refresh.js @@ -1,3 +1,5 @@ +// Refresh data sources and notify only the screens whose rendered data changed. + async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) { const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL'); const slideIds = []; @@ -106,6 +108,24 @@ function hasApiSourceChanged(apiSource, responseDetails) { return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson); } +function isDifferentHour(previousPulledAt, currentPulledAt) { + const previous = new Date(previousPulledAt); + const current = new Date(currentPulledAt); + if (!Number.isFinite(previous.getTime())) { + return false; + } + + return previous.getFullYear() !== current.getFullYear() + || previous.getMonth() !== current.getMonth() + || previous.getDate() !== current.getDate() + || previous.getHours() !== current.getHours(); +} + +function hasWeatherLocationChanged(location, responseDetails, refreshedAt) { + return normalizeSnapshotValue(location && location.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson) + || isDifferentHour(location && location.last_pulled_at, refreshedAt); +} + async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) { const connection = await pool.getConnection(); try { @@ -169,16 +189,17 @@ async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.'); } + const refreshedAt = new Date(); await connection.beginTransaction(); await connection.query( 'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?', - [new Date(), pullError || null, result ? result.responseJson : null, actorId, location.id] + [refreshedAt, pullError || null, result ? result.responseJson : null, actorId, location.id] ); await connection.commit(); if (pullError) { console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError); - } else if (typeof notifyAffectedScreens === 'function') { + } else if (hasWeatherLocationChanged(location, result, refreshedAt) && typeof notifyPlayerScreens === 'function') { try { await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id); } catch (notifyError) { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 1ffd552..e0d3425 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -389,6 +389,9 @@ function createUploadSyncService(options) { } const filePath = resolveUploadFilePath(uploadDir, uploadPath); + if (String(uploadPath || '').startsWith('/media/player-cache/')) { + continue; + } try { await fs.promises.unlink(filePath); } catch (error) { @@ -432,10 +435,6 @@ function createUploadSyncService(options) { const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName; const nextAbsolutePath = path.join(currentDir, entryName); - if (!relativeDir && entryName === 'player-cache') { - continue; - } - if (entry.isDirectory && entry.isDirectory()) { await walkDirectory(nextAbsolutePath, nextRelativePath); continue; @@ -445,7 +444,11 @@ function createUploadSyncService(options) { continue; } - uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/')); + const normalizedRelativePath = nextRelativePath.replace(/\\/g, '/'); + if (normalizedRelativePath.startsWith('player-cache/') && !normalizedRelativePath.startsWith('player-cache/remote-images/')) { + continue; + } + uploadPaths.push((normalizedRelativePath.startsWith('player-cache/') ? '/media/' : '/media/uploads/') + normalizedRelativePath); } } @@ -930,6 +933,12 @@ function createUploadSyncService(options) { }); }); const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir); + const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir); + generatedCachePaths.filter(function (uploadPath) { + return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); + }).forEach(function (uploadPath) { + uploadRefs.add(uploadPath); + }); Array.from(uploadRefs).forEach(function (uploadPath) { queuePlayerUploadSync({ type: 'put', @@ -957,8 +966,12 @@ function createUploadSyncService(options) { if (mode === 'playlist') { const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); - if (operation.nextUploadRefs.length) { - await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); + const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir); + const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) { + return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); + })); + if (nextUploadRefs.length) { + await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); } if (operation.previousUploadRefs.length) { diff --git a/src/web/lib/notify-player-screens.js b/src/web/lib/notify-player-screens.js index 7c408b2..a2c5f9c 100644 --- a/src/web/lib/notify-player-screens.js +++ b/src/web/lib/notify-player-screens.js @@ -1,4 +1,6 @@ -function createNotifyPlayerScreens(forwardPlayerCommand) { +// Route screen refresh commands to the physical player hosting each screen. + +function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) { if (typeof forwardPlayerCommand !== 'function') { throw new Error('createNotifyPlayerScreens requires a player command sender.'); } @@ -12,12 +14,29 @@ function createNotifyPlayerScreens(forwardPlayerCommand) { return Promise.resolve(0); } - return Promise.allSettled(uniqueSlugs.map(function (slug) { + return Promise.allSettled(uniqueSlugs.map(async function (slug) { + // A screen may have connections on multiple players after a remote move or reconnect. + if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') { + const screenState = await getScreenConnections(slug); + const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : []; + const playerBaseUrls = Array.from(new Set(connections.map(function (connection) { + return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); + }).filter(Boolean))); + if (playerBaseUrls.length) { + const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function' + ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); })) + : playerBaseUrls; + return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) { + return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh'); + })); + } + } return forwardPlayerCommand(slug, commandOrPayload || 'refresh'); })).then(function (results) { - return results.filter(function (result) { - return result.status === 'fulfilled'; + const successful = results.filter(function (result) { + return result.status === 'fulfilled' && (!result.value || result.value.ok !== false); }).length; + return successful; }); }; } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index 418f019..eb8d510 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,3 +1,5 @@ +// Commands and synchronization actions forwarded from the web app to players. + const { createRequestAuthHeaders } = require('#src/request-auth'); const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); @@ -215,6 +217,31 @@ function createPlayerActionService(options) { return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); } + async function resolvePlayerBaseUrlForPublicUrl(playerPublicBaseUrl) { + const normalizedPublicBaseUrl = normalizeBaseUrl(playerPublicBaseUrl); + if (!normalizedPublicBaseUrl) { + return ''; + } + + try { + const players = await fetchPlayerRegistrations(pool); + const matchedPlayer = (Array.isArray(players) ? players : []).find(function (player) { + return normalizeBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl; + }); + const registeredInternalBaseUrl = normalizeBaseUrl(matchedPlayer && matchedPlayer.internal_base_url); + if (registeredInternalBaseUrl) { + return registeredInternalBaseUrl; + } + } catch (_error) { + } + + if (isLocalLikeBaseUrl(normalizedPublicBaseUrl) && configuredPlayerInternalBaseUrl) { + return configuredPlayerInternalBaseUrl; + } + + return normalizedPublicBaseUrl; + } + async function forwardAnnouncementRefresh(slug) { const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const targetBaseUrls = Array.from(new Set([ @@ -367,6 +394,7 @@ function createPlayerActionService(options) { return { forwardPlayerCommand: forwardPlayerCommand, + resolvePlayerBaseUrlForPublicUrl: resolvePlayerBaseUrlForPublicUrl, forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js index 25495ab..dde8a14 100644 --- a/src/web/lib/region-scripts.js +++ b/src/web/lib/region-scripts.js @@ -1,3 +1,5 @@ +// Load and normalize browser region scripts for server-rendered player pages. + const fs = require('fs'); const path = require('path'); diff --git a/src/web/lib/web-startup.js b/src/web/lib/web-startup.js index 2e5d0ae..86f60b8 100644 --- a/src/web/lib/web-startup.js +++ b/src/web/lib/web-startup.js @@ -1,3 +1,5 @@ +// Start background services, scheduled tasks, and web application dependencies. + async function initializeWebServer(options) { const common = options && options.common; const pool = options && options.pool; diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index 3071129..8c9497b 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -969,8 +969,11 @@ } if (typeof rawJson !== 'string' || !rawJson.trim()) { - if (button) { - button.classList.add('d-none'); + if (collapseButton) { + collapseButton.classList.add('d-none'); + } + if (expandButton) { + expandButton.classList.add('d-none'); } return; } @@ -979,8 +982,11 @@ try { parsedJson = JSON.parse(rawJson); } catch (_error) { - if (button) { - button.classList.add('d-none'); + if (collapseButton) { + collapseButton.classList.add('d-none'); + } + if (expandButton) { + expandButton.classList.add('d-none'); } return; } diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 5a0c33b..d0dd622 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -35,7 +35,7 @@ function getSelectedScreenClients(state) { var select = getScreenCommandSelect(); var selectedSlug = select ? String(select.value || '').trim() : ''; - var clients = Array.isArray(state && state.clients) ? state.clients : []; + var clients = state && Array.isArray(state.clients) ? state.clients : []; if (!selectedSlug || selectedSlug === '__all__') { return clients; @@ -336,15 +336,16 @@ var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client'; var blackoutButtonText = blackout ? 'Restore' : 'Blackout'; var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; + var commandTargetId = client.clientId || client.id || ''; return [ '
', - '
', + '
', '', - '
', - '
', - '
', - '
', + '
', + '
', + '
', + '
', '
' ].join(''); } @@ -368,7 +369,7 @@ } var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); - var connectionId = String(row.getAttribute('data-client-id') || '').trim(); + var connectionId = String(row.getAttribute('data-client-client-id') || row.getAttribute('data-client-id') || '').trim(); var clientId = String(row.getAttribute('data-client-client-id') || '').trim(); var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim(); var clientNameCell = row.querySelector('td[data-label="Client"] > div'); @@ -428,7 +429,7 @@ } var connectionInput = pauseForm.querySelector('input[name="connectionId"]'); if (connectionInput) { - connectionInput.value = client.id || ''; + connectionInput.value = client.clientId || client.id || ''; } var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]'); if (playerBaseUrlInput) { @@ -447,7 +448,7 @@ if (reloadForm) { var reloadInput = reloadForm.querySelector('input[name="connectionId"]'); if (reloadInput) { - reloadInput.value = client.id || ''; + reloadInput.value = client.clientId || client.id || ''; } var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]'); if (reloadPlayerBaseUrlInput) { @@ -483,7 +484,7 @@ } var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]'); if (blackoutConnectionInput) { - blackoutConnectionInput.value = client.id || ''; + blackoutConnectionInput.value = client.clientId || client.id || ''; } var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]'); if (blackoutPlayerBaseUrlInput) { @@ -508,7 +509,7 @@ } var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]'); if (previousConnectionInput) { - previousConnectionInput.value = client.id || ''; + previousConnectionInput.value = client.clientId || client.id || ''; } var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]'); if (previousPlayerBaseUrlInput) { @@ -533,7 +534,7 @@ } var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]'); if (nextConnectionInput) { - nextConnectionInput.value = client.id || ''; + nextConnectionInput.value = client.clientId || client.id || ''; } var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]'); if (nextPlayerBaseUrlInput) { diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index c6ec0b4..5eb3214 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -93,8 +93,22 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev } var color = String(backgroundColor || '#111111').trim() || '#111111'; + var gradient = ''; + try { + var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient; + 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 (stopColor, index, colors) { return { color: stopColor, 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) { + var angle = Number(gradientData.angle); + gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')'; + } + } + } catch (_error) { + gradient = ''; + } element.style.backgroundColor = color; - element.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : 'none'; + element.style.backgroundImage = [backgroundImagePath ? 'url("' + encodeURI(String(backgroundImagePath)) + '")' : '', gradient].filter(Boolean).join(',') || 'none'; element.style.backgroundPosition = 'center'; element.style.backgroundSize = 'cover'; element.style.backgroundRepeat = 'no-repeat'; @@ -110,7 +124,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev 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) { diff --git a/src/web/public/js/web-ui-helpers.js b/src/web/public/js/web-ui-helpers.js index 4632d1f..62bae13 100644 --- a/src/web/public/js/web-ui-helpers.js +++ b/src/web/public/js/web-ui-helpers.js @@ -187,163 +187,8 @@ return ip; } - window.escapeHtml = escapeHtml; initializeUrlValidation(); - var defaultQrOptions = window.defaultQrOptions || { - width: 1000, - height: 1000, - type: 'canvas', - margin: 10, - qrOptions: {}, - dotsOptions: { - color: '#000000', - type: 'square' - }, - cornersSquareOptions: { - color: '#000000', - type: 'square' - }, - cornersDotOptions: { - color: '#000000', - type: 'square' - }, - backgroundOptions: { - color: '#ffffff' - } - }; - var qrDotTypes = window.qrDotTypes || [ - { value: 'square', label: 'Square' }, - { value: 'dots', label: 'Dots' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'extra-rounded', label: 'Extra rounded' }, - { value: 'classy', label: 'Classy' }, - { value: 'classy-rounded', label: 'Classy rounded' } - ]; - var qrCornerSquareTypes = window.qrCornerSquareTypes || [ - { value: 'square', label: 'Square' }, - { value: 'dot', label: 'Dot' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'extra-rounded', label: 'Extra rounded' }, - { value: 'dots', label: 'Dots' }, - { value: 'classy', label: 'Classy' }, - { value: 'classy-rounded', label: 'Classy rounded' } - ]; - var qrCornerDotTypes = window.qrCornerDotTypes || [ - { value: 'square', label: 'Square' }, - { value: 'dot', label: 'Dot' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'extra-rounded', label: 'Extra rounded' }, - { value: 'classy', label: 'Classy' }, - { value: 'classy-rounded', label: 'Classy rounded' } - ]; - - function getOptionValue(option) { - if (option && typeof option === 'object' && option.value !== undefined && option.value !== null) { - return String(option.value).trim(); - } - - return String(option === undefined || option === null ? '' : option).trim(); - } - - function normalizeMarginValue(value, fallback) { - var parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; - } - - function normalizeHexColorValue(value, fallback) { - var raw = String(value === undefined || value === null ? '' : value).trim(); - return raw || fallback; - } - - function normalizeBooleanValue(value, fallback) { - if (value === undefined || value === null || value === '') { - return Boolean(fallback); - } - if (typeof value === 'boolean') { - return value; - } - var text = String(value).trim().toLowerCase(); - if (text === 'true' || text === '1' || text === 'yes' || text === 'on') { - return true; - } - if (text === 'false' || text === '0' || text === 'no' || text === 'off') { - return false; - } - return Boolean(value); - } - - function normalizeOptionValue(value, options, fallback) { - var text = String(value === undefined || value === null ? '' : value).trim(); - for (var index = 0; index < options.length; index += 1) { - if (getOptionValue(options[index]) === text) { - return text; - } - } - - return fallback; - } - - function normalizeRatioValue(value, fallback) { - var parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - } - - function normalizeGradientTypeValue(value, fallback) { - var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase(); - return text === 'radial' ? 'radial' : 'linear'; - } - - function normalizeGradientRotationValue(value, fallback) { - var parsed = Number(value); - return Number.isFinite(parsed) ? Math.round(parsed) : fallback; - } - - function normalizeColorModeValue(value, fallback) { - var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase(); - return text === 'gradient' ? 'gradient' : 'single'; - } - - function normalizeColorValue(value, fallback) { - return normalizeHexColorValue(value, fallback); - } - - function renderSelectOptions(options, currentValue) { - return options.map(function (option) { - var optionValue = getOptionValue(option); - var optionLabel = option && typeof option === 'object' && option.label !== undefined ? option.label : optionValue; - return ''; - }).join(''); - } - - function normalizeSvgMarkup(svg) { - var markup = String(svg || '').trim(); - if (!markup || markup.charAt(0) !== '<') { - return markup; - } - - return markup.replace(/^]*)>/i, function (_match, attrText) { - var attrs = String(attrText || ''); - if (!/\bwidth\s*=\s*/i.test(attrs)) { - attrs += ' width="95%"'; - } - if (!/\bheight\s*=\s*/i.test(attrs)) { - attrs += ' height="95%"'; - } - if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) { - attrs += ' preserveAspectRatio="xMidYMid meet"'; - } - if (!/\bstyle\s*=\s*/i.test(attrs)) { - attrs += ' style="display:block;width:95%;height:95%;"'; - } - return ''; - }); - } - - function normalizeQrSvg(svg) { - return normalizeSvgMarkup(svg); - } - async function blobToText(blob) { if (!blob) { return ''; @@ -369,23 +214,8 @@ return ''; } - window.defaultQrOptions = defaultQrOptions; - window.qrDotTypes = qrDotTypes; - window.qrCornerSquareTypes = qrCornerSquareTypes; - window.qrCornerDotTypes = qrCornerDotTypes; - window.normalizeMarginValue = normalizeMarginValue; - window.normalizeHexColorValue = normalizeHexColorValue; - window.normalizeBooleanValue = normalizeBooleanValue; - window.normalizeOptionValue = normalizeOptionValue; - window.normalizeRatioValue = normalizeRatioValue; - window.normalizeGradientTypeValue = normalizeGradientTypeValue; - window.normalizeGradientRotationValue = normalizeGradientRotationValue; - window.normalizeColorModeValue = normalizeColorModeValue; - window.normalizeColorValue = normalizeColorValue; - window.renderSelectOptions = renderSelectOptions; - window.normalizeSvgMarkup = normalizeSvgMarkup; - window.normalizeQrSvg = normalizeQrSvg; window.blobToText = blobToText; + window.escapeHtml = escapeHtml; window.pendingByRegionId = window.pendingByRegionId || Object.create(null); window.webUiHelpers = { diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index 0f9f88a..2588cf0 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -9,6 +9,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; + const findAvailableClientName = deps.findAvailableClientName; const withClientNameReservation = deps.withClientNameReservation; const broadcastDashboardState = deps.broadcastDashboardState; const requirePermission = deps.requirePermission; @@ -297,8 +298,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) { liveConnections = []; } - const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections); - if (!available) { + const selectedClientName = await findAvailableClientName(pool, clientName, bindingDeviceId, liveConnections); + if (!selectedClientName) { return res.status(409).json({ error: 'Client name already exists.' }); } @@ -306,14 +307,14 @@ module.exports = function registerScreenCommandRoutes(app, deps) { if (playerBaseUrl) { await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, { command: command, - clientName: clientName, + clientName: selectedClientName, clientId: connectionId || deviceId || null, deviceId: deviceId || null }, connectionId || deviceId || undefined, deviceId || null); } else { await forwardPlayerCommandForConnections(slug, liveConnections, { command: command, - clientName: clientName, + clientName: selectedClientName, clientId: connectionId || deviceId || null, deviceId: deviceId || null }, connectionId || deviceId || undefined, deviceId || null); @@ -329,7 +330,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { command: command, connectionId: connectionId || null, deviceId: deviceId, - clientName: clientName, + clientName: selectedClientName, ok: true, liveOnly: true }); @@ -339,7 +340,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { `UPDATE d_onboarding_devices SET client_name = ?, modified_at = CURRENT_TIMESTAMP WHERE device_id = ?`, - [clientName, bindingDeviceId] + [selectedClientName, bindingDeviceId] ); if (!updateResult.affectedRows) { return res.status(404).json({ error: 'Client not found' }); @@ -351,7 +352,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { command: command, connectionId: connectionId || null, deviceId: deviceId, - clientName: clientName, + clientName: selectedClientName, ok: true }); }); @@ -360,42 +361,68 @@ module.exports = function registerScreenCommandRoutes(app, deps) { if (command === 'moveclient') { const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim(); let physicalPlayerId = ''; + let registeredPlayerBaseUrl = ''; const tabClientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim(); const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl); + const liveConnections = await fetchScreenConnections(slug); + const liveConnection = liveConnections.find(function (connection) { + const candidateConnectionId = String(connection && connection.id || '').trim(); + const candidateClientId = String(connection && connection.clientId || '').trim(); + return candidateConnectionId === connectionId || candidateClientId === connectionId; + }) || null; + const liveDeviceId = String(liveConnection && liveConnection.deviceId || '').trim(); + const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); - if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId) { + if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId && !liveDeviceId) { return res.status(400).json({ error: 'Client connection is required' }); } if (!targetScreenSlug) { return res.status(400).json({ error: 'Target screen is required' }); } - if (submittedPlayerBaseUrl) { + if (livePlayerBaseUrl || submittedPlayerBaseUrl) { const [playerRows] = await pool.query( - 'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1', - [submittedPlayerBaseUrl] + 'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1', + [livePlayerBaseUrl || submittedPlayerBaseUrl] ); - const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim(); - if (registeredPlayerId) { - physicalPlayerId = registeredPlayerId; - } + const registeredPlayer = playerRows[0] || null; + physicalPlayerId = String(registeredPlayer && registeredPlayer.identifier || '').trim(); + registeredPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(registeredPlayer && registeredPlayer.public_base_url); + } else if (legacyDeviceId || liveDeviceId) { + const [playerRows] = await pool.query( + 'SELECT identifier, public_base_url FROM d_players WHERE identifier = ? LIMIT 1', + [legacyDeviceId || liveDeviceId] + ); + const registeredPlayer = playerRows[0] || null; + physicalPlayerId = String(registeredPlayer && registeredPlayer.identifier || '').trim(); + registeredPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(registeredPlayer && registeredPlayer.public_base_url); } - - physicalPlayerId = physicalPlayerId || legacyDeviceId; if (!physicalPlayerId) { return res.status(400).json({ error: 'Registered player identity is required' }); } - const [currentRows] = await pool.query( - `SELECT d.client_name, s.slug AS current_screen_slug - FROM d_onboarding_devices d - LEFT JOIN d_screens s ON s.id = d.screen_id - WHERE d.device_id = ? - LIMIT 1`, - [physicalPlayerId] - ); + let [currentRows] = tabClientId + ? await pool.query( + `SELECT d.client_name, s.slug AS current_screen_slug + FROM d_onboarding_devices d + LEFT JOIN d_screens s ON s.id = d.screen_id + WHERE d.device_id = ? + LIMIT 1`, + [tabClientId] + ) + : [[]]; + if (!currentRows.length && physicalPlayerId && physicalPlayerId !== tabClientId) { + [currentRows] = await pool.query( + `SELECT d.client_name, s.slug AS current_screen_slug + FROM d_onboarding_devices d + LEFT JOIN d_screens s ON s.id = d.screen_id + WHERE d.device_id = ? + LIMIT 1`, + [physicalPlayerId] + ); + } const onboardingRow = currentRows[0] || null; const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim(); const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim(); @@ -418,7 +445,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) { }); } - const liveConnections = await fetchScreenConnections(slug); let status = null; if (onboardingRow) { const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]); @@ -485,14 +511,20 @@ module.exports = function registerScreenCommandRoutes(app, deps) { }; } let targetPlayerUrl = ''; - const liveConnection = Array.isArray(liveConnections) + const matchedLiveConnection = Array.isArray(liveConnections) ? liveConnections.find(function (connection) { - const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); + const candidateConnectionId = String(connection && connection.id || '').trim(); + const candidateClientId = String(connection && connection.clientId || '').trim(); const candidateDeviceId = String(connection && connection.deviceId || '').trim(); - return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId; + return candidateConnectionId === connectionId + || candidateClientId === connectionId + || candidateConnectionId === physicalPlayerId + || candidateClientId === physicalPlayerId + || candidateDeviceId === physicalPlayerId; }) || liveConnections[0] || null : null; - const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || ''); + const sourcePlayerBaseUrl = registeredPlayerBaseUrl + || normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl); if (sourcePlayerBaseUrl) { targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`; } @@ -505,8 +537,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) { connectionId: connectionId || null, screenSlug: targetScreenSlug }); - if (playerBaseUrl) { - await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, { + if (sourcePlayerBaseUrl) { + await forwardPlayerCommandForPlayerBaseUrl(slug, sourcePlayerBaseUrl, { command: 'redirect', url: targetPlayerUrl, moveToken: moveToken || null @@ -544,8 +576,17 @@ module.exports = function registerScreenCommandRoutes(app, deps) { } const liveConnections = await fetchScreenConnections(slug); - const result = playerBaseUrl - ? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null) + const liveConnection = connectionId + ? liveConnections.find(function (connection) { + const candidateConnectionId = String(connection && connection.id || '').trim(); + const candidateClientId = String(connection && connection.clientId || '').trim(); + return candidateConnectionId === connectionId || candidateClientId === connectionId; + }) + : null; + const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); + const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl; + const result = targetPlayerBaseUrl + ? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null) : await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null); if (typeof broadcastDashboardState === 'function') { diff --git a/src/web/routes/onboarding.js b/src/web/routes/onboarding.js index 0d26521..b600393 100644 --- a/src/web/routes/onboarding.js +++ b/src/web/routes/onboarding.js @@ -1,3 +1,5 @@ +// Web routes for pairing players and completing onboarding device bindings. + const { createRequestAuthHeaders } = require('#src/request-auth'); const { fetchPlayerRegistrations } = require('#src/data/player-registry'); const { renderView } = require('../view'); diff --git a/src/web/routes/register.js b/src/web/routes/register.js index a45906c..4787548 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -182,6 +182,7 @@ function registerSignageRoutes(app, deps) { forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, getScreenConnections: deps.playerActionService.getScreenConnections, isClientNameAvailable: deps.isClientNameAvailable, + findAvailableClientName: deps.findAvailableClientName, withClientNameReservation: deps.withClientNameReservation, broadcastDashboardState: deps.broadcastDashboardState, requirePermission: deps.requirePermission diff --git a/src/web/views/data-sources/weather/forecast-preview.hbs b/src/web/views/data-sources/weather/forecast-preview.hbs index 7bb3ee9..0e27413 100644 --- a/src/web/views/data-sources/weather/forecast-preview.hbs +++ b/src/web/views/data-sources/weather/forecast-preview.hbs @@ -3,7 +3,7 @@
{{#if weatherPreview.hasSnapshot}}
{{#each weatherPreview.forecast}}
{{day}}
{{condition}}
{{high}}°{{low}}°
{{rain}} rain
{{/each}}
-
Hourly forecast · next 24 hours
{{#each weatherPreview.hourly}}
{{day}}
{{temperature}}°
{{rain}}
{{/each}}
+
{{#each weatherPreview.hourly}}
{{day}}
{{temperature}}°
{{rain}}
{{/each}}
{{else}}
Daily and hourly forecasts will appear here after the first successful fetch.
{{/if}} diff --git a/src/web/views/signage/slides/popup-preview.hbs b/src/web/views/signage/slides/popup-preview.hbs index a21eaa4..a281288 100644 --- a/src/web/views/signage/slides/popup-preview.hbs +++ b/src/web/views/signage/slides/popup-preview.hbs @@ -183,7 +183,7 @@ var backgroundGradient = ''; try { var gradientData = typeof payload.backgroundGradient === 'string' ? JSON.parse(payload.backgroundGradient) : payload.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 gradientStops = 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 }; }); gradientStops = gradientStops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12); if (gradientStops.length >= 2) { diff --git a/test/admin-client-commands.test.js b/test/admin-client-commands.test.js index bfad052..7ebceaf 100644 --- a/test/admin-client-commands.test.js +++ b/test/admin-client-commands.test.js @@ -37,6 +37,9 @@ test('move client rebinding redirects the live player to the target screen', asy if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') { return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]]; } + if (sql.includes('SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ?')) { + return [[{ identifier: 'player-a', public_base_url: 'http://remote-player.example' }]]; + } if (sql.includes('INSERT INTO d_onboarding_devices')) { return [{ affectedRows: 1 }]; } @@ -152,6 +155,61 @@ test('move client rebinding redirects the live player to the target screen', asy assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true); }); +test('move client requires a registered player identity', async () => { + const { app, handlers } = createHandlers(); + + registerScreenCommandRoutes(app, { + pool: { + async query(sql) { + if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) { + return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]]; + } + return [[]]; + } + }, + common: { fetchPlayerRegistrations: async () => [] }, + forwardPlayerCommand: async () => ({ ok: true }), + forwardPlayerCommandToBaseUrl: async () => ({ ok: true }), + getScreenConnections: async () => ({ connections: [] }), + isClientNameAvailable: async () => true, + withClientNameReservation: async (_pool, _name, callback) => callback(), + broadcastDashboardState: async () => {}, + requirePermission() { + return function (_req, _res, next) { + next(); + }; + } + }); + + const response = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(value) { + this.body = value; + return this; + } + }; + + await handlers['/clients/:slug/commands'][1]({ + params: { slug: 'source-screen' }, + body: { + command: 'moveclient', + deviceId: 'unregistered-device', + clientName: 'Lobby Client', + targetScreenSlug: 'target-screen', + playerBaseUrl: 'http://unregistered-player.example' + }, + query: {} + }, response, () => {}); + + assert.equal(response.statusCode, 400); + assert.equal(response.body.error, 'Registered player identity is required'); +}); + test('screen control commands can target all screens', async () => { const calls = []; const { app, handlers } = createHandlers(); diff --git a/test/client-name-check.test.js b/test/client-name-check.test.js index 4c33214..90d6af1 100644 --- a/test/client-name-check.test.js +++ b/test/client-name-check.test.js @@ -4,6 +4,7 @@ const crypto = require('node:crypto'); const { collectLiveConnections, + findAvailableClientName, isClientNameAvailable, normalizeClientName, normalizeDeviceId, @@ -22,7 +23,7 @@ test('collectLiveConnections returns an array safely', () => { assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]); }); -test('isClientNameAvailable rejects matching db rows and live connections', async () => { +test('isClientNameAvailable ignores stored names for offline devices', async () => { const pool = { async query(sql, params) { assert.match(sql, /FROM d_onboarding_devices/); @@ -31,12 +32,28 @@ test('isClientNameAvailable rejects matching db rows and live connections', asyn } }; - assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false); + assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), true); + assert.equal(await isClientNameAvailable(pool, 'Screen A', 'device-01', [{ clientName: 'screen a', deviceId: 'device-99' }]), false); assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false); assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true); assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false); }); +test('findAvailableClientName adds the first free numeric suffix', async () => { + const occupiedNames = new Set(['Lobby', 'Lobby (1)']); + const pool = { + async query(_sql, params) { + const name = params[0]; + return [[occupiedNames.has(name) ? { device_id: 'active-device' } : undefined].filter(Boolean)]; + } + }; + + assert.equal( + await findAvailableClientName(pool, 'Lobby', 'new-device', [{ deviceId: 'active-device' }]), + 'Lobby (2)' + ); +}); + test('withClientNameReservation acquires and releases locks around the handler', async () => { const calls = []; const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`; diff --git a/test/data-source-refresh.test.js b/test/data-source-refresh.test.js index 64a65e6..44a0cbd 100644 --- a/test/data-source-refresh.test.js +++ b/test/data-source-refresh.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh'); +const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../src/web/lib/data-source-refresh'); function createConnection(options) { const state = Object.assign({ @@ -178,3 +178,86 @@ test('refreshRssFeed skips notifications when the RSS items are unchanged', asyn assert.deepEqual(notifyCalls, []); }); + +test('refreshWeatherLocation notifies players when the forecast changes', async () => { + const connection = createConnection({ + slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }], + screenRows: [{ slug: 'screen-c' }] + }); + const notifyCalls = []; + const pool = { async getConnection() { return connection; } }; + const common = { + async fetchWeatherLocationForecast() { + return { responseJson: JSON.stringify({ temperature: 21 }) }; + }, + parseJsonSafe(value) { + return JSON.parse(value); + } + }; + + await refreshWeatherLocation(pool, common, { + id: 4, + last_pulled_at: new Date(), + last_response_json: JSON.stringify({ temperature: 20 }) + }, 42, async function (slugs, payload) { + notifyCalls.push({ slugs, payload }); + }); + + assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]); +}); + +test('refreshWeatherLocation skips notifications when the forecast is unchanged within the hour', async () => { + const connection = createConnection({ + slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }], + screenRows: [{ slug: 'screen-c' }] + }); + const notifyCalls = []; + const pool = { async getConnection() { return connection; } }; + const snapshot = JSON.stringify({ temperature: 20 }); + const common = { + async fetchWeatherLocationForecast() { + return { responseJson: snapshot }; + }, + parseJsonSafe(value) { + return JSON.parse(value); + } + }; + + await refreshWeatherLocation(pool, common, { + id: 4, + last_pulled_at: new Date(), + last_response_json: snapshot + }, 42, async function (slugs, payload) { + notifyCalls.push({ slugs, payload }); + }); + + assert.deepEqual(notifyCalls, []); +}); + +test('refreshWeatherLocation notifies when an unchanged forecast crosses into a new hour', async () => { + const connection = createConnection({ + slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }], + screenRows: [{ slug: 'screen-c' }] + }); + const notifyCalls = []; + const pool = { async getConnection() { return connection; } }; + const snapshot = JSON.stringify({ temperature: 20 }); + const common = { + async fetchWeatherLocationForecast() { + return { responseJson: snapshot }; + }, + parseJsonSafe(value) { + return JSON.parse(value); + } + }; + + await refreshWeatherLocation(pool, common, { + id: 4, + last_pulled_at: new Date('2000-01-01T00:00:00.000Z'), + last_response_json: snapshot + }, 42, async function (slugs, payload) { + notifyCalls.push({ slugs, payload }); + }); + + assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]); +}); diff --git a/test/notify-player-screens.test.js b/test/notify-player-screens.test.js new file mode 100644 index 0000000..7abbb93 --- /dev/null +++ b/test/notify-player-screens.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createNotifyPlayerScreens } = require('../src/web/lib/notify-player-screens'); + +test('playlist notifications use the player URL reported by the screen connection', async () => { + const calls = []; + const notify = createNotifyPlayerScreens( + async function () { + calls.push({ type: 'fallback' }); + return { ok: true }; + }, + async function () { + return { + connections: [{ playerPublicBaseUrl: 'http://192.168.0.80:8082' }] + }; + }, + async function (baseUrl, slug, command) { + calls.push({ baseUrl, slug, command }); + return { ok: true }; + }, + async function () { + return 'http://player:8081'; + } + ); + + const count = await notify(['test'], 'refresh'); + + assert.equal(count, 1); + assert.deepEqual(calls, [{ + baseUrl: 'http://player:8081', + slug: 'test', + command: 'refresh' + }]); +}); diff --git a/test/onboarding-device-prune.test.js b/test/onboarding-device-prune.test.js index aaa624f..04701a4 100644 --- a/test/onboarding-device-prune.test.js +++ b/test/onboarding-device-prune.test.js @@ -14,7 +14,27 @@ test('prunes old unbound onboarding devices without deleting completed bindings' await pruneStaleOnboardingDevices(pool); - assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 1 MINUTE\)/); - assert.match(queryText, /WHERE screen_id IS NULL/); + assert.match(queryText, /screen_id IS NULL/); + assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 15 MINUTE\)/); + assert.match(queryText, /last_seen_at < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/); assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/); +}); + +test('updates an onboarding device last-seen timestamp by device id', async () => { + let queryText = ''; + let queryParams = null; + const pool = { + async query(sql, params) { + queryText = sql; + queryParams = params; + return [[]]; + } + }; + + const { touchOnboardingDeviceLastSeen } = require('../src/db/common'); + await touchOnboardingDeviceLastSeen(pool, ['device-123', 'client-123']); + + assert.match(queryText, /SET last_seen_at = CURRENT_TIMESTAMP/); + assert.match(queryText, /WHERE device_id IN \(\?, \?\)/); + assert.deepEqual(queryParams, ['device-123', 'client-123']); }); \ No newline at end of file diff --git a/test/player-runtime.test.js b/test/player-runtime.test.js index e109909..7d73106 100644 --- a/test/player-runtime.test.js +++ b/test/player-runtime.test.js @@ -96,6 +96,52 @@ test('player runtime snapshots websocket state and checks live names', async () } }); +test('player runtime suffixes a reconnecting client name already in use', async () => { + process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret'; + + const persistedNames = []; + const runtime = createPlayerRuntime({ + pool: { + async query(_sql, params) { + return [[params[0] === 'Lobby' && params[1] !== 'device-a' ? { device_id: 'device-a' } : undefined].filter(Boolean)]; + } + }, + persistClientName(deviceId, clientName) { + persistedNames.push({ deviceId, clientName }); + return Promise.resolve(); + } + }); + const server = http.createServer(); + runtime.installWebsocket(server); + + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address(); + const token = createPageAuthToken({ scope: 'player', slug: 'reconnect-test' }); + const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`); + const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`); + + try { + await Promise.all([ + new Promise((resolve, reject) => { clientA.once('open', resolve); clientA.once('error', reject); }), + new Promise((resolve, reject) => { clientB.once('open', resolve); clientB.once('error', reject); }) + ]); + clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Lobby', deviceId: 'device-a' })); + await waitFor(() => runtime.snapshotConnections('reconnect-test').some((connection) => connection.clientName === 'Lobby')); + clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Lobby', deviceId: 'device-b' })); + + await waitFor(() => { + const connections = runtime.snapshotConnections('reconnect-test'); + return connections.length === 2 && connections.some((connection) => connection.clientName === 'Lobby (1)'); + }); + + assert.deepEqual(persistedNames, [{ deviceId: 'device-b', clientName: 'Lobby (1)' }]); + } finally { + clientA.close(); + clientB.close(); + await new Promise((resolve) => server.close(resolve)); + } +}); + test('player runtime accepts websocket auth from cookies', async () => { process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret'; @@ -198,4 +244,30 @@ test('player runtime sends targeted and broadcast commands to live sockets', asy clientB.close(); await new Promise((resolve) => server.close(resolve)); } +}); + +test('player runtime removes browser connections that stop sending state', async () => { + process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret'; + + const runtime = createPlayerRuntime({ pool: null, staleConnectionMs: 50 }); + const server = http.createServer(); + runtime.installWebsocket(server); + + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address(); + const token = createPageAuthToken({ scope: 'player', slug: 'stale-test' }); + const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/stale-test?auth=${encodeURIComponent(token)}`); + + try { + await new Promise((resolve, reject) => { + client.once('open', resolve); + client.once('error', reject); + }); + client.send(JSON.stringify({ type: 'state', clientId: 'stale-client', clientName: 'Stale Player' })); + await waitFor(() => runtime.snapshotConnections('stale-test').length === 1); + await waitFor(() => runtime.snapshotConnections('stale-test').length === 0, 1000); + } finally { + client.close(); + await new Promise((resolve) => server.close(resolve)); + } }); \ No newline at end of file diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index 82f8b90..a52c870 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi } ]); - const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.1' }); + const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.2' }); assert.equal(pendingMigrations.length, 0); }); diff --git a/test/weather-forecast.test.js b/test/weather-forecast.test.js index 9e0f5f2..a732b7d 100644 --- a/test/weather-forecast.test.js +++ b/test/weather-forecast.test.js @@ -35,5 +35,8 @@ test('weather forecast preview only shows the fetch hint without a snapshot', () const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8'); assert.match(template, /\{\{#if weatherPreview\.hasSnapshot\}\}[\s\S]*weather-daily-forecast[\s\S]*weather-hourly-forecast[\s\S]*\{\{\/if\}\}/); + assert.match(template, /id="weather-daily-forecast" class="weather-daily-forecast"/); + assert.match(template, /id="weather-hourly-forecast" class="d-none"/); + assert.doesNotMatch(template, /Hourly forecast · next 24 hours/); assert.match(template, /Daily and hourly forecasts will appear here after the first successful fetch\./); }); \ No newline at end of file