diff --git a/CHANGELOG.md b/CHANGELOG.md index 0683f1c..a691227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.6.7 - 2026-08-08 + +### Fixed + +- Admin client commands now stay on the bridge for remote players, so screen control no longer depends on a public player address. +- The connected-clients screen-group controls now use the same async bulk-command path as the dashboard, including the All Screens option and live pause/blackout toggles. + ## 2.6.6 - 2026-08-07 ### Fixed diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 1fbc4db..bbc954d 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -12,15 +12,15 @@ MYSQL_ROOT_PASSWORD="root_password" # Player settings PLAYER_IDENTIFIER="player-local" -PLAYER_PUBLIC_BASE_URL="http://localhost:8081" -PLAYER_INTERNAL_BASE_URL="http://player:8081" +PLAYER_PUBLIC_URL="http://localhost:8081" +PLAYER_INTERNAL_URL="http://player:8081" # Web app bootstrap settings SESSION_MAX_AGE_DAYS=14 DEFAULT_ADMIN_USERNAME="admin" DEFAULT_ADMIN_NAME="Admin" -DEFAULT_ADMIN_PASSWORD="admin" -PASSWORD_HASH_ITERATIONS=310000 +DEFAULT_ADMIN_PASSWORD="password123" # Bridge settings for the player-bridge service -WEB_BASE_URL="http://web:8080" \ No newline at end of file +WEB_INTERNAL_URL="http://web:8080" +BRIDGE_INTERNAL_URL="http://player-bridge:8090" \ No newline at end of file diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index b68f036..afcab8b 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -4,8 +4,8 @@ PULSE_SIGNAGE_SHARED_SECRET="" # Player settings PLAYER_IDENTIFIER="player-remote" -PLAYER_PUBLIC_BASE_URL="http://localhost:8081" +PLAYER_PUBLIC_URL="http://localhost:8081" PLAYER_AGENT_RECONNECT_DELAY_MS=5000 # Remote player connectivity settings -THIN_CLIENT_BASE_URL="http://player-agent:8090" \ No newline at end of file +BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090" \ No newline at end of file diff --git a/docker-compose/README.md b/docker-compose/README.md index 9a68ef3..81b17fe 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -59,15 +59,16 @@ Responsibilities: - serves the player UI on port `8081` - connects to MySQL in local mode -- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL` +- connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL` - registers live connections and accepts control commands Key configuration: -- `PLAYER_PUBLIC_BASE_URL` -- `PLAYER_INTERNAL_BASE_URL` +- `PLAYER_PUBLIC_URL` +- `PLAYER_INTERNAL_URL` +- `BRIDGE_INTERNAL_URL` - `PLAYER_IDENTIFIER` -- `THIN_CLIENT_BASE_URL` in remote mode +- `BRIDGE_PUBLIC_URL` in remote mode - `PULSE_SIGNAGE_SHARED_SECRET` - database settings in local mode @@ -85,7 +86,7 @@ Responsibilities: Key configuration: - `PULSE_SIGNAGE_SHARED_SECRET` -- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers +- `WEB_INTERNAL_URL` for the bridge when it should call the web app directly instead of inferring from request headers - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` ### `mysql` @@ -116,11 +117,14 @@ Important values: - `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests - `PLAYER_IDENTIFIER` - unique local player identifier - `DB_*` - MySQL credentials and database name for the stack -- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises -- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls +- `PLAYER_PUBLIC_URL` - public URL the player advertises +- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls +- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding +- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly - `SESSION_MAX_AGE_DAYS` - dashboard session lifetime - `DEFAULT_ADMIN_*` - bootstrap admin account values - `PASSWORD_HASH_ITERATIONS` - password hashing cost +- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container ### `.env.remote.example` @@ -131,8 +135,8 @@ Important values: - `PULSE_SIGNAGE_IMAGE` - image to run on the device - `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment - `PLAYER_IDENTIFIER` - unique remote player identifier -- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player -- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to +- `PLAYER_PUBLIC_URL` - public URL for the remote player +- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to - `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent ### `PULSE_SIGNAGE_SHARED_SECRET` @@ -166,11 +170,16 @@ Leave it blank only if you intentionally want to run without request signing in | `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. | | `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. | | `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. | -| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. | -| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. | -| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. | +| `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. | +| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. | +| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. | +| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. | +| `BRIDGE_PUBLIC_URL` | player, remote player | URL of the bridge service. | | `PLAYER_IDENTIFIER` | player | Stable player identifier. | | `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. | +| `MYSQL_DATABASE` | mysql | Database name used by the local MySQL container. | +| `MYSQL_USER` | mysql | Database user used by the local MySQL container. | +| `MYSQL_PASSWORD` | mysql | Database password used by the local MySQL container. | ## Ports @@ -208,7 +217,7 @@ Each compose file creates its own named network: - The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`. - A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to. - The bridge service is the dashboard-facing command path for connected remote players. -- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint. +- The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint. - The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. ## Recommended Setup diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml index 2da1408..1b62cd8 100644 --- a/docker-compose/docker-compose.remote.yml +++ b/docker-compose/docker-compose.remote.yml @@ -10,8 +10,8 @@ services: ports: - "8081:8081" environment: - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} - THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-} + PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} + BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} volumes: - pulse-signage:/app/media diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 9c8a024..385d5b0 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -15,11 +15,11 @@ services: DB_USER: ${DB_USER:-pulse-signage} DB_PASSWORD: ${DB_PASSWORD:-signage_password} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} - DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} - PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} + DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123} volumes: - pulse-signage:/app/media command: ["node", "src/web.js"] @@ -35,8 +35,8 @@ services: ports: - "8081:8081" environment: - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} - PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} + PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} + PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} @@ -59,7 +59,7 @@ services: ports: - "8090:8090" environment: - WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080} + WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} diff --git a/package.json b/package.json index 4556c1a..a02e80f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.6", + "version": "2.6.7", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 8c1ce9c..886d686 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -44,6 +44,55 @@ function formatPlayerConnectionLabel(deviceId, remoteAddress) { return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId; } +function normalizeProxyBaseUrl(value) { + const normalized = String(value || '').trim().replace(/\/$/, ''); + if (!normalized) { + return ''; + } + + try { + const url = new URL(normalized); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { + url.hostname = 'host.docker.internal'; + } + return url.toString().replace(/\/$/, ''); + } catch (_error) { + return normalized; + } +} + +function isLocalLikeBaseUrl(value) { + let host = ''; + try { + host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase(); + } catch (_error) { + return false; + } + + return host === 'localhost' + || host === '127.0.0.1' + || host === '::1' + || host === 'host.docker.internal' + || host === 'player' + || host === 'player-dev' + || host === 'player-local' + || host === 'player-bridge-dev' + || host === 'web' + || host === 'player-bridge' + || host.endsWith('.local') + || host.endsWith('.internal') + || host.endsWith('.docker.internal'); +} + +function resolveSnapshotUpstreamBaseUrl(player) { + const internalBaseUrl = normalizeProxyBaseUrl(player && player.internal_base_url); + if (internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl)) { + return internalBaseUrl; + } + + return normalizeProxyBaseUrl(player && player.public_base_url) || null; +} + function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { const key = String(slug || '').trim(); if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { @@ -51,24 +100,38 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) } const deviceIds = screenPlayerDeviceIds.get(key); - if (!Array.isArray(deviceIds) || !deviceIds.length) { - return []; + const targets = Array.isArray(deviceIds) + ? Array.from(new Set(deviceIds.map(function (value) { + return normalizeDeviceId(value); + }).filter(Boolean))).map(function (deviceId) { + const socket = playerSockets.get(deviceId); + if (!socket || socket.readyState !== WebSocket.OPEN) { + return null; + } + + return { deviceId: deviceId, socket: socket }; + }).filter(Boolean) + : []; + if (targets.length) { + return targets; } - return Array.from(new Set(deviceIds.map(function (value) { - return normalizeDeviceId(value); - }).filter(Boolean))).map(function (deviceId) { - const socket = playerSockets.get(deviceId); - if (!socket || socket.readyState !== WebSocket.OPEN) { - return null; - } + const fallbackTargets = Array.from(playerSockets.values()).filter(function (socket) { + return socket && socket.readyState === WebSocket.OPEN; + }).map(function (socket) { + return { + deviceId: String(socket.playerDeviceId || '').trim(), + socket: socket + }; + }).filter(function (target) { + return Boolean(target.deviceId); + }); - return { deviceId: deviceId, socket: socket }; - }).filter(Boolean); + return fallbackTargets.length === 1 ? fallbackTargets : []; } function resolveWebBaseUrl(req) { - const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, ''); + const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); if (configuredWebBaseUrl) { return configuredWebBaseUrl; } @@ -118,24 +181,130 @@ async function start() { const screenSnapshotsWs = new WebSocketServer({ noServer: true }); const playerSockets = new Map(); const screenSnapshotCache = new Map(); + const screenSnapshotSourcesBySlug = new Map(); + const screenSnapshotSubscribersBySlug = new Map(); const screenPlayerDeviceIds = new Map(); const pendingPlayerCommands = new Map(); - function normalizeProxyBaseUrl(value) { - const normalized = String(value || '').trim().replace(/\/$/, ''); - if (!normalized) { - return ''; + function getScreenSnapshotSourceBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; } - try { - const url = new URL(normalized); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { - url.hostname = 'host.docker.internal'; - } - return url.toString().replace(/\/$/, ''); - } catch (_error) { - return normalized; + if (!screenSnapshotSourcesBySlug.has(key)) { + screenSnapshotSourcesBySlug.set(key, new Map()); } + + return screenSnapshotSourcesBySlug.get(key); + } + + function getScreenSnapshotSubscriberBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; + } + + if (!screenSnapshotSubscribersBySlug.has(key)) { + screenSnapshotSubscribersBySlug.set(key, new Set()); + } + + return screenSnapshotSubscribersBySlug.get(key); + } + + function buildMergedScreenSnapshot(slug) { + const key = String(slug || '').trim(); + const sourceBucket = screenSnapshotSourcesBySlug.get(key); + const connections = []; + const deviceIds = []; + + if (sourceBucket && typeof sourceBucket.forEach === 'function') { + sourceBucket.forEach(function (payload) { + if (payload && Array.isArray(payload.connections)) { + connections.push.apply(connections, payload.connections); + } + }); + sourceBucket.forEach(function (_payload, sourceKey) { + deviceIds.push(sourceKey); + }); + } + + return { + slug: key, + count: connections.length, + connections: connections, + deviceIds: deviceIds + }; + } + + function broadcastScreenSnapshot(slug) { + const key = String(slug || '').trim(); + const snapshot = buildMergedScreenSnapshot(key); + storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds); + + const bucket = screenSnapshotSubscribersBySlug.get(key); + if (!bucket || !bucket.size) { + return; + } + + const payload = JSON.stringify({ + type: 'snapshot', + slug: key, + connections: snapshot.connections, + sentAt: new Date().toISOString() + }); + + bucket.forEach(function (socket) { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(payload); + } + }); + } + + function setScreenSnapshotSource(slug, sourceKey, connections) { + const key = String(slug || '').trim(); + const normalizedSourceKey = String(sourceKey || '').trim(); + if (!key || !normalizedSourceKey) { + return; + } + + const bucket = getScreenSnapshotSourceBucket(key); + if (!bucket) { + return; + } + + bucket.set(normalizedSourceKey, { + slug: key, + connections: Array.isArray(connections) ? connections : [] + }); + broadcastScreenSnapshot(key); + } + + function clearScreenSnapshotSource(slug, sourceKey) { + const key = String(slug || '').trim(); + const normalizedSourceKey = String(sourceKey || '').trim(); + const bucket = screenSnapshotSourcesBySlug.get(key); + if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) { + return; + } + + bucket.delete(normalizedSourceKey); + if (!bucket.size) { + screenSnapshotSourcesBySlug.delete(key); + } + + broadcastScreenSnapshot(key); + } + + function clearPlayerSnapshotSources(sourceKey) { + const normalizedSourceKey = String(sourceKey || '').trim(); + if (!normalizedSourceKey) { + return; + } + + Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) { + clearScreenSnapshotSource(slug, normalizedSourceKey); + }); } async function fetchPlayerSnapshotRegistrations() { @@ -641,6 +810,16 @@ async function start() { socket.playerDeviceId = deviceId; + if (messageType === 'snapshot') { + const slug = String(payload.slug || '').trim(); + if (!slug) { + return; + } + + setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []); + return; + } + if (messageType === 'register') { const player = await upsertPlayerRegistration(pool, { deviceId: deviceId, @@ -719,12 +898,14 @@ async function start() { }); socket.on('close', function () { + clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } }); socket.on('error', function () { + clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } @@ -734,33 +915,18 @@ async function start() { screenSnapshotsWs.on('connection', function (socket, request, slug) { const normalizedSlug = String(slug || '').trim(); const upstreamSockets = new Map(); - const upstreamSnapshots = new Map(); const upstreamDeviceIds = new Set(); let refreshTimer = null; let closed = false; - function sendMergedSnapshot() { - if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) { - return; - } - - const connections = []; - upstreamSnapshots.forEach(function (payload) { - if (payload && Array.isArray(payload.connections)) { - connections.push.apply(connections, payload.connections); - } - }); - - storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds)); - - socket.send(JSON.stringify({ - type: 'snapshot', - slug: normalizedSlug, - connections: connections, - sentAt: new Date().toISOString() - })); + const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug); + if (!subscriberBucket) { + socket.close(); + return; } + subscriberBucket.add(socket); + function closeUpstreamSockets() { upstreamSockets.forEach(function (upstreamSocket) { try { @@ -769,7 +935,6 @@ async function start() { } }); upstreamSockets.clear(); - upstreamSnapshots.clear(); } async function refreshUpstreams() { @@ -786,12 +951,19 @@ async function start() { const seenKeys = new Set(); players.forEach(function (player) { - const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); + const sourceKey = String(player && player.identifier || player && player.id || '').trim(); + const connectedSocket = sourceKey ? playerSockets.get(sourceKey) : null; + if (connectedSocket && connectedSocket.readyState === WebSocket.OPEN) { + seenKeys.add(sourceKey); + upstreamDeviceIds.add(sourceKey); + return; + } + + const baseUrl = resolveSnapshotUpstreamBaseUrl(player); if (!baseUrl) { return; } - const sourceKey = String(player && player.identifier || player && player.id || baseUrl); seenKeys.add(sourceKey); upstreamDeviceIds.add(sourceKey); if (upstreamSockets.has(sourceKey)) { @@ -814,20 +986,15 @@ async function start() { if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) { return; } - upstreamSnapshots.set(sourceKey, { - slug: normalizedSlug, - connections: Array.isArray(payload.connections) ? payload.connections : [] - }); - sendMergedSnapshot(); + setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []); } catch (_error) { } }; upstreamSocket.onclose = function () { upstreamSockets.delete(sourceKey); - upstreamSnapshots.delete(sourceKey); - if (!closed) { - sendMergedSnapshot(); + if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) { + clearScreenSnapshotSource(normalizedSlug, sourceKey); } }; @@ -852,7 +1019,7 @@ async function start() { } }); - sendMergedSnapshot(); + broadcastScreenSnapshot(normalizedSlug); } refreshUpstreams(); @@ -863,6 +1030,10 @@ async function start() { socket.on('close', function () { closed = true; + subscriberBucket.delete(socket); + if (!subscriberBucket.size) { + screenSnapshotSubscribersBySlug.delete(normalizedSlug); + } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; @@ -872,6 +1043,10 @@ async function start() { socket.on('error', function () { closed = true; + subscriberBucket.delete(socket); + if (!subscriberBucket.size) { + screenSnapshotSubscribersBySlug.delete(normalizedSlug); + } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; @@ -915,7 +1090,12 @@ async function start() { }); } -module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets }; +module.exports = { + start: start, + resolveWebBaseUrl: resolveWebBaseUrl, + resolveScreenCommandTargets: resolveScreenCommandTargets, + resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl +}; if (require.main === module) { start().catch(function (error) { diff --git a/src/player.js b/src/player.js index 8a3c67d..1f12a9c 100644 --- a/src/player.js +++ b/src/player.js @@ -18,21 +18,37 @@ const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/d // Player runtime, media API, and websocket wiring. async function start() { const app = express(); - const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); + const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 8081); - const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); - const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL); - const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, ''); + const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); + const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL); + const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, ''); const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier(); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); + let thinClientSocket = null; const playerRuntime = createPlayerRuntime({ pool: pool, - normalizeDeviceId: normalizeDeviceId + normalizeDeviceId: normalizeDeviceId, + notifySnapshot: function (snapshot) { + if (!thinClientSocket || thinClientSocket.readyState !== WebSocket.OPEN) { + return; + } + + try { + thinClientSocket.send(JSON.stringify({ + type: 'snapshot', + deviceId: PLAYER_DEVICE_ID, + slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '', + connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : [] + })); + } catch (_error) { + } + } }); const playerPlaylistService = isRemotePlayer ? null @@ -59,9 +75,9 @@ async function start() { console.info('[player] startup', { mode: isRemotePlayer ? 'bridge client' : 'local', connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null, - bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null, - bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null + publicBaseUrl: PLAYER_PUBLIC_URL || null, + bridgeBaseUrl: PLAYER_INTERNAL_URL || null, + bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null }); } @@ -83,7 +99,7 @@ async function start() { } async function triggerWebMediaSync() { - if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) { + if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) { return false; } @@ -92,7 +108,7 @@ async function start() { method: 'POST', pathname: '/api/internal/sync/player-media' }); - const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, { + const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ Accept: 'application/json' @@ -194,9 +210,9 @@ async function start() { common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerPublicBaseUrl: PLAYER_PUBLIC_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_URL, + bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID }); registerPlayerRoutes(app, { @@ -207,18 +223,18 @@ async function start() { playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, rtmpStreamService: rtmpStreamService, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerPublicBaseUrl: PLAYER_PUBLIC_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_URL, + bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID }); function createThinClientWebSocketUrl() { - if (!THIN_CLIENT_BASE_URL) { + if (!BRIDGE_PUBLIC_URL) { return null; } - return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; + return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; } function startThinClientRegistration() { @@ -256,6 +272,23 @@ async function start() { 'x-pulse-request-timestamp': timestamp }, authHeaders) }); + thinClientSocket = socket; + + function sendSnapshot(slug) { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + + try { + socket.send(JSON.stringify({ + type: 'snapshot', + deviceId: PLAYER_DEVICE_ID, + slug: String(slug || '').trim(), + connections: playerRuntime.snapshotConnections(slug) + })); + } catch (_error) { + } + } socket.on('open', function () { logPlayerStartup({ @@ -265,10 +298,14 @@ async function start() { socket.send(JSON.stringify({ type: 'register', deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL })); + playerRuntime.snapshotSlugs().forEach(function (slug) { + sendSnapshot(slug); + }); + if (!webMediaSyncCompleted) { triggerWebMediaSync().then(function (success) { webMediaSyncTriggered = Boolean(success); @@ -285,8 +322,8 @@ async function start() { socket.send(JSON.stringify({ type: 'heartbeat', deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL })); if (!webMediaSyncTriggered && !webMediaSyncCompleted) { @@ -317,6 +354,7 @@ async function start() { socket.on('close', function () { clearTimers(); + thinClientSocket = null; reconnectTimer = setTimeout(connect, 5000); }); @@ -363,8 +401,8 @@ async function start() { try { await recordPlayerHeartbeat(pool, { deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL }).catch(function (error) { console.error(error); }); @@ -392,7 +430,7 @@ async function start() { if (PLAYER_DEVICE_ID && !isRemotePlayer) { const { upsertPlayerRegistration } = require('./player/onboarding'); - await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) { + await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) { console.error(error); }); } diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 6f38572..e1010b2 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -16,7 +16,7 @@ function normalizeDeviceId(value) { } function getPublicBaseUrl(req, configuredUrl) { - const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); if (configured) { return configured; } @@ -50,7 +50,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) { } function getPlayerInternalBaseUrl(configuredUrl) { - const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); return configured || null; } @@ -203,22 +203,22 @@ function registerPlayerOnboardingRoutes(app, options) { const common = options && options.common ? options.common : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; - const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); if (!app || !common) { throw new Error('registerPlayerOnboardingRoutes requires app and common.'); } - if (!thinClientBaseUrl && (!pool || !playerRuntime)) { - throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.'); + if (!bridgeBaseUrl && (!pool || !playerRuntime)) { + throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); async function fetchThinClient(req, pathname, options) { - if (!thinClientBaseUrl) { + if (!bridgeBaseUrl) { return null; } @@ -239,7 +239,7 @@ function registerPlayerOnboardingRoutes(app, options) { headers['content-type'] = requestOptions.contentType; } - return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + return fetch(new URL(pathname, bridgeBaseUrl).toString(), { method: method, headers: headers, body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body @@ -305,7 +305,7 @@ function registerPlayerOnboardingRoutes(app, options) { }, async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), { method: 'GET' }); @@ -317,7 +317,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!payload) { return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); } - payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; return res.json(payload); } @@ -329,7 +329,7 @@ function registerPlayerOnboardingRoutes(app, options) { screenId: status ? status.screen_id : null, screenSlug: status ? status.screen_slug : null, screenName: status ? status.screen_name : null, - playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null }); } catch (error) { next(error); @@ -338,7 +338,7 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { try { - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const response = await fetchThinClient(_req, '/api/onboarding/screens', { method: 'GET' }); @@ -361,7 +361,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } - const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; + const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'no-store'); @@ -382,11 +382,11 @@ function registerPlayerOnboardingRoutes(app, options) { return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' }); } - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const forwardedBody = Object.assign({}, req.body || {}, { deviceId: deviceId }); - const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), { + const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), { method: 'POST', headers: Object.assign({ 'content-type': 'application/json' @@ -402,7 +402,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!payload) { return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); } - payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`; + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`; return res.json(payload); } if (!deviceId) { @@ -423,7 +423,7 @@ function registerPlayerOnboardingRoutes(app, options) { screenId: status && status.screen_id ? status.screen_id : null, screenSlug: status ? status.screen_slug : screenSlug, screenName: status && status.screen_name ? status.screen_name : null, - playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`, queued: Boolean(status && status.queued) }); } catch (error) { diff --git a/src/player/onboarding/player-onboarding-form.script.html b/src/player/onboarding/player-onboarding-form.script.html index e82e2e7..53a31e8 100644 --- a/src/player/onboarding/player-onboarding-form.script.html +++ b/src/player/onboarding/player-onboarding-form.script.html @@ -7,6 +7,12 @@ var form = document.getElementById("onboarding-form"); var message = document.getElementById("onboarding-message"); var screenSelect = document.getElementById("onboarding-screen-select"); + function getSessionStorageItem(key) { + try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; } + } + function setSessionStorageItem(key, value) { + try { window.sessionStorage.setItem(key, value); } catch (_error) {} + } function setMessage(value) { if (message) { message.textContent = value || ""; } } function parseResponseError(response) { return response.text().then(function (text) { @@ -41,15 +47,15 @@ } try { if (!deviceId) { - deviceId = window.localStorage.getItem(deviceKey) || ""; + deviceId = window.sessionStorage.getItem(deviceKey) || ""; } if (deviceId) { - window.localStorage.setItem(deviceKey, deviceId); + window.sessionStorage.setItem(deviceKey, deviceId); } } catch (_error) {} loadScreens().then(function () { try { - var storedClientName = window.localStorage.getItem(clientNameKey) || ""; + var storedClientName = getSessionStorageItem(clientNameKey) || ""; var storedScreenSlug = window.localStorage.getItem(screenKey) || ""; var clientNameInput = form.querySelector("input[name=\"clientName\"]"); if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; } @@ -83,7 +89,7 @@ }) .then(function (payload) { if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } + if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} setMessage("Onboarding complete."); if (form) { diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 2a89920..67a5341 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -2,6 +2,12 @@ (function () { var deviceKey = "pulse-signage-player-device-id"; var clientNameKey = "pulse-signage-player-client-name"; + function getSessionStorageItem(key) { + try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; } + } + function setSessionStorageItem(key, value) { + try { window.sessionStorage.setItem(key, value); } catch (_error) {} + } function getClientNameStorageKey(_screenSlug) { return clientNameKey; } @@ -25,10 +31,10 @@ } function getDeviceId() { var stored = ""; - try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } + try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } if (stored) { return stored; } var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2)); - try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {} + try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {} return next; } function setStatus(message) { if (status) { status.textContent = message; } } @@ -83,8 +89,8 @@ }) .then(function (payload) { if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } - if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } + 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) {} setLocalMessage("Onboarding complete."); if (localForm) { @@ -99,8 +105,8 @@ .then(function (response) { return response.ok ? response.json() : null; }) .then(function (payload) { if (payload && payload.onboarded && payload.screenSlug) { - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } - if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } + 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)); return true; @@ -127,8 +133,8 @@ loadScreens().then(function () { try { var storedScreenSlug = window.localStorage.getItem(screenKey) || ""; - var storedClientName = window.localStorage.getItem(clientNameKey) || ""; - if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; } + var storedClientName = getSessionStorageItem(clientNameKey) || ""; + if (!storedClientName && storedScreenSlug) { storedClientName = getSessionStorageItem(getClientNameStorageKey(storedScreenSlug)) || ""; } if (storedClientName && localForm) { var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); if (clientNameInput) { clientNameInput.value = storedClientName; } diff --git a/src/player/player-client-name.script.html b/src/player/player-client-name.script.html index 94b9e34..8792cd6 100644 --- a/src/player/player-client-name.script.html +++ b/src/player/player-client-name.script.html @@ -4,9 +4,25 @@ const onboardingClientNameStorageKey = 'pulse-signage-player-client-name'; const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id'; + function getSessionStorageItem(key) { + try { + return window.sessionStorage.getItem(key) || ''; + } catch (_error) { + return ''; + } + } + + function setSessionStorageItem(key, value) { + try { + window.sessionStorage.setItem(key, value); + } catch (_error) { + // ignore storage errors + } + } + function getOnboardingDeviceId() { try { - var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || ''; + var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey); return String(storedDeviceId || '').trim(); } catch (_error) { return ''; @@ -19,24 +35,14 @@ return onboardingClientName; } try { - var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey); + var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey); if (storedClientName) { onboardingClientName = storedClientName; - try { - window.localStorage.setItem('pulse-signage-player-client-name', storedClientName); - } catch (_mirrorError) { - // ignore storage errors - } return onboardingClientName; } - var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name'); + var genericClientName = getSessionStorageItem('pulse-signage-player-client-name'); if (genericClientName) { onboardingClientName = genericClientName; - try { - window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName); - } catch (_error) { - // ignore storage errors - } return onboardingClientName; } } catch (_error) { @@ -51,12 +57,8 @@ return; } onboardingClientName = normalizedName; - try { - window.localStorage.setItem('pulse-signage-player-client-name', normalizedName); - window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName); - } catch (_error) { - // ignore storage errors - } + setSessionStorageItem('pulse-signage-player-client-name', normalizedName); + setSessionStorageItem(onboardingClientNameStorageKey, normalizedName); if (socket && socket.readyState === WebSocket.OPEN) { sendCommandState(socket); } diff --git a/src/player/public/js/player-page-playlist.js b/src/player/public/js/player-page-playlist.js index 5d6b6c5..8ee0482 100644 --- a/src/player/public/js/player-page-playlist.js +++ b/src/player/public/js/player-page-playlist.js @@ -188,13 +188,13 @@ function syncWebpagePreloads(sourceSlides, targetIndex) { preloadSignature = signature; } -// Return a stable client id for this browser session. +// Return a stable client id for this screen session. function getCommandClientId() { if (commandClientId) { return commandClientId; } try { - var storedClientId = window.localStorage.getItem(commandClientStorageKey); + var storedClientId = window.sessionStorage.getItem(commandClientStorageKey); if (storedClientId) { commandClientId = storedClientId; return commandClientId; @@ -204,7 +204,7 @@ function getCommandClientId() { } commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2)); try { - window.localStorage.setItem(commandClientStorageKey, commandClientId); + window.sessionStorage.setItem(commandClientStorageKey, commandClientId); } catch (_error2) { // ignore storage errors } diff --git a/src/player/routes.js b/src/player/routes.js index 8b3d986..efb88f2 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -31,23 +31,23 @@ function registerPlayerRoutes(app, options) { const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; - const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + 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; if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); } - if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) { - throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.'); + if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) { + throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); - async function fetchThinClient(req, pathname, options) { - if (!thinClientBaseUrl) { + async function fetchBridge(req, pathname, options) { + if (!bridgeBaseUrl) { return null; } @@ -71,7 +71,7 @@ function registerPlayerRoutes(app, options) { headers['content-type'] = requestOptions.contentType; } - return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + return fetch(new URL(pathname, bridgeBaseUrl).toString(), { method: method, headers: headers, body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body @@ -179,8 +179,8 @@ function registerPlayerRoutes(app, options) { }); app.get('/api/media/config', requireRequestAuth, function (_req, res) { - if (thinClientBaseUrl) { - void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), { + if (bridgeBaseUrl) { + void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), { method: 'GET', headers: createRequestAuthHeaders({ method: 'GET', @@ -298,9 +298,9 @@ function registerPlayerRoutes(app, options) { app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token; - void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { + void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { method: 'GET', headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {} }).then(async function (response) { @@ -342,8 +342,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { method: 'GET' }); if (!response) { @@ -397,8 +397,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { method: 'GET' }); if (!response) { @@ -440,8 +440,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { method: 'GET' }); if (!response) { diff --git a/src/player/runtime.js b/src/player/runtime.js index aa7f225..61d2e8b 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -21,6 +21,7 @@ function normalizePlayerPublicBaseUrl(pageUrl) { function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : null; + const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null; const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' ? options.normalizeDeviceId : function (value) { @@ -222,6 +223,10 @@ function createPlayerRuntime(options) { return allConnections; } + function snapshotSlugs() { + return Array.from(connectionsBySlug.keys()); + } + async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections()); } @@ -229,6 +234,16 @@ function createPlayerRuntime(options) { function broadcastConnectionSnapshot(slug) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); + const connections = snapshotConnections(slug); + if (notifySnapshot) { + try { + notifySnapshot({ + slug: key, + connections: connections + }); + } catch (_error) { + } + } if (!bucket || !bucket.size) { return; } @@ -236,7 +251,7 @@ function createPlayerRuntime(options) { const payload = JSON.stringify({ type: 'snapshot', slug: key, - connections: snapshotConnections(slug), + connections: connections, sentAt: new Date().toISOString() }); @@ -508,6 +523,7 @@ function createPlayerRuntime(options) { broadcastAnnouncementRefresh: broadcastAnnouncementRefresh, snapshotConnections: snapshotConnections, snapshotAllConnections: snapshotAllConnections, + snapshotSlugs: snapshotSlugs, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, sendCommandToConnection: sendCommandToConnection, broadcastCommand: broadcastCommand diff --git a/src/web.js b/src/web.js index 255a963..98cc103 100644 --- a/src/web.js +++ b/src/web.js @@ -62,7 +62,8 @@ async function start() { const playerActionService = createPlayerActionService({ pool: pool, common: common, - playerInternalBaseUrl: webConfig.thinClientBaseUrl + playerInternalBaseUrl: webConfig.playerInternalUrl, + bridgeInternalBaseUrl: webConfig.bridgeInternalUrl }); const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); @@ -70,8 +71,8 @@ async function start() { const webBootstrap = createWebBootstrap({ pool: pool, common: common, - playerInternalBaseUrl: webConfig.playerInternalBaseUrl, - thinClientBaseUrl: webConfig.thinClientBaseUrl, + playerInternalBaseUrl: webConfig.playerInternalUrl, + bridgeInternalBaseUrl: webConfig.bridgeInternalUrl, uploadDir: webConfig.uploadsDir, formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, @@ -210,7 +211,7 @@ async function start() { initializeBackgroundTasks: initializeBackgroundTasks, captureSlideThumbnail: captureSlideThumbnail, server: server, - webBaseUrl: webConfig.webBaseUrl, + webBaseUrl: webConfig.webInternalUrl, dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs }); diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 3ed2f1e..f5e4b69 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -8,8 +8,8 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); function createWebBootstrap(options) { const pool = options && options.pool; const common = options && options.common; - const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); - const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const configuredPlayerInternalUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredBridgeInternalUrl = String(options && options.bridgeInternalBaseUrl || process.env.BRIDGE_INTERNAL_URL || '').trim().replace(/\/$/, ''); const uploadDir = String(options && options.uploadDir || '').trim(); const dashboardRefreshIntervalMs = 5000; const formatDashboardDate = options && options.formatDashboardDate; @@ -27,7 +27,7 @@ function createWebBootstrap(options) { let dashboardRefreshInFlight = null; let broadcastDashboardState = null; function getPlayerSnapshotSocketUrl(slug) { - const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl; + const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl; if (!resolvedPlayerInternalBaseUrl) { throw new Error('Unable to resolve the player internal base URL.'); } @@ -109,7 +109,7 @@ function createWebBootstrap(options) { const dashboardStateService = createDashboardStateService({ pool: pool, common: common, - thinClientBaseUrl: configuredThinClientBaseUrl, + thinClientBaseUrl: configuredBridgeInternalUrl, playerSnapshotCache: playerSnapshotCache, playerSnapshotSockets: playerSnapshotSockets, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, @@ -120,7 +120,7 @@ function createWebBootstrap(options) { const uploadSyncService = createUploadSyncService({ pool: pool, common: common, - playerInternalBaseUrl: configuredPlayerInternalBaseUrl, + playerInternalBaseUrl: configuredPlayerInternalUrl, playerSnapshotCache: playerSnapshotCache, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue @@ -253,7 +253,7 @@ function createWebBootstrap(options) { return { upload: upload, uploadSyncService: uploadSyncService, - playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, + playerInternalBaseUrl: configuredPlayerInternalUrl || null, buildDashboardState: buildDashboardState, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, diff --git a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js index 7ba61d2..95f787c 100644 --- a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js +++ b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js @@ -18,57 +18,50 @@ function registerFontSweepTask(options) { const uploadSyncService = options && options.uploadSyncService; const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; - const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata; const mediaDir = String(options && options.mediaDir || '').trim(); if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { throw new Error('registerFontSweepTask requires the font sweep dependencies.'); } - const metadataPromise = typeof getPlayerTaskMetadata === 'function' - ? Promise.resolve(getPlayerTaskMetadata()) - : Promise.resolve({}); + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: { + mediaDir: mediaDir + }, + run: async function () { + const desiredOperations = collectFontLibrarySyncOperations(mediaDir); + const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { + return operation && operation.uploadPath ? operation.uploadPath : ''; + }).filter(Boolean)); + const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); - return metadataPromise.then(function (metadata) { - backgroundTaskQueue.registerRecurringTask({ - key: TASK.key, - title: TASK.title, - category: TASK.category, - intervalMs: TASK.intervalMs, - metadata: Object.assign({ - mediaDir: mediaDir - }, metadata || {}), - run: async function () { - const desiredOperations = collectFontLibrarySyncOperations(mediaDir); - const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { - return operation && operation.uploadPath ? operation.uploadPath : ''; - }).filter(Boolean)); - const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); - - for (let i = 0; i < desiredOperations.length; i += 1) { - const operation = desiredOperations[i] || {}; - const uploadPath = String(operation.uploadPath || '').trim(); - if (!uploadPath) { - continue; - } - - if (String(operation.type || '').trim().toLowerCase() === 'delete') { - await removeUploadFileFromPlayer(uploadPath, mediaDir); - } else { - await pushUploadFileToPlayer(uploadPath, mediaDir); - } + for (let i = 0; i < desiredOperations.length; i += 1) { + const operation = desiredOperations[i] || {}; + const uploadPath = String(operation.uploadPath || '').trim(); + if (!uploadPath) { + continue; } - for (let i = 0; i < currentUploadPaths.length; i += 1) { - const uploadPath = String(currentUploadPaths[i] || '').trim(); - if (!uploadPath || desiredUploadPaths.has(uploadPath)) { - continue; - } - + if (String(operation.type || '').trim().toLowerCase() === 'delete') { await removeUploadFileFromPlayer(uploadPath, mediaDir); + } else { + await pushUploadFileToPlayer(uploadPath, mediaDir); } } - }); + + for (let i = 0; i < currentUploadPaths.length; i += 1) { + const uploadPath = String(currentUploadPaths[i] || '').trim(); + if (!uploadPath || desiredUploadPaths.has(uploadPath)) { + continue; + } + + await removeUploadFileFromPlayer(uploadPath, mediaDir); + } + } }); } diff --git a/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js new file mode 100644 index 0000000..6772f9d --- /dev/null +++ b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js @@ -0,0 +1,32 @@ +const TASK = { + key: 'onboarding-device-prune', + title: 'Onboarding device prune', + category: 'cleanup', + trigger: 'scheduled recurring task, hourly', + purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.', + taskType: 'recurring-run', + intervalMs: 60 * 60 * 1000 +}; + +function registerOnboardingDevicePruneTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const pool = options && options.pool; + const common = options && options.common; + + if (!backgroundTaskQueue || !pool || !common || typeof common.pruneStaleOnboardingDevices !== 'function') { + throw new Error('registerOnboardingDevicePruneTask requires the onboarding prune dependencies.'); + } + + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: {}, + run: async function () { + await common.pruneStaleOnboardingDevices(pool); + } + }); +} + +module.exports = { registerOnboardingDevicePruneTask }; \ No newline at end of file diff --git a/src/web/lib/config.js b/src/web/lib/config.js index 27da7b5..408a9dd 100644 --- a/src/web/lib/config.js +++ b/src/web/lib/config.js @@ -5,9 +5,9 @@ function createWebConfig() { const uploadsDir = path.join(mediaDir, 'uploads'); const thumbnailsDir = path.join(mediaDir, 'thumbnails'); const assetDir = path.join(__dirname, '..', 'public'); - const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); - const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, ''); - const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); + const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); + const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, ''); + const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const sessionCookieName = 'digital_signage_session'; const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; @@ -20,9 +20,9 @@ function createWebConfig() { uploadsDir: uploadsDir, thumbnailsDir: thumbnailsDir, assetDir: assetDir, - playerInternalBaseUrl: playerInternalBaseUrl, - thinClientBaseUrl: thinClientBaseUrl, - webBaseUrl: webBaseUrl, + playerInternalUrl: playerInternalUrl, + bridgeInternalUrl: bridgeInternalUrl, + webInternalUrl: webInternalUrl, sessionCookieName: sessionCookieName, sessionMaxAgeMs: sessionMaxAgeMs, dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 66077dd..1f551b3 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -438,6 +438,9 @@ function createUploadSyncService(options) { console.warn('Unable to flush pending upload syncs:', error); }); }, 5000); + if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') { + pendingPlayerUploadSyncFlushTimer.unref(); + } } async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { @@ -677,6 +680,11 @@ function createUploadSyncService(options) { return pendingPlaylistUploadSyncFlushInFlight; } + if (pendingPlaylistUploadSyncFlushTimer) { + clearTimeout(pendingPlaylistUploadSyncFlushTimer); + pendingPlaylistUploadSyncFlushTimer = null; + } + if (!pendingPlaylistUploadSyncs.size) { return null; } @@ -715,6 +723,11 @@ function createUploadSyncService(options) { return pendingPlayerUploadSyncFlushInFlight; } + if (pendingPlayerUploadSyncFlushTimer) { + clearTimeout(pendingPlayerUploadSyncFlushTimer); + pendingPlayerUploadSyncFlushTimer = null; + } + if (!pendingPlayerUploadSyncs.size) { return null; } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index a990ff9..bf1225d 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,5 +1,5 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); -const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); +const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); function isLocalLikeBaseUrl(value) { let host = ''; @@ -33,24 +33,10 @@ function isRecentPlayerRegistration(player, staleSeconds) { return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; } -async function fetchRecentPlayerRegistrations(pool) { - if (!pool || typeof fetchPlayerRegistrations !== 'function') { - return []; - } - - try { - const players = await fetchPlayerRegistrations(pool); - return (Array.isArray(players) ? players : []).filter(function (player) { - return isRecentPlayerRegistration(player, 60); - }); - } catch (_error) { - return []; - } -} - function createPlayerActionService(options) { const pool = options && options.pool; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, ''); const common = options && options.common; if (!common) { @@ -151,6 +137,79 @@ function createPlayerActionService(options) { }); } + async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) { + const targetBaseUrl = normalizeBaseUrl(baseUrl); + if (!targetBaseUrl) { + return null; + } + + const authHeaders = createRequestAuthHeaders({ + method: 'GET', + pathname: `/api/screens/${encodeURIComponent(slug)}/connections` + }); + + const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...authHeaders + } + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return null; + }); + } + + async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) { + const targetDeviceId = String(deviceId || '').trim(); + const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl); + if (!targetDeviceId) { + throw new Error('Device ID is required.'); + } + if (!targetBridgeBaseUrl) { + throw new Error('Unable to resolve the player bridge base URL.'); + } + + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null + ? Object.assign({}, commandOrPayload) + : { command: commandOrPayload }; + + const authHeaders = createRequestAuthHeaders({ + method: 'POST', + pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`, + body: payload + }); + + const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...authHeaders + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return { ok: true }; + }); + } + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); @@ -190,69 +249,62 @@ function createPlayerActionService(options) { } async function getScreenConnections(slug) { - const authHeaders = createRequestAuthHeaders({ - method: 'GET', - pathname: `/api/screens/${encodeURIComponent(slug)}/connections` - }); - const recentPlayers = await fetchRecentPlayerRegistrations(pool); - const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) { - return normalizeBaseUrl(player && player.public_base_url); - }).filter(Boolean))); - - if (!targetBaseUrls.length) { - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (resolvedPlayerInternalBaseUrl) { - targetBaseUrls.push(resolvedPlayerInternalBaseUrl); - } - } - + const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl); + const playerBaseUrl = await getPlayerInternalBaseUrl(); + const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean))); if (!targetBaseUrls.length) { throw new Error('Unable to resolve the player internal base URL.'); } - const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) { - const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...authHeaders - } - }); - - if (!response.ok) { - return null; - } - - return response.json().catch(function () { - return null; - }); + const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) { + return fetchScreenConnectionsFromBaseUrl(baseUrl, slug); })); + const successfulResults = results.filter(function (result) { + return result.status === 'fulfilled' && result.value; + }).map(function (result) { + return result.value; + }); + + if (!successfulResults.length) { + const rejection = results.find(function (result) { + return result.status === 'rejected'; + }); + throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`); + } + const mergedConnections = []; - let screen = null; - let degraded = false; - results.forEach(function (result) { - if (!result) { - degraded = true; - return; - } - if (!screen && result.screen) { - screen = result.screen; - } - if (Array.isArray(result.connections)) { - mergedConnections.push.apply(mergedConnections, result.connections); - } - if (result.degraded) { - degraded = true; - } + const seenKeys = new Set(); + + successfulResults.forEach(function (result) { + const connections = Array.isArray(result && result.connections) ? result.connections : []; + connections.forEach(function (connection) { + const key = [ + String(connection && connection.id || '').trim(), + String(connection && connection.clientId || '').trim(), + String(connection && connection.deviceId || '').trim(), + String(connection && connection.playerPublicBaseUrl || '').trim() + ].join('|'); + if (!key || seenKeys.has(key)) { + return; + } + seenKeys.add(key); + mergedConnections.push(connection); + }); }); return { - screen: screen, + screen: successfulResults.find(function (result) { + return Boolean(result && result.screen); + }) ? successfulResults.find(function (result) { + return Boolean(result && result.screen); + }).screen : null, screenSlug: slug, count: mergedConnections.length, connections: mergedConnections, - degraded: degraded + degraded: results.some(function (result) { + return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded); + }) }; } @@ -302,6 +354,7 @@ function createPlayerActionService(options) { forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, + forwardPlayerCommandToDevice: forwardPlayerCommandToDevice, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 9eb6e4b..46a65fa 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -8,8 +8,6 @@ var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; var LIST_PAGE_SIZE = 25; var latestDashboardState = null; - var ALL_SCREENS_SLUG = '__all__'; - var ALL_SCREENS_LABEL = 'All screens'; function getClientSearchInput() { var table = document.getElementById('dashboard-clients-table'); @@ -20,6 +18,147 @@ return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]'); } + function getScreenCommandSelect() { + return document.querySelector('[data-screen-command-select]'); + } + + function getScreenCommandForms() { + if (!document.querySelectorAll) { + return []; + } + + return Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]')); + } + + function getSelectedScreenClients(state) { + var select = getScreenCommandSelect(); + var selectedSlug = select ? String(select.value || '').trim() : ''; + var clients = Array.isArray(state && state.clients) ? state.clients : []; + + if (!selectedSlug || selectedSlug === '__all__') { + return clients; + } + + return clients.filter(function (client) { + return String(client && client.screen_slug || '').trim() === selectedSlug; + }); + } + + function getSelectedScreenLabel() { + var select = getScreenCommandSelect(); + if (!select) { + return ''; + } + + var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null; + return selectedOption ? String(selectedOption.getAttribute('data-screen-name') || selectedOption.textContent || '').trim() : ''; + } + + function updateToggleButton(button, form, state) { + if (!button) { + return; + } + + var action = String(form && form.getAttribute('data-screen-command-action') || '').trim(); + var selectedLabel = getSelectedScreenLabel() || 'selected screen group'; + var isAllScreens = String(getScreenCommandSelect() && getScreenCommandSelect().value || '').trim() === '__all__'; + var clients = getSelectedScreenClients(state); + var hasClients = clients.length > 0; + var allPaused = hasClients && clients.every(function (client) { + return Boolean(client && client.paused); + }); + var allBlackout = hasClients && clients.every(function (client) { + return Boolean(client && client.blackout); + }); + + if (action === 'pause') { + var pauseLabel = allPaused ? 'Resume ' + (isAllScreens ? 'all clients' : 'screen') : 'Pause ' + (isAllScreens ? 'all clients' : 'screen'); + var pauseConfirm = allPaused ? 'Resume ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Pause ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?'; + var pauseIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill'; + button.innerHTML = '' + escapeHtml(pauseLabel); + setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); + button.setAttribute('aria-label', pauseLabel); + button.setAttribute('title', pauseLabel); + if (form) { + var pauseInput = form.querySelector('input[name="paused"]'); + if (pauseInput) { + pauseInput.value = allPaused ? 'false' : 'true'; + } + form.setAttribute('data-confirm-message', pauseConfirm); + } + return; + } + + if (action === 'blackout') { + var blackoutLabel = allBlackout ? 'Restore ' + (isAllScreens ? 'all clients' : 'screen') : 'Blackout ' + (isAllScreens ? 'all clients' : 'screen'); + var blackoutConfirm = allBlackout ? 'Restore ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Blackout ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?'; + var blackoutIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash'; + button.innerHTML = '' + escapeHtml(blackoutLabel); + setButtonVariant(button, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); + button.setAttribute('aria-label', blackoutLabel); + button.setAttribute('title', blackoutLabel); + if (form) { + var blackoutInput = form.querySelector('input[name="blackout"]'); + if (blackoutInput) { + blackoutInput.value = allBlackout ? 'false' : 'true'; + } + form.setAttribute('data-confirm-message', blackoutConfirm); + } + } + } + + function updateScreenCommandControls() { + var select = getScreenCommandSelect(); + if (!select) { + return; + } + + var selectedSlug = String(select.value || '').trim(); + var actionTarget = '/clients/' + encodeURIComponent(selectedSlug || '__all__') + '/commands'; + var selectedName = getSelectedScreenLabel(); + var selectedClients = getSelectedScreenClients(latestDashboardState); + var allPaused = selectedClients.length > 0 && selectedClients.every(function (client) { + return Boolean(client && client.paused); + }); + var allBlackout = selectedClients.length > 0 && selectedClients.every(function (client) { + return Boolean(client && client.blackout); + }); + + getScreenCommandForms().forEach(function (form) { + if (!form) { + return; + } + + form.setAttribute('action', actionTarget); + + var action = String(form.getAttribute('data-screen-command-action') || '').trim(); + var button = form.querySelector('button[type="submit"]'); + + if (action === 'pause' || action === 'blackout') { + updateToggleButton(button, form, latestDashboardState); + return; + } + + if (button) { + button.setAttribute('aria-label', selectedName ? selectedName : 'Selected screen group'); + } + }); + } + + function initScreenCommandControls() { + var select = getScreenCommandSelect(); + if (!select || (select.dataset && select.dataset.bound === 'true')) { + return; + } + + if (select.dataset) { + select.dataset.bound = 'true'; + } + + select.addEventListener('change', updateScreenCommandControls); + updateScreenCommandControls(); + } + function getClientListQueryState() { var searchParams = new URLSearchParams(String(window.location && window.location.search || '')); var searchInput = getClientSearchInput(); @@ -170,77 +309,6 @@ return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE); } - function getClientMoveModalElements() { - return { - modal: document.getElementById('client-move-screen-modal'), - form: document.getElementById('client-move-screen-form'), - targetSelect: document.getElementById('client-move-screen-target'), - connectionInput: document.querySelector('[data-client-move-connection-id]'), - deviceInput: document.querySelector('[data-client-move-device-id]'), - clientNameInput: document.querySelector('[data-client-move-client-name]'), - playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]') - }; - } - - function getClientMoveScreens() { - if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) { - return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients); - } - - var select = document.getElementById('client-move-screen-target'); - if (!select || !select.options) { - return []; - } - - return Array.prototype.slice.call(select.options).map(function (option) { - return { - slug: String(option.value || '').trim(), - name: String(option.textContent || option.value || '').trim() - }; - }).filter(function (screen) { - return Boolean(screen && screen.slug); - }); - } - - function updateClientMoveModalFromRow(row) { - var elements = getClientMoveModalElements(); - if (!elements.form || !elements.targetSelect || !row) { - return; - } - - var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); - var connectionId = String(row.getAttribute('data-client-key') || '').trim(); - var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); - var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim(); - var clientNameCell = row.querySelector('td[data-label="Client"] > div'); - var clientName = String(clientNameCell && clientNameCell.textContent || '').trim(); - var options = Array.prototype.slice.call(elements.targetSelect.options || []); - - options.forEach(function (option) { - option.disabled = false; - if (String(option.value || '').trim() === currentScreenSlug) { - option.disabled = true; - } - }); - - elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#'; - if (elements.connectionInput) { - elements.connectionInput.value = connectionId; - } - if (elements.deviceInput) { - elements.deviceInput.value = deviceId; - } - if (elements.clientNameInput) { - elements.clientNameInput.value = clientName; - } - if (elements.playerBaseUrlInput) { - elements.playerBaseUrlInput.value = playerBaseUrl; - } - elements.targetSelect.value = ''; - if (elements.form.querySelector('button[type="submit"]')) { - elements.form.querySelector('button[type="submit"]').disabled = false; - } - } function renderClientActionCell(client) { var paused = Boolean(client.paused); var pauseButtonClass = 'btn btn-sm btn-info'; @@ -257,7 +325,7 @@ return [ '