diff --git a/CHANGELOG.md b/CHANGELOG.md index 437354c..0c940e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,25 @@ All notable changes to this project will be documented in this file. - Multiple players are now supported across the player registry, dashboard snapshots, and screen group management, so a deployment can track more than one connected player at a time. - The player-agent can now run remotely, which lets a player connect through the bridge instead of requiring everything to stay on the same host. - The Docker Compose setup now includes a public stack with a player bridge service and a separate remote player stack, so local and split-device deployments share the same documented layout. +- Dedicated local and remote compose manifests now live at `docker-compose/docker-compose.yml` and `docker-compose/docker-compose.remote.yml`, with matching example env files for the two deployment modes. +- Admin client commands now have a dedicated route and test coverage, which keeps dashboard actions aligned with the current player control-plane flow. ### Changed - Screen group views now show each registered player's public base URL and computed player URL on the edit screen instead of assuming a single local player. - The dashboard status indicator now reports the connected player count, which makes the live feed status more explicit when several players are online. +- Player command dispatch, playlist playback, and render preloading were tightened so the player keeps using the active web base URL and can warm assets before the slide is shown. +- Playlist video-duration selection now uses the longest matching video region when a slide contains more than one video region. +- Media sync, font sync, and upload sync now follow the newer player-agent workflow, keeping background tasks and dashboard refreshes consistent with split-device deployments. +- The onboarding landing page now shows a built-in QR placeholder and falls back to it when the QR request fails, so the screen never starts blank. +- Upload sync now resolves the player internal base URL once per batch and reuses it for queued upload and delete operations, keeping mirrored media writes pointed at the active player endpoint. +- The connected clients list now defaults to client name, then IP address, so rows stay in a stable order when connection details change. + +### Fixed + +- Connected clients search results now keep the row action column, because the row template resolves `currentUser` from the parent view context while rendering inside the `clients` loop. +- The move-client modal now carries the row's player base URL through to the redirect step, so clients move correctly even when local and bridge-backed players are mixed. +- Paginated table cards now only apply their minimum height when the table content would otherwise exceed that threshold, which keeps short result sets compact in smaller browser windows. ## 2.6.4 - 2026-08-07 @@ -38,7 +52,6 @@ All notable changes to this project will be documented in this file. ### Fixed -- Player registrations now rely on the device ID only, and the unused player identifier column is removed from the player registry schema. ### Changed diff --git a/docker-compose/README.md b/docker-compose/README.md index cecf7df..9a68ef3 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -1,6 +1,6 @@ # Docker Compose Setup -This folder contains the public Docker Compose definitions for Pulse Signage and the remote player stack. +This folder contains the Docker Compose definitions for Pulse Signage, including the public stack and the remote player stack. ## Files diff --git a/docs/api.md b/docs/api.md index 711899b..157ad07 100644 --- a/docs/api.md +++ b/docs/api.md @@ -78,6 +78,11 @@ Response fields: Returns the upload directory configured for the player service. Access: internal-only. +Response fields: + +- `mediaDir` +- `uploadDir` + ### `PUT /api/media/{filename}` Writes an uploaded file into the player upload directory. Access: internal-only and write-protected behind your deployment boundary. @@ -95,6 +100,14 @@ Query fields: - `source` required - `disableAudio` optional +Response fields: + +- `key` +- `playlistUrl` +- `disableAudio` +- `ready` +- `live` + ### `GET /api/rtmp/streams/{key}/index.m3u8` Returns the RTMP session HLS manifest. Access: internal-only. @@ -114,6 +127,7 @@ Response fields: - `slides` - `rssFeeds` - `apiSources` +- `timetableGroups` - `revision` ### `GET /api/screens/{slug}/connections` @@ -198,6 +212,7 @@ Response fields: `GET /api/media/config` returns an object with: +- `mediaDir` - `uploadDir` ### RTMP Session Response @@ -208,6 +223,7 @@ Response fields: - `playlistUrl` - `disableAudio` - `ready` +- `live` ### Onboarding Write Response @@ -230,6 +246,7 @@ Response fields: - `slides` - `rssFeeds` - `apiSources` +- `timetableGroups` - `revision` ### Connections Response diff --git a/docs/schema.md b/docs/schema.md index 51589fc..ed53229 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -21,7 +21,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `a_roles` -- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `role_key` is unique. - `name` is unique. ### `a_permissions` @@ -93,7 +94,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `c_playlist_slides` -- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by` - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` - `slide_id` -> `c_slides.id` with `ON DELETE CASCADE` diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 422b2c3..8c1ce9c 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -44,6 +44,29 @@ function formatPlayerConnectionLabel(deviceId, remoteAddress) { return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId; } +function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { + const key = String(slug || '').trim(); + if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { + return []; + } + + const deviceIds = screenPlayerDeviceIds.get(key); + if (!Array.isArray(deviceIds) || !deviceIds.length) { + return []; + } + + 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; + } + + return { deviceId: deviceId, socket: socket }; + }).filter(Boolean); +} + function resolveWebBaseUrl(req) { const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, ''); if (configuredWebBaseUrl) { @@ -148,6 +171,28 @@ async function start() { return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : ''; } + function removeConnectedPlayerSocket(socket) { + if (!socket || !socket.playerDeviceId) { + return false; + } + + const current = playerSockets.get(socket.playerDeviceId); + if (current !== socket) { + return false; + } + + playerSockets.delete(socket.playerDeviceId); + return true; + } + + function logPlayerDisconnect(socket) { + if (!socket || !socket.playerDeviceId) { + return; + } + + logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`); + } + function resolveMediaPath(fileName) { const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { @@ -347,12 +392,7 @@ async function start() { return res.status(400).json({ error: 'Command is required.' }); } - const [registeredPlayers] = await pool.query( - `SELECT identifier, public_base_url - FROM d_players - ORDER BY modified_at DESC, identifier ASC` - ); - const targetPlayers = Array.isArray(registeredPlayers) ? registeredPlayers : []; + const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds); if (!targetPlayers.length) { return res.status(404).json({ error: 'Player is not connected.' }); @@ -365,40 +405,15 @@ async function start() { payload.blackout = blackoutValue; } - const results = await Promise.all(targetPlayers.map(async function (player) { - const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); - if (!baseUrl) { - return { ok: false, status: 502, error: 'Player base URL is not configured.' }; - } - + const results = await Promise.all(targetPlayers.map(async function (target) { const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {}); - const response = await fetch(new URL(`/api/screens/${encodeURIComponent(slug)}/commands`, baseUrl).toString(), { - method: 'POST', - headers: Object.assign({ - 'content-type': 'application/json', - Accept: 'application/json' - }, createRequestAuthHeaders({ - method: 'POST', - pathname: `/api/screens/${encodeURIComponent(slug)}/commands`, - body: requestBody - })), - body: JSON.stringify(requestBody) - }); - - const text = await response.text(); - let parsed = null; - try { - parsed = text ? JSON.parse(text) : null; - } catch (_error) { - parsed = { ok: response.ok, raw: text }; - } + const response = await sendPlayerCommandToSocket(target.socket, requestBody); return Object.assign({ - ok: response.ok, - status: response.status, - playerIdentifier: String(player && player.identifier || '').trim(), - playerUrl: baseUrl - }, parsed && typeof parsed === 'object' ? parsed : {}); + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : (response && response.ok ? 200 : 502), + playerIdentifier: String(target.deviceId || '').trim() + }, response && typeof response === 'object' ? response : {}); })); res.json({ @@ -704,20 +719,14 @@ async function start() { }); socket.on('close', function () { - if (socket.playerDeviceId) { - const current = playerSockets.get(socket.playerDeviceId); - if (current === socket) { - playerSockets.delete(socket.playerDeviceId); - } + if (removeConnectedPlayerSocket(socket)) { + logPlayerDisconnect(socket); } }); socket.on('error', function () { - if (socket.playerDeviceId) { - const current = playerSockets.get(socket.playerDeviceId); - if (current === socket) { - playerSockets.delete(socket.playerDeviceId); - } + if (removeConnectedPlayerSocket(socket)) { + logPlayerDisconnect(socket); } }); }); @@ -906,7 +915,7 @@ async function start() { }); } -module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl }; +module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets }; if (require.main === module) { start().catch(function (error) { diff --git a/src/player.js b/src/player.js index e4493c9..8a3c67d 100644 --- a/src/player.js +++ b/src/player.js @@ -48,6 +48,23 @@ async function start() { playerRuntime.installWebsocket(server); app.use(express.json()); + let hasLoggedPlayerStartup = false; + + function logPlayerStartup(connectionState) { + if (hasLoggedPlayerStartup) { + return; + } + + hasLoggedPlayerStartup = true; + 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 + }); + } + fs.mkdirSync(MEDIA_DIR, { recursive: true }); function resolveLocalMediaFilePath(fileName) { @@ -241,6 +258,10 @@ async function start() { }); socket.on('open', function () { + logPlayerStartup({ + connected: true + }); + socket.send(JSON.stringify({ type: 'register', deviceId: PLAYER_DEVICE_ID, @@ -322,6 +343,12 @@ async function start() { }; } + if (!isRemotePlayer) { + logPlayerStartup({ + connected: false + }); + } + const stopThinClientRegistration = startThinClientRegistration(); server.listen(PORT, function () { diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index a446aa0..2a89920 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -11,6 +11,7 @@ var localForm = document.getElementById("onboarding-local-form"); var localMessage = document.getElementById("onboarding-message"); var localScreenSelect = document.getElementById("onboarding-screen-select"); + var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E"; function parseResponseError(response) { return response.text().then(function (text) { var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding."; @@ -60,7 +61,11 @@ .catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; }); } function loadQr(deviceId) { - if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); } + if (!qr) { return; } + qr.onerror = function () { + qr.src = qrPlaceholderSrc; + }; + qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); } function submitOnboarding(deviceId, clientName, screenSlug) { return fetch("/api/onboarding", { @@ -133,6 +138,9 @@ }); redirectIfOnboarded(deviceId).then(function (redirected) { if (redirected) { return; } + if (qr && !qr.getAttribute("src")) { + qr.src = qrPlaceholderSrc; + } loadQr(deviceId); setStatus("Waiting for onboarding to finish."); window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000); diff --git a/src/player/playlist.js b/src/player/playlist.js index 3a58a47..a27e123 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -144,11 +144,14 @@ function createPlayerPlaylistService(options) { return null; } - const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) { + const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) { return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; }); - const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; + const duration = videoRegions.reduce(function (longest, region) { + const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000; + return regionDuration > longest ? regionDuration : longest; + }, 0); return Number.isFinite(duration) && duration > 0 ? duration : null; } catch (_error) { return null; diff --git a/src/player/render.js b/src/player/render.js index f15807b..771b017 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -46,7 +46,7 @@ function renderOnboardingLandingBody() { '
', '
', '
', - ' Onboarding QR code', + ' Onboarding QR code', '
', '
Preparing onboarding link...
', '
', diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 39084b9..3ed2f1e 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -22,8 +22,8 @@ function createWebBootstrap(options) { const dashboardWs = new WebSocketServer({ noServer: true }); const dashboardClients = new Set(); - const playerSnapshotCache = new Map(); - const playerSnapshotSockets = new Map(); + const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map(); + const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map(); let dashboardRefreshInFlight = null; let broadcastDashboardState = null; function getPlayerSnapshotSocketUrl(slug) { @@ -44,6 +44,10 @@ function createWebBootstrap(options) { } function ensurePlayerSnapshotSubscription(slug) { + if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) { + return options.ensurePlayerSnapshotSubscription(slug); + } + const key = String(slug || '').trim(); if (!key || playerSnapshotSockets.has(key)) { return; @@ -130,12 +134,44 @@ function createWebBootstrap(options) { const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; const runMediaSyncTask = uploadSyncService.runMediaSyncTask; + let lastDashboardState = null; + + function getFallbackDashboardState() { + return lastDashboardState || { + playlists: [], + screens: [], + clients: [], + kioskPlayers: [], + slides: [], + playerServiceConnected: false, + connectedPlayersCount: 0, + connectedClientsCount: 0 + }; + } + + async function resolveDashboardState() { + try { + const state = await buildDashboardState(); + lastDashboardState = state; + return state; + } catch (error) { + if (lastDashboardState) { + console.error(error); + return lastDashboardState; + } + + throw error; + } + } async function sendDashboardStateToSocket(socket) { if (!socket || socket.readyState !== WebSocket.OPEN) { return; } - const state = await buildDashboardState(); + const state = await resolveDashboardState().catch(function (error) { + console.error(error); + return getFallbackDashboardState(); + }); socket.send(JSON.stringify({ type: 'dashboard-state', state: state })); } @@ -145,7 +181,10 @@ function createWebBootstrap(options) { } dashboardRefreshInFlight = (async function () { - const state = await buildDashboardState(); + const state = await resolveDashboardState().catch(function (error) { + console.error(error); + return getFallbackDashboardState(); + }); const payload = JSON.stringify({ type: 'dashboard-state', state: state }); for (const socket of dashboardClients) { if (socket && socket.readyState === WebSocket.OPEN) { diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index c4f52d0..1852de5 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -120,7 +120,11 @@ function createDashboardStateService(options) { ? await common.fetchPlayerRegistrations(pool) : []; screensData.forEach(function (screen) { - ensurePlayerSnapshotSubscription(screen.slug); + try { + ensurePlayerSnapshotSubscription(screen.slug); + } catch (_error) { + // Keep building dashboard state when one player snapshot subscription fails. + } }); const [onboardingRows] = await pool.query( diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 4449e06..66077dd 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -40,6 +40,14 @@ function normalizePlayerRowBaseUrl(player) { return normalizeBaseUrl(player && player.internal_base_url); } +function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; +} + function createUploadSyncService(options) { const pool = options && options.pool; const common = options && options.common; @@ -68,7 +76,11 @@ function createUploadSyncService(options) { async function getPlayerInternalBaseUrl() { const metadata = await getPlayerTaskMetadata(); - return metadata && metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; + if (!metadata || metadata.playerActive === false) { + return null; + } + + return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; } async function getPlayerTaskMetadata() { @@ -85,26 +97,45 @@ function createUploadSyncService(options) { if (pool && typeof fetchPlayerRegistrations === 'function') { const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); const players = await fetchPlayerRegistrations(pool); - const exactPlayer = Array.isArray(players) - ? players.find(function (player) { - return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; - }) - : null; const registeredPlayers = Array.isArray(players) ? players : []; - const preferredPlayer = registeredPlayers.find(function (player) { + const recentPlayers = registeredPlayers.filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }); + const preferredPlayer = recentPlayers.find(function (player) { const internalBaseUrl = normalizePlayerRowBaseUrl(player); return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl); - }) || exactPlayer || registeredPlayers[0] || null; - const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer); - const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url); - const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim(); - if (resolvedInternalBaseUrl || resolvedPublicBaseUrl || resolvedIdentifier) { + }) || recentPlayers.find(function (player) { + return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; + }) || recentPlayers[0] || null; + if (preferredPlayer) { + const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer); + const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url); + const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim(); playerInternalBaseUrl = resolvedInternalBaseUrl || null; playerTaskMetadata = { playerIdentifier: resolvedIdentifier || null, playerPublicBaseUrl: resolvedPublicBaseUrl || null, playerInternalBaseUrl: resolvedInternalBaseUrl || null, - playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null + playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null, + playerActive: true + }; + return playerTaskMetadata; + } + + if (registeredPlayers.length) { + const stalePlayer = registeredPlayers.find(function (player) { + return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; + }) || registeredPlayers[0] || null; + const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer); + const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url); + const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim(); + playerInternalBaseUrl = staleInternalBaseUrl || null; + playerTaskMetadata = { + playerIdentifier: staleIdentifier || null, + playerPublicBaseUrl: stalePublicBaseUrl || null, + playerInternalBaseUrl: staleInternalBaseUrl || null, + playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null, + playerActive: false }; return playerTaskMetadata; } @@ -117,7 +148,8 @@ function createUploadSyncService(options) { playerIdentifier: getConfiguredPlayerIdentifier() || null, playerPublicBaseUrl: null, playerInternalBaseUrl: playerInternalBaseUrl, - playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null + playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null, + playerActive: true }; return playerTaskMetadata; })().then(function (metadata) { @@ -377,6 +409,10 @@ function createUploadSyncService(options) { return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; } + function isPlayerUnavailableResponse(response) { + return Boolean(response) && Number(response.status) === 503; + } + function queuePlayerUploadSync(operation) { if (!operation || !operation.uploadPath) { return; @@ -404,13 +440,13 @@ function createUploadSyncService(options) { }, 5000); } - async function pushUploadFileToPlayer(uploadPath, localUploadDir) { + async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); + if (!targetBaseUrl) { return false; } @@ -435,7 +471,7 @@ function createUploadSyncService(options) { pathname: `/api/media/${encodeURIComponent(relativePath)}`, body: fileBuffer }); - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', @@ -444,7 +480,9 @@ function createUploadSyncService(options) { body: fileBuffer }); if (!response.ok) { - console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); + if (!isPlayerUnavailableResponse(response)) { + console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); + } return false; } return true; @@ -456,13 +494,13 @@ function createUploadSyncService(options) { } } - async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { + async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); + if (!targetBaseUrl) { return false; } @@ -475,7 +513,7 @@ function createUploadSyncService(options) { method: 'DELETE', pathname: `/api/media/${encodeURIComponent(relativePath)}` }); - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -483,7 +521,9 @@ function createUploadSyncService(options) { } }); if (!response.ok && response.status !== 404) { - console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); + if (!isPlayerUnavailableResponse(response)) { + console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); + } return false; } return true; @@ -500,9 +540,10 @@ function createUploadSyncService(options) { return; } + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); for (let i = 0; i < uniqueRefs.length; i += 1) { - const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); + const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl); if (!success) { queuePlayerUploadSync({ type: 'put', @@ -683,15 +724,23 @@ function createUploadSyncService(options) { const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata ? pendingEntries[0].metadata : await getPlayerTaskMetadata(); + if (playerMetadata && playerMetadata.playerActive === false) { + pendingPlayerUploadSyncs.clear(); + pendingPlayerUploadSyncRetryLogAt = 0; + return; + } + const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl + ? playerMetadata.playerInternalBaseUrl + : await getPlayerInternalBaseUrl(); let successCount = 0; let failureCount = 0; for (let i = 0; i < pendingEntries.length; i += 1) { const operation = pendingEntries[i]; let success = false; if (operation.type === 'delete') { - success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); + success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); } else { - success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); + success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); } if (success) { successCount += 1; diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index 2b74f21..a990ff9 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -25,6 +25,29 @@ function normalizeBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } +function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + 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(/\/$/, ''); @@ -57,10 +80,10 @@ function createPlayerActionService(options) { }) : null; const registeredPlayers = Array.isArray(players) ? players : []; - const preferredPlayer = registeredPlayers.find(function (player) { + const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) { const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url); return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl); - }) || exactPlayer || registeredPlayers[0] || null; + }) || registeredPlayers[0] || null; const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url); if (resolvedBaseUrl) { playerInternalBaseUrl = resolvedBaseUrl; @@ -87,24 +110,26 @@ function createPlayerActionService(options) { return playerInternalBaseUrlPromise; } - async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { + async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) { + const targetBaseUrl = normalizeBaseUrl(baseUrl); + if (!targetBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; if (connectionId) { payload.connectionId = connectionId; } + const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: `/api/screens/${encodeURIComponent(slug)}/commands`, body: payload }); - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { - throw new Error('Unable to resolve the player internal base URL.'); - } - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { + const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -126,6 +151,11 @@ function createPlayerActionService(options) { }); } + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); + } + async function forwardAnnouncementRefresh(slug) { const authHeaders = createRequestAuthHeaders({ method: 'POST', @@ -164,29 +194,66 @@ function createPlayerActionService(options) { method: 'GET', pathname: `/api/screens/${encodeURIComponent(slug)}/connections` }); - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + 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); + } + } + + if (!targetBaseUrls.length) { throw new Error('Unable to resolve the player internal base URL.'); } - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...authHeaders + 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 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; } }); - if (!response.ok) { - const errorText = await response.text().catch(function () { return ''; }); - const error = new Error(errorText || `Unable to load screen connections for ${slug}.`); - error.statusCode = response.status; - throw error; - } - - return response.json().catch(function () { - return { connections: [] }; - }); + return { + screen: screen, + screenSlug: slug, + count: mergedConnections.length, + connections: mergedConnections, + degraded: degraded + }; } async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) { @@ -234,6 +301,7 @@ function createPlayerActionService(options) { forwardPlayerCommand: forwardPlayerCommand, forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, + forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index e74f3ed..5ad675c 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -694,7 +694,8 @@ .table-pagination-page .app-content { height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .table-pagination-page .app-content .container-fluid { @@ -730,7 +731,7 @@ display: flex; flex: 0 1 auto; flex-direction: column; - min-height: 0; + min-height: var(--table-pagination-card-min-height, 0); max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem))); overflow: hidden; } diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 0eb9fcb..9eb6e4b 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -6,10 +6,170 @@ var getClientDisplayName = webUiHelpers.getClientDisplayName; var setButtonVariant = webUiHelpers.setButtonVariant; 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'); + var container = table && typeof table.closest === 'function' + ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) + : null; + + return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]'); + } + + function getClientListQueryState() { + var searchParams = new URLSearchParams(String(window.location && window.location.search || '')); + var searchInput = getClientSearchInput(); + var searchValue = searchInput ? String(searchInput.value || '').trim() : String(searchParams.get('search') || '').trim(); + + return { + search: searchValue, + sort: String(searchParams.get('sort') || '').trim(), + direction: String(searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc', + page: Math.max(1, Math.floor(Number(searchParams.get('page') || 1) || 1)) + }; + } + + function isClientSearchLoading() { + return Boolean(document.querySelector('[data-table-search-loading="true"]')); + } + + function getComparableClientSortValue(rawValue) { + var value = String(rawValue || '').trim(); + + if (!value) { + return { type: 'empty', value: '' }; + } + + var numericValue = Number(value.replace(/,/g, '')); + if (!Number.isNaN(numericValue)) { + return { type: 'number', value: numericValue }; + } + + var dateValue = Date.parse(value); + if (!Number.isNaN(dateValue)) { + return { type: 'date', value: dateValue }; + } + + return { type: 'string', value: value.toLowerCase() }; + } + + function compareClientSortValues(leftValue, rightValue) { + var left = getComparableClientSortValue(leftValue); + var right = getComparableClientSortValue(rightValue); + + if (left.type === 'empty' && right.type === 'empty') { + return 0; + } + if (left.type === 'empty') { + return 1; + } + if (right.type === 'empty') { + return -1; + } + if (left.type === right.type) { + if (left.value < right.value) { + return -1; + } + if (left.value > right.value) { + return 1; + } + return 0; + } + + return String(left.value).localeCompare(String(right.value), undefined, { numeric: true, sensitivity: 'base' }); + } + + function createClientSearchMatcher(searchTerm) { + var query = String(searchTerm || '').trim().toLowerCase(); + + if (!query) { + return function () { + return true; + }; + } + + return function (client) { + var searchableValues = [ + client && client.name, + client && client.client_name, + client && client.clientId, + client && client.deviceId, + client && client.slug, + client && client.screen_slug, + client && client.screen_name, + client && client.ipAddress, + client && client.clientIp, + client && client.status, + client && client.currentSlideTitle + ]; + + return searchableValues.map(function (value) { + return String(value || '').trim().toLowerCase(); + }).join(' ').indexOf(query) !== -1; + }; + } + + function sortClientsForTable(clients, sortKey, sortDirection) { + var normalizedSortKey = String(sortKey || '').trim(); + var normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; + var accessors = { + client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); }, + screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); }, + slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); }, + ip: function (client) { return String(client && client.clientIp || '').trim(); }, + viewport: function (client) { + var viewport = client && client.viewport; + if (!viewport || !viewport.width || !viewport.height) { + return ''; + } + return String(Number(viewport.width) || 0) + 'x' + String(Number(viewport.height) || 0); + }, + connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); } + }; + + function compareValues(leftValue, rightValue) { + return compareClientSortValues(leftValue, rightValue); + } + + var sortKeys = accessors[normalizedSortKey] + ? [normalizedSortKey] + : ['client']; + + if (sortKeys[0] === 'client') { + sortKeys.push('ip'); + } else if (sortKeys[0] === 'ip') { + sortKeys.push('client'); + } + + return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { + for (var index = 0; index < sortKeys.length; index += 1) { + var sortKeyName = sortKeys[index]; + var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient)); + + if (comparison !== 0) { + return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison; + } + } + + return 0; + }); + } + + function getVisibleClients(state) { + var query = getClientListQueryState(); + var clients = Array.isArray(state && state.clients) ? state.clients.slice() : []; + var searchMatcher = createClientSearchMatcher(query.search); + + clients = clients.filter(searchMatcher); + clients = sortClientsForTable(clients, query.sort, query.direction); + + 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'), @@ -17,7 +177,8 @@ 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]') + clientNameInput: document.querySelector('[data-client-move-client-name]'), + playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]') }; } @@ -50,6 +211,7 @@ 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 || []); @@ -71,6 +233,9 @@ 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; @@ -91,12 +256,12 @@ return [ '
', - '
', + '
', '', - '
', - '
', - '
', - '
', + '
', + '
', + '
', + '
', '
' ].join(''); } @@ -128,6 +293,10 @@ if (connectionInput) { connectionInput.value = client.id || ''; } + var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]'); + if (playerBaseUrlInput) { + playerBaseUrlInput.value = client.player_url || ''; + } pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -143,6 +312,10 @@ if (reloadInput) { reloadInput.value = client.id || ''; } + var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]'); + if (reloadPlayerBaseUrlInput) { + reloadPlayerBaseUrlInput.value = client.player_url || ''; + } reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?'); } @@ -175,6 +348,10 @@ if (blackoutConnectionInput) { blackoutConnectionInput.value = client.id || ''; } + var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]'); + if (blackoutPlayerBaseUrlInput) { + blackoutPlayerBaseUrlInput.value = client.player_url || ''; + } blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -196,6 +373,10 @@ if (previousConnectionInput) { previousConnectionInput.value = client.id || ''; } + var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]'); + if (previousPlayerBaseUrlInput) { + previousPlayerBaseUrlInput.value = client.player_url || ''; + } previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -217,6 +398,10 @@ if (nextConnectionInput) { nextConnectionInput.value = client.id || ''; } + var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]'); + if (nextPlayerBaseUrlInput) { + nextPlayerBaseUrlInput.value = client.player_url || ''; + } nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } } @@ -255,7 +440,7 @@ var actionCell = hasActionsColumn ? '' + renderClientActionCell(client) + '' : ''; return [ - '', + '', '
' + clientName + '
', '
' + screenName + '
', '' + currentSlide + '', @@ -267,6 +452,51 @@ ].join(''); } + function updateClientRowCells(row, client, hasActionsColumn) { + if (!row || !row.cells || row.cells.length < 6) { + return; + } + + var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; + var clientIpValue = normalizeDisplayIp(client.clientIp); + var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; + var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; + var clientNameValue = getClientDisplayName(client); + var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; + var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; + var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; + + row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client))); + row.setAttribute('data-client-id', escapeHtml(client.clientId || '')); + row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || '')); + row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || '')); + row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || '')); + + setCellHtml(row.cells[0], '
' + clientName + '
'); + setCellHtml(row.cells[1], '
' + screenName + '
'); + setCellHtml(row.cells[2], currentSlide); + setCellHtml(row.cells[3], clientIp); + setCellHtml(row.cells[4], viewport); + setCellHtml(row.cells[5], connectedAt); + + syncClientActionCell(row, client, hasActionsColumn); + } + + function createClientRowFromTemplate(client, hasActionsColumn) { + var template = document.createElement('tbody'); + template.innerHTML = renderClientRow(client, hasActionsColumn); + return template.firstElementChild || null; + } + + function setCellHtml(cell, html) { + if (!cell || String(cell.innerHTML || '') === String(html || '')) { + return false; + } + + cell.innerHTML = html; + return true; + } + function renderScreenTile(screen) { var clientCount = Number(screen.player_connection_count || 0); var connectionLabel = clientCount ? clientCount + ' live' : 'No clients'; @@ -327,76 +557,76 @@ } } - function updateClientTable(state) { + function updateClientTable(state, forceRender) { var tbody = document.getElementById('dashboard-clients-table-body'); if (!tbody || !Array.isArray(state.clients)) { return; } + if (!forceRender && isClientSearchLoading()) { + return; + } var table = document.getElementById('dashboard-clients-table'); var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true'); - if (!state.clients.length) { + var visibleClients = getVisibleClients(state); + + var canPatchRows = typeof tbody.querySelectorAll === 'function' + && typeof tbody.insertBefore === 'function' + && typeof tbody.removeChild === 'function' + && typeof document.createElement === 'function'; + + if (!visibleClients.length) { tbody.innerHTML = 'No connected clients yet.'; return; } + + if (!canPatchRows) { + tbody.innerHTML = visibleClients.map(function (client) { + return renderClientRow(client, hasActionsColumn); + }).join(''); + return; + } + var existingRows = {}; Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) { - existingRows[row.getAttribute('data-client-key')] = row; + existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row; }); - Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) { - if (!row.hasAttribute('data-client-key')) { - row.parentNode.removeChild(row); + var nextRows = visibleClients.map(function (client) { + var rowKey = String(getClientRowKey(client) || '').trim(); + var row = existingRows[rowKey] || null; + + if (!row) { + row = createClientRowFromTemplate(client, hasActionsColumn); + } else { + updateClientRowCells(row, client, hasActionsColumn); } + + return row; + }).filter(function (row) { + return Boolean(row); }); - state.clients.forEach(function (client, index) { - var rowKey = getClientRowKey(client); - var row = existingRows[rowKey]; - if (!row) { - var tempBody = document.createElement('tbody'); - tempBody.innerHTML = renderClientRow(client, hasActionsColumn); - row = tempBody.firstElementChild; - } - - if (!row) { - return; - } - - row.setAttribute('data-client-key', rowKey); - row.setAttribute('data-client-id', client.clientId || ''); - row.setAttribute('data-client-device-id', client.deviceId || ''); - row.setAttribute('data-client-screen-slug', client.screen_slug || ''); - if (row.cells && row.cells.length >= 6) { - var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; - var clientIpValue = normalizeDisplayIp(client.clientIp); - var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; - var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; - var clientNameValue = getClientDisplayName(client); - var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; - var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; - var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; - - row.cells[0].innerHTML = '
' + clientName + '
'; - row.cells[1].innerHTML = '
' + screenName + '
'; - row.cells[2].innerHTML = currentSlide; - row.cells[3].innerHTML = clientIp; - row.cells[4].innerHTML = viewport; - row.cells[5].innerHTML = connectedAt; - syncClientActionCell(row, client, hasActionsColumn); - } - + nextRows.forEach(function (row, index) { var referenceNode = tbody.children[index] || null; if (referenceNode !== row) { tbody.insertBefore(row, referenceNode); } }); - while (tbody.children.length > state.clients.length) { + while (tbody.children.length > nextRows.length) { tbody.removeChild(tbody.lastElementChild); } } + function refreshClientTableFromLatestState() { + if (!latestDashboardState) { + return; + } + + updateClientTable(latestDashboardState, true); + } + function updateKioskLauncherModal(state) { var modal = document.getElementById('dashboard-kiosk-launcher-modal'); if (!modal) { @@ -674,6 +904,7 @@ return; } latestDashboardState = state; + window.webLatestDashboardState = latestDashboardState; updateStats(state); updateScreenGrid(state); updateScreenCommandControls(state); @@ -895,6 +1126,7 @@ } window.webHandleDashboardState = handleDashboardState; + window.webRefreshClientTableFromLatestState = refreshClientTableFromLatestState; initClientRenameHandler(); initClientMoveHandler(); diff --git a/src/web/public/js/regions/type/time-date.js b/src/web/public/js/regions/type/time-date.js index c0219bd..dd936b0 100644 --- a/src/web/public/js/regions/type/time-date.js +++ b/src/web/public/js/regions/type/time-date.js @@ -313,6 +313,7 @@ '
' + '
Available placeholders
' + '
' + renderPlaceholderChips() + '
' + + '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, or {{title.lower()}}.
' + '
' + '
' + ''; diff --git a/src/web/public/js/table/table-search.js b/src/web/public/js/table/table-search.js index b19bd1d..689f791 100644 --- a/src/web/public/js/table/table-search.js +++ b/src/web/public/js/table/table-search.js @@ -1,4 +1,6 @@ (function () { + var minimumPaginationCardHeight = 20 * 16; + function rebindTableContainer(container) { if (!container) { return; @@ -33,8 +35,18 @@ var cardRect = card.getBoundingClientRect(); var bottomInset = 16; var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset); + var contentHeight = Math.max(0, card.scrollHeight || 0); + var shouldApplyMinimum = contentHeight > minimumPaginationCardHeight; + var cardHeight = Math.max(minimumPaginationCardHeight, availableHeight); + + if (shouldApplyMinimum) { + card.style.setProperty('--table-pagination-card-min-height', minimumPaginationCardHeight + 'px'); + } else { + card.style.removeProperty('--table-pagination-card-min-height'); + } + card.style.removeProperty('--table-pagination-card-height'); - card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px'); + card.style.setProperty('--table-pagination-card-max-height', cardHeight + 'px'); }); } @@ -112,6 +124,7 @@ var currentUrl = new URL(window.location.href); var pendingSearchTimer = null; var requestSequence = 0; + var pendingSearchRequests = 0; var container = input.closest('[data-table-search-container]') || input.closest('.card') || null; input.value = String(currentUrl.searchParams.get(searchParam) || '').trim(); @@ -133,6 +146,10 @@ requestSequence += 1; var sequenceId = requestSequence; + pendingSearchRequests += 1; + if (container) { + container.setAttribute('data-table-search-loading', 'true'); + } fetch(nextUrl.toString(), { method: 'GET', @@ -165,6 +182,11 @@ } }).catch(function () { window.location.assign(nextUrl.toString()); + }).finally(function () { + pendingSearchRequests = Math.max(0, pendingSearchRequests - 1); + if (container && pendingSearchRequests === 0) { + container.removeAttribute('data-table-search-loading'); + } }); } diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index acb9e5b..682c44a 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -6,6 +6,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const forwardPlayerCommand = deps.forwardPlayerCommand; + const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; const withClientNameReservation = deps.withClientNameReservation; @@ -13,11 +14,74 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const requirePermission = deps.requirePermission; const ALL_SCREENS_SLUG = '__all__'; + async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) { + if (typeof getScreenConnections !== 'function' || !screenSlug) { + return []; + } + + try { + const response = await getScreenConnections(screenSlug); + const liveConnections = Array.isArray(response && response.connections) ? response.connections : []; + if (!liveConnections.length) { + return []; + } + + const normalizedConnectionId = String(connectionId || '').trim(); + const liveConnection = normalizedConnectionId + ? liveConnections.find(function (connection) { + const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); + const candidateDeviceId = String(connection && connection.deviceId || '').trim(); + return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId; + }) + : null; + const targetConnections = liveConnection ? [liveConnection] : liveConnections; + + return Array.from(new Set(targetConnections.map(function (connection) { + return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); + }).filter(Boolean))); + } catch (_error) { + return []; + } + } + + function normalizeExplicitPlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; + } + + async function resolveAllPlayerBaseUrls() { + if (!common || typeof common.fetchPlayerRegistrations !== 'function') { + return []; + } + + try { + const players = await common.fetchPlayerRegistrations(pool); + return Array.from(new Set((Array.isArray(players) ? players : []) + .filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }) + .map(function (player) { + return String(player && player.public_base_url || '').trim().replace(/\/$/, ''); + }) + .filter(Boolean))); + } catch (_error) { + return []; + } + } + app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { try { const slug = String(req.params.slug || '').trim(); const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); + const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || ''); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; @@ -47,7 +111,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) { } await Promise.all(screenRows.map(function (screenRow) { - return forwardPlayerCommand(String(screenRow && screenRow.slug || '').trim(), commandPayload); + const screenSlug = String(screenRow && screenRow.slug || '').trim(); + return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) { + if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { + return Promise.all(playerBaseUrls.map(function (playerBaseUrl) { + return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined); + })); + } + + return forwardPlayerCommand(screenSlug, commandPayload); + }); })); if (typeof broadcastDashboardState === 'function') { @@ -74,6 +147,28 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return res.status(404).json({ error: 'Screen not found' }); } + if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') { + const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) + ? Object.assign({}, req.body, { command: command }) + : { command: command }; + if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { + commandPayload.blackout = blackoutValue; + } + + const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined); + + if (typeof broadcastDashboardState === 'function') { + await broadcastDashboardState(); + } + + return res.json(Object.assign({ + screen: screenRows[0], + screenSlug: slug, + command: command, + connectionId: connectionId || null + }, result && typeof result === 'object' ? result : {})); + } + if (command === 'setclientname') { const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); @@ -256,17 +351,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId; }) : null; - const targetBaseUrl = String( + const sourcePlayerBaseUrl = String( + explicitPlayerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || '' ).trim().replace(/\/$/, ''); - const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; + const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; - await forwardPlayerCommand(slug, { - command: 'redirect', - url: targetPlayerUrl - }, connectionId || deviceId || undefined); + if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { + await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, { + command: 'redirect', + url: targetPlayerUrl + }, connectionId || deviceId || undefined); + } else { + await forwardPlayerCommand(slug, { + command: 'redirect', + url: targetPlayerUrl + }, connectionId || deviceId || undefined); + } if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); @@ -292,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) { commandPayload.blackout = blackoutValue; } - const result = connectionId - ? await forwardPlayerCommand(slug, commandPayload, connectionId) - : await forwardPlayerCommand(slug, commandPayload); + const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId); + const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function' + ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { + return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId); + })).then(function (results) { + return Array.isArray(results) && results.length ? results[0] : { ok: true }; + }) + : (connectionId + ? await forwardPlayerCommand(slug, commandPayload, connectionId) + : await forwardPlayerCommand(slug, commandPayload)); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); diff --git a/src/web/routes/register.js b/src/web/routes/register.js index c00e35b..6a39c3e 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -33,6 +33,7 @@ function registerRoutes(app, deps) { common: deps.common, pages: deps.pages, mediaDir: deps.mediaDir, + formatDashboardDate: deps.formatDashboardDate, buildDashboardState: deps.buildDashboardState, fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId, requirePermission: deps.requirePermission, @@ -141,6 +142,7 @@ function registerSignageRoutes(app, deps) { pool: deps.pool, common: deps.common, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, + forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, getScreenConnections: deps.playerActionService.getScreenConnections, isClientNameAvailable: deps.isClientNameAvailable, withClientNameReservation: deps.withClientNameReservation, diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js index 2162834..057b345 100644 --- a/src/web/routes/signage/clients/routes.js +++ b/src/web/routes/signage/clients/routes.js @@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) { const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; const { buildPagination } = require('../../../lib/pagination'); - const { sortRows, createSearchMatcher } = require('../../../lib/list-query'); + const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query'); const requirePermission = deps.requirePermission; const LIST_PAGE_SIZE = 25; @@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) { connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); } }; - if (!accessors[normalizedSortKey]) { - return Array.isArray(clients) ? clients.slice() : []; + function compareValues(leftValue, rightValue) { + return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue)); } - return sortRows(clients, function (client) { - return accessors[normalizedSortKey](client); - }, normalizedDirection); + const sortKeys = accessors[normalizedSortKey] + ? [normalizedSortKey] + : ['client']; + + if (sortKeys[0] === 'client') { + sortKeys.push('ip'); + } else if (sortKeys[0] === 'ip') { + sortKeys.push('client'); + } + + return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { + for (let index = 0; index < sortKeys.length; index += 1) { + const sortKeyName = sortKeys[index]; + const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient)); + + if (comparison !== 0) { + return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison; + } + } + + return 0; + }); } app.get('/clients', requirePermission('clients.read'), async function (req, res, next) { diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index bc4236d..0356eac 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) { return null; } - const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { + const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => { return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; }); - const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; + const duration = videoRegions.reduce((longest, region) => { + const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000; + return regionDuration > longest ? regionDuration : longest; + }, 0); return Number.isFinite(duration) && duration > 0 ? duration : null; } catch (_error) { return null; diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index be815a8..9503888 100644 --- a/src/web/routes/signage/screens/routes.js +++ b/src/web/routes/signage/screens/routes.js @@ -12,6 +12,14 @@ module.exports = function registerScreensRoutes(app, deps) { const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; + } + const LIST_PAGE_SIZE = 25; const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat'); const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh'); @@ -87,13 +95,18 @@ module.exports = function registerScreensRoutes(app, deps) { ? await common.fetchPlayerRegistrations(pool) : []; - return (Array.isArray(playerRegistrations) ? playerRegistrations : []).map(function (player) { + return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }).map(function (player) { const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, ''); const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl); - return Object.assign({}, player, { + return { + identifier: String(player && player.identifier || '').trim(), public_base_url: baseUrl || null, player_url: playerUrl || null - }); + }; + }).sort(function (left, right) { + return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true }); }); } diff --git a/src/web/views/signage/clients/list.hbs b/src/web/views/signage/clients/list.hbs index 12c643e..11d213e 100644 --- a/src/web/views/signage/clients/list.hbs +++ b/src/web/views/signage/clients/list.hbs @@ -97,7 +97,7 @@ {{#if clients.length}} {{#each clients}} - +
{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}
@@ -129,33 +129,38 @@ Unknown {{/if}} - {{#if (hasPermission currentUser 'clients.allow')}} + {{#if (hasPermission ../currentUser 'clients.allow')}}
+
+
+
+
+
@@ -185,6 +190,7 @@ +