Merge branch 'feature/player-agent-control-plane'

This commit is contained in:
2026-08-07 22:42:03 +01:00
62 changed files with 2440 additions and 461 deletions
-18
View File
@@ -1,18 +0,0 @@
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
PULSE_SIGNAGE_SHARED_SECRET=
DB_HOST=mysql
DB_PORT=3306
DB_NAME=pulse-signage
DB_USER=pulse-signage
DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
PLAYER_INTERNAL_BASE_URL=http://player:8081
SESSION_MAX_AGE_DAYS=14
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_NAME=Admin
DEFAULT_ADMIN_PASSWORD=admin
PASSWORD_HASH_ITERATIONS=310000
+4
View File
@@ -3,6 +3,10 @@ media/
!src/web/lib/media/ !src/web/lib/media/
!src/web/lib/media/** !src/web/lib/media/**
docker-compose.dev.yml docker-compose.dev.yml
/docker-compose/*.dev.yml
/docker-compose/*.env
/docker-compose/*.env.local
/docker-compose/*.env.remote
/dev-demo-seed.js /dev-demo-seed.js
.vscode/ .vscode/
.env .env
+24 -67
View File
@@ -1,81 +1,38 @@
# Pulse Signage # Pulse Signage
Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen. Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen.
It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service. It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service.
## Why It Stands Out ## What It Can Do
- Keep signage under your own control. - Run polished screen experiences with playlists, slides, and reusable templates.
- Build polished screen experiences with reusable templates and playlists. - Keep announcements, schedules, RSS feeds, API content, and other live data in sync.
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens. - Manage many screens from a single dashboard.
- See what is live, what is queued, and what needs attention from a single admin dashboard. - Support everyday signage, event messages, lobbies, dashboards, and other always-on displays.
- Use the same system for everyday signage, timed messages, and more structured information displays. - Keep the player, dashboard, and bridge connected so updates move quickly and consistently.
## What It Handles ## Why It Fits
- Screens and playlist assignments. - It keeps signage under your own control.
- Slides, templates, and canvas sizes. - It is built for teams that need screens to stay current without extra manual work.
- Announcements and screen-specific targeting. - It works well for offices, venues, campuses, and operations teams.
- RSS feeds, API sources, and schedule-based content. - It stays focused on display management instead of trying to be a general-purpose CMS.
- Roles and permissions for admin access.
- Background tasks and scheduled refresh jobs.
- Player onboarding and live playback control.
## Simple Deployment ## Deploying
Pulse Signage ships with a Docker Compose stack for the published image: Docker Compose is the recommended way to deploy Pulse Signage. It keeps the web app, player, bridge, and database together in a predictable setup.
- Published-image stack: `docker compose -f docker-compose.yml up -d` If you want the details, start with the [Compose guide](docker-compose/README.md).
Use this stack when you want to run the tagged image instead of building from source. ## Docs
## Environment File - [Documentation home](docs/README.md) - the technical reference index.
- [API reference](docs/api.md) - the player HTTP surface and onboarding endpoints.
- [Database schema](docs/schema.md) - the tables and data model the app maintains.
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
- [Compose guide](docker-compose/README.md) - deployment options and service layout.
The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup. ## Explore The Docs
The variables are split by who uses them: The project includes a dashboard for managing content, a player runtime for rendering screens, an onboarding flow for connecting devices, and a bridge layer for remote screens. If you want to understand how the pieces fit together, the docs above cover the technical details without repeating the project overview.
Shared across the stack:
- `PULSE_SIGNAGE_IMAGE`
- `PULSE_SIGNAGE_SHARED_SECRET`
Web container:
- `SESSION_MAX_AGE_DAYS`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
- `PASSWORD_HASH_ITERATIONS`
Player container:
- `PLAYER_PUBLIC_BASE_URL`
- `PLAYER_INTERNAL_BASE_URL`
MySQL container:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
## First-Time Use
The first startup seeds a default admin account if the database is empty.
- Username: `admin`
- Password: `admin`
## In Practice
Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
+26
View File
@@ -0,0 +1,26 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services
DB_HOST="mysql"
DB_PORT=3306
DB_NAME="pulse-signage"
DB_USER="pulse-signage"
DB_PASSWORD="signage_password"
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"
# 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
# Bridge settings for the player-bridge service
WEB_BASE_URL="http://web:8080"
+11
View File
@@ -0,0 +1,11 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings
PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote player connectivity settings
THIN_CLIENT_BASE_URL="http://192.168.0.80:8090"
+28
View File
@@ -0,0 +1,28 @@
name: pulse-signage-remote
services:
player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
restart: unless-stopped
ports:
- "8081:8081"
environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
volumes:
- pulse-signage:/app/media
command: ["node", "src/player.js"]
networks:
- pulse_signage
volumes:
pulse-signage:
networks:
pulse_signage:
name: pulse-signage-remote
external: false
@@ -1,3 +1,5 @@
name: pulse-signage
services: services:
web: web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
@@ -7,18 +9,17 @@ services:
environment: environment:
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes: volumes:
- media:/app/media - pulse-signage:/app/media
command: ["node", "src/web.js"] command: ["node", "src/web.js"]
depends_on: depends_on:
mysql: mysql:
@@ -34,14 +35,15 @@ services:
environment: environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes: volumes:
- media:/app/media - pulse-signage:/app/media
command: ["node", "src/player.js"] command: ["node", "src/player.js"]
depends_on: depends_on:
mysql: mysql:
@@ -49,12 +51,34 @@ services:
networks: networks:
- pulse_signage - pulse_signage
player-bridge:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
restart: unless-stopped
ports:
- "8090:8090"
environment:
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
command: ["node", "src/player-bridge/index.js"]
depends_on:
mysql:
condition: service_healthy
networks:
- pulse_signage
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
restart: unless-stopped restart: unless-stopped
ports:
- "3306:3306"
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_DATABASE: ${DB_NAME:-pulse-signage}
MYSQL_USER: ${DB_USER:-signage_user} MYSQL_USER: ${DB_USER:-pulse-signage}
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password} MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
command: command:
@@ -72,9 +96,9 @@ services:
volumes: volumes:
mysql_data: mysql_data:
media: pulse-signage:
networks: networks:
pulse_signage: pulse_signage:
name: pulse_signage name: pulse-signage
external: false external: false
+13
View File
@@ -0,0 +1,13 @@
# Documentation
This folder contains the technical reference material for Pulse Signage.
## Whats Here
- [API reference](api.md) - the player HTTP surface and onboarding endpoints.
- [Database schema](schema.md) - the tables and data model used by the app.
- [WebSocket reference](websocket.md) - the live player and snapshot channels.
## How To Read It
If you want the big picture first, start with the main [project README](../README.md). It gives a plain overview of what Pulse Signage does, while the pages in this folder explain how the pieces work.
+6 -7
View File
@@ -113,7 +113,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
## Devices ## Devices
- `d_players` - player registry and connection metadata. - `d_players` - player registry and connection metadata.
- `d_screens` - screen records and playlist/player assignment. - `d_screens` - screen records and playlist assignment.
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names. - `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
## Announcements ## Announcements
@@ -123,18 +123,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `d_players` ### `d_players`
- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at` - `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
- `device_id` is the primary key. - `id` is the primary key.
- `identifier` is unique and is the stable player identity used by the app.
### `d_screens` ### `d_screens`
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `name`, `slug`, `playlist_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `slug` is unique. - `slug` is unique.
- Foreign keys: - Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL` - `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
- `player_id` -> `d_players.id` with `ON DELETE RESTRICT`
- `player_id` is required and defaults to `'1'` for the current singleton-player model. - Screens are no longer tied to a player foreign key directly; player registration and live connection metadata are tracked separately in `d_players`.
### `d_onboarding_devices` ### `d_onboarding_devices`
@@ -276,7 +276,6 @@ erDiagram
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses C_PLAYLISTS ||--o{ D_SCREENS : uses
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.5.14", "version": "2.6.5",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+1
View File
@@ -89,6 +89,7 @@ module.exports = {
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl, fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord, fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
fetchTemplateById: data.fetchTemplateById, fetchTemplateById: data.fetchTemplateById,
fetchPlayerRegistrations: data.fetchPlayerRegistrations,
fetchSlideById: data.fetchSlideById, fetchSlideById: data.fetchSlideById,
fetchTemplatesData: data.fetchTemplatesData, fetchTemplatesData: data.fetchTemplatesData,
fetchCanvasSizesData: data.fetchCanvasSizesData, fetchCanvasSizesData: data.fetchCanvasSizesData,
+1 -2
View File
@@ -22,10 +22,9 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC ORDER BY s.id DESC
`); `);
const [screens] = await pool.query(` const [screens] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM d_screens s FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_id
LEFT JOIN d_players pl ON pl.device_id = s.player_id
ORDER BY s.id DESC ORDER BY s.id DESC
`); `);
const [playlistSlides] = await pool.query(` const [playlistSlides] = await pool.query(`
+4 -1
View File
@@ -7,7 +7,8 @@ const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules'); const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord } = require('./screens'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes'); const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides'); const { fetchSlideById, buildSlidePayload } = require('./slides');
@@ -63,6 +64,8 @@ module.exports = {
fetchScreenPlayerUrls, fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl, fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord, fetchScreenPlayerRecord,
fetchPlayerRecordByIdentifier,
fetchPlayerRegistrations,
fetchTemplateById, fetchTemplateById,
fetchSlideById, fetchSlideById,
fetchTemplatesData, fetchTemplatesData,
+140
View File
@@ -0,0 +1,140 @@
function normalizeDeviceId(value) {
return String(value || '')
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 128);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeIdentifier(value) {
return String(value || '').trim().slice(0, 255);
}
async function columnExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].column_count) > 0;
}
async function fetchPlayerRegistrations(pool) {
if (!pool) {
return [];
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at
FROM d_players
ORDER BY modified_at DESC, identifier ASC`
);
return rows;
}
function getConfiguredPlayerIdentifier() {
return normalizeDeviceId(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '');
}
async function resolvePlayerRegistration(pool, identifier) {
const normalizedIdentifier = normalizeDeviceId(identifier);
if (!pool || !normalizedIdentifier) {
return null;
}
const hasIdentifierColumn = await columnExists(pool, 'd_players', 'identifier');
const hasDeviceIdColumn = await columnExists(pool, 'd_players', 'device_id');
const identifierColumn = hasIdentifierColumn ? 'identifier' : (hasDeviceIdColumn ? 'device_id' : '');
if (!identifierColumn) {
return null;
}
const selectIdExpression = hasIdentifierColumn ? 'id' : 'NULL AS id';
const selectIdentifierExpression = hasIdentifierColumn ? 'identifier' : 'device_id AS identifier';
const orderByExpression = hasIdentifierColumn ? 'modified_at DESC, id DESC' : 'modified_at DESC';
const [rows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE ${identifierColumn} = ?
LIMIT 1`,
[normalizedIdentifier]
);
if (rows[0]) {
return rows[0];
}
const [fallbackRows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
ORDER BY ${orderByExpression}
LIMIT 1`
);
return fallbackRows[0] || null;
}
async function upsertPlayerRegistration(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = VALUES(public_base_url),
internal_base_url = VALUES(internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
);
return resolvePlayerRegistration(pool, identifier);
}
async function recordPlayerHeartbeat(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = COALESCE(VALUES(public_base_url), public_base_url),
internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
);
return resolvePlayerRegistration(pool, identifier);
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
normalizeIdentifier: normalizeIdentifier,
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
fetchPlayerRegistrations: fetchPlayerRegistrations,
resolvePlayerRegistration: resolvePlayerRegistration,
upsertPlayerRegistration: upsertPlayerRegistration,
recordPlayerHeartbeat: recordPlayerHeartbeat
};
+48 -19
View File
@@ -13,6 +13,27 @@ function normalizePlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, ''); return String(value || '').trim().replace(/\/$/, '');
} }
function getConfiguredPlayerIdentifier() {
return String(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '').trim() || null;
}
async function fetchPlayerRecordByIdentifier(pool, identifier) {
const normalizedIdentifier = String(identifier || '').trim();
if (!normalizedIdentifier) {
return null;
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE identifier = ?
LIMIT 1`,
[normalizedIdentifier]
);
return rows[0] || null;
}
function buildScreenPlayerUrl(screen, fallbackBaseUrl) { function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
const slug = String(screen && screen.slug || '').trim(); const slug = String(screen && screen.slug || '').trim();
if (!slug) { if (!slug) {
@@ -28,20 +49,21 @@ function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
} }
async function fetchScreenPlayerUrls(pool) { async function fetchScreenPlayerUrls(pool) {
const playerBaseUrl = await fetchPlayerPublicBaseUrl(pool);
if (!playerBaseUrl) {
return {};
}
const [rows] = await pool.query(` const [rows] = await pool.query(`
SELECT s.slug, p.public_base_url SELECT slug
FROM d_screens s FROM d_screens
LEFT JOIN d_players p ON p.device_id = s.player_id ORDER BY slug ASC
WHERE p.public_base_url IS NOT NULL
AND TRIM(p.public_base_url) <> ''
ORDER BY p.modified_at DESC, s.slug ASC
`); `);
const playerUrls = {}; const playerUrls = {};
rows.forEach(function (row) { rows.forEach(function (row) {
const slug = String(row && row.slug || '').trim(); const slug = String(row && row.slug || '').trim();
const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url); const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl);
const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl);
if (slug && playerUrl && !playerUrls[slug]) { if (slug && playerUrl && !playerUrls[slug]) {
playerUrls[slug] = playerUrl; playerUrls[slug] = playerUrl;
} }
@@ -51,27 +73,32 @@ async function fetchScreenPlayerUrls(pool) {
} }
async function fetchPlayerPublicBaseUrl(pool) { async function fetchPlayerPublicBaseUrl(pool) {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const [rows] = await pool.query(` const [rows] = await pool.query(`
SELECT public_base_url SELECT public_base_url
FROM d_players FROM d_players
WHERE device_id = '1' WHERE identifier = ?
LIMIT 1 LIMIT 1
`); `, [configuredPlayerIdentifier]);
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null; return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
} }
async function fetchScreenPlayerRecord(pool, slug) { async function fetchScreenPlayerRecord(pool, slug) {
const [rows] = await pool.query(` if (slug) {
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at const [rows] = await pool.query(
FROM d_screens s `SELECT slug
JOIN d_players p ON p.device_id = s.player_id FROM d_screens
WHERE s.slug = ? WHERE slug = ?
ORDER BY p.modified_at DESC, p.device_id ASC LIMIT 1`,
LIMIT 1 [slug]
`, [slug]); );
if (!rows.length) {
return null;
}
}
return rows[0] || null; return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier());
} }
async function uniqueScreenSlug(pool, baseSlug, excludeId) { async function uniqueScreenSlug(pool, baseSlug, excludeId) {
@@ -112,6 +139,8 @@ async function fetchScreenEditData(pool) {
module.exports = { module.exports = {
slugify, slugify,
normalizePlayerBaseUrl, normalizePlayerBaseUrl,
getConfiguredPlayerIdentifier,
fetchPlayerRecordByIdentifier,
buildScreenPlayerUrl, buildScreenPlayerUrl,
fetchScreenPlayerUrls, fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl, fetchPlayerPublicBaseUrl,
+31 -1
View File
@@ -1,5 +1,17 @@
// Snapshot only: keep this file aligned with the current schema state. // Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) { async function ensureSchema(pool, options) {
const schemaLockName = 'pulse_signage_schema_lock';
const schemaConnection = await pool.getConnection();
try {
pool = schemaConnection;
const [lockRows] = await pool.query('SELECT GET_LOCK(?, 120) AS lock_acquired', [schemaLockName]);
if (!Number(lockRows && lockRows[0] && lockRows[0].lock_acquired)) {
throw new Error('Unable to acquire the schema migration lock.');
}
try {
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS c_canvas_sizes ( CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -116,13 +128,24 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_players (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(128) NOT NULL UNIQUE,
public_base_url VARCHAR(512) NULL,
internal_base_url VARCHAR(512) NULL,
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS d_screens ( CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE, slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL, playlist_id INT NULL,
player_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -362,6 +385,13 @@ async function ensureSchema(pool, options) {
const { runMigrations } = require('./migrations'); const { runMigrations } = require('./migrations');
await runMigrations(pool, options); await runMigrations(pool, options);
} finally {
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
});
}
} finally {
schemaConnection.release();
}
} }
module.exports = { module.exports = {
+139 -5
View File
@@ -19,10 +19,6 @@ const VERSIONED_MIGRATIONS = [
`); `);
} }
// Seed the singleton player row used by the current one-player model.
// For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced.
await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`);
// Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id.
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
if (!(await columnExists(pool, 'd_screens', 'player_id'))) { if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
@@ -212,6 +208,93 @@ const VERSIONED_MIGRATIONS = [
run: async function (pool) { run: async function (pool) {
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio'); await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
} }
},
{
version: '2.6.2',
label: 'v2.6.2 player identity schema',
run: async function (pool) {
if (!(await columnExists(pool, 'd_players', 'device_id'))) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
if (!(await columnExists(pool, 'd_players', 'identifier'))) {
await ensureColumn(pool, 'd_players', 'identifier', 'VARCHAR(128) NOT NULL UNIQUE', 'id');
}
if (!(await columnExists(pool, 'd_players', 'id'))) {
await ensureColumn(pool, 'd_players', 'id', 'INT NOT NULL AUTO_INCREMENT', null);
await pool.query('ALTER TABLE d_players ADD PRIMARY KEY (id)');
} else if (!(await columnIsAutoIncrement(pool, 'd_players', 'id'))) {
await pool.query('ALTER TABLE d_players MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT');
}
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
return;
}
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await pool.query('DROP TABLE IF EXISTS d_players_rebuild');
await pool.query(`
CREATE TABLE d_players_rebuild (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(128) NOT NULL UNIQUE,
public_base_url VARCHAR(512) NULL,
internal_base_url VARCHAR(512) NULL,
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
INSERT INTO d_players_rebuild (identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at)
SELECT DISTINCT
device_id AS identifier,
public_base_url,
internal_base_url,
last_seen_at,
created_at,
modified_at
FROM d_players
ORDER BY COALESCE(created_at, modified_at, device_id), device_id
`);
await pool.query('DROP TEMPORARY TABLE IF EXISTS d_player_id_map');
await pool.query(`
CREATE TEMPORARY TABLE d_player_id_map AS
SELECT old_players.device_id AS old_device_id, rebuilt_players.id AS new_player_id
FROM d_players old_players
JOIN d_players_rebuild rebuilt_players ON rebuilt_players.identifier = old_players.device_id
`);
await pool.query(
`UPDATE d_screens s
JOIN d_player_id_map m ON m.old_device_id = CAST(s.player_id AS CHAR)
SET s.player_id = m.new_player_id
WHERE s.player_id IS NOT NULL`
);
await pool.query('DROP TABLE d_players');
await pool.query('RENAME TABLE d_players_rebuild TO d_players');
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
}
},
{
version: '2.6.3',
label: 'v2.6.3 screen-player fk removal',
run: async function (pool) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
if (await columnExists(pool, 'd_screens', 'player_id')) {
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
}
}
},
{
version: '2.6.4',
label: 'v2.6.4 drop screen player id',
run: async function (pool) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await dropColumnIfExists(pool, 'd_screens', 'player_id');
}
} }
]; ];
@@ -228,6 +311,20 @@ async function columnExists(pool, tableName, columnName) {
return Number(rows && rows[0] && rows[0].column_count) > 0; return Number(rows && rows[0] && rows[0].column_count) > 0;
} }
async function columnIsAutoIncrement(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS auto_increment_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND EXTRA LIKE '%auto_increment%'`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].auto_increment_count) > 0;
}
async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) { async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
if (await columnExists(pool, tableName, columnName)) { if (await columnExists(pool, tableName, columnName)) {
return; return;
@@ -289,6 +386,32 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
} }
} }
async function dropForeignKeyIfExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT CONSTRAINT_NAME AS constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1`,
[tableName, columnName]
);
const constraintName = String(rows && rows[0] && rows[0].constraint_name || '').trim();
if (!constraintName) {
return;
}
try {
await pool.query('ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + constraintName);
} catch (error) {
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
throw error;
}
}
}
async function dropColumnIfExists(pool, tableName, columnName) { async function dropColumnIfExists(pool, tableName, columnName) {
if (await columnExists(pool, tableName, columnName)) { if (await columnExists(pool, tableName, columnName)) {
try { try {
@@ -443,9 +566,20 @@ async function runMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version. // Only run migrations that are newer than the installed schema version and not beyond the app version.
const targetVersion = String(appVersion || '0.0.0').trim(); const targetVersion = String(appVersion || '0.0.0').trim();
const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id');
let effectiveCurrentVersion = currentVersion;
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
effectiveCurrentVersion = '2.1.0';
}
if (!screenPlayerColumnPresent && compareVersions(effectiveCurrentVersion, '2.6.3') < 0) {
effectiveCurrentVersion = '2.6.3';
}
for (const migration of VERSIONED_MIGRATIONS) { for (const migration of VERSIONED_MIGRATIONS) {
if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
await migration.run(pool); await migration.run(pool);
} }
} }
+130 -44
View File
@@ -1,8 +1,10 @@
// Player onboarding routes and signup flow helpers. // Player onboarding routes and signup flow helpers.
const express = require('express');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code'); const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth'); const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
const { isTransientDbError } = require('./store'); const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000; const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
@@ -133,7 +135,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}); });
} }
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) { async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) {
const normalizedDeviceId = normalizeDeviceId(deviceId); const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, ''); const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, ''); const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
@@ -141,18 +143,11 @@ async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalB
return null; return null;
} }
await pool.query( return upsertPlayerRegistrationRecord(pool, {
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at) identifier: normalizedDeviceId,
VALUES (?, ?, ?, CURRENT_TIMESTAMP) publicBaseUrl: normalizedPublicBaseUrl || null,
ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, internalBaseUrl: normalizedInternalBaseUrl || null
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null] });
);
return {
device_id: normalizedDeviceId,
public_base_url: normalizedPublicBaseUrl || null,
internal_base_url: normalizedInternalBaseUrl || null
};
} }
async function bindPlayerToScreen(pool, deviceId, screenSlug) { async function bindPlayerToScreen(pool, deviceId, screenSlug) {
@@ -167,25 +162,9 @@ async function bindPlayerToScreen(pool, deviceId, screenSlug) {
return null; return null;
} }
const screenId = Number(screenRows[0].id);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Current model: every screen binds to the shared player row '1'.
// If we introduce multiple players, resolve the correct player row here instead of hardcoding it.
await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
return { return {
screen_id: screenId, screen_id: Number(screenRows[0].id),
screen_slug: normalizedScreenSlug, screen_slug: normalizedScreenSlug
player_id: '1'
}; };
} }
@@ -224,13 +203,66 @@ function registerPlayerOnboardingRoutes(app, options) {
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : 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 playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
if (!app || !pool || !common || !playerRuntime) { if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.'); throw new Error('registerPlayerOnboardingRoutes requires app and common.');
}
if (!thinClientBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
if (req.headers['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
function requireOnboardingPageAuth(req, res, next) { function requireOnboardingPageAuth(req, res, next) {
if (!sharedSecret) { if (!sharedSecret) {
return next(); return next();
@@ -254,7 +286,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/onboard', async function (req, res, next) { app.get('/onboard', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try { try {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -272,15 +304,32 @@ function registerPlayerOnboardingRoutes(app, options) {
next(); next();
}, async function (req, res, next) { }, async function (req, res, next) {
try { try {
const status = await getOnboardingStatus(pool, req.query.deviceId); const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Player bridge unavailable.' });
}
res.status(response.status);
const payload = await readJsonResponse(response);
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;
return res.json(payload);
}
const status = await getOnboardingStatus(pool, deviceId);
res.json({ res.json({
deviceId: normalizeDeviceId(req.query.deviceId), deviceId: normalizeDeviceId(deviceId),
onboarded: Boolean(status && status.screen_id), onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null, clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null, screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null, screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null, screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -289,6 +338,16 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try { try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
method: 'GET'
});
res.status(response.status);
const text = await response.text();
res.type(response.headers.get('content-type') || 'application/json');
return res.send(text);
}
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows }); res.json({ screens: rows });
} catch (error) { } catch (error) {
@@ -298,11 +357,11 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/qr', async function (req, res, next) { app.get('/api/onboarding/qr', async function (req, res, next) {
try { try {
const deviceId = normalizeDeviceId(req.query.deviceId); const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
} }
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
@@ -312,9 +371,9 @@ function registerPlayerOnboardingRoutes(app, options) {
} }
}); });
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) { app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
try { try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId); const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
const clientName = String((req.body && req.body.clientName) || '').trim(); const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId); const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
@@ -322,6 +381,30 @@ function registerPlayerOnboardingRoutes(app, options) {
res.set('Retry-After', String(retryAfterSeconds)); res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' }); return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
} }
if (thinClientBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
});
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), {
method: 'POST',
headers: Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/onboarding',
body: forwardedBody
}), req.headers['x-pulse-page-auth'] ? { 'x-pulse-page-auth': String(req.headers['x-pulse-page-auth']).trim() } : {}),
body: JSON.stringify(forwardedBody)
});
res.status(response.status);
const payload = await readJsonResponse(response);
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)}`;
return res.json(payload);
}
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
} }
@@ -340,11 +423,14 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status && status.screen_id ? status.screen_id : null, screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug, screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null, screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued) queued: Boolean(status && status.queued)
}); });
} catch (error) { } catch (error) {
next(error); const statusCode = Number(error && error.statusCode || error && error.status || 500);
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
error: error && error.message ? error.message : 'Onboarding failed.'
});
} }
}); });
} }
@@ -39,8 +39,14 @@
return screens; return screens;
}); });
} }
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; } try {
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {} if (!deviceId) {
deviceId = window.localStorage.getItem(deviceKey) || "";
}
if (deviceId) {
window.localStorage.setItem(deviceKey, deviceId);
}
} catch (_error) {}
loadScreens().then(function () { loadScreens().then(function () {
try { try {
var storedClientName = window.localStorage.getItem(clientNameKey) || ""; var storedClientName = window.localStorage.getItem(clientNameKey) || "";
@@ -58,10 +64,14 @@
if (!clientName) { setMessage("Client name is required."); return; } if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; } if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client..."); setMessage("Saving client...");
var payload = { clientName: clientName, screenSlug: screenSlug };
if (deviceId) {
payload.deviceId = deviceId;
}
fetch("/api/onboarding", { fetch("/api/onboarding", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug }) body: JSON.stringify(payload)
}) })
.then(function (response) { .then(function (response) {
if (response.ok) { if (response.ok) {
@@ -131,7 +131,7 @@
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; } if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) { if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; } if (clientNameInput) { clientNameInput.value = storedClientName; }
} }
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; } if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
} catch (_error) {} } catch (_error) {}
+27 -30
View File
@@ -263,20 +263,33 @@ function renderSlideMarkup(markup, shouldFade) {
}); });
} }
function schedulePostRenderSetup(root, delayMs) {
if (!root) {
return;
}
if (root.isConnected === false) {
return;
}
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(root);
}
initializeRenderedVideoPlayback(root, delayMs);
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
}
}
if (!shouldFade) { if (!shouldFade) {
app.innerHTML = markup; app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') { schedulePostRenderSetup(app, 0);
syncRtmpRegions(app);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
return app.firstElementChild; return app.firstElementChild;
} }
@@ -325,11 +338,7 @@ function renderSlideMarkup(markup, shouldFade) {
app.innerHTML = ''; app.innerHTML = '';
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
app.appendChild(nextShell); app.appendChild(nextShell);
if (typeof syncRtmpRegions === 'function') { schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell; return nextShell;
} }
@@ -340,24 +349,12 @@ function renderSlideMarkup(markup, shouldFade) {
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2); pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell); app.appendChild(nextShell);
void nextShell.offsetHeight;
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
previousShell.style.opacity = '0'; previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') { schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
playRegionAnimations(nextShell, 'intro');
}
}); });
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) { if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell); previousShell.parentNode.removeChild(previousShell);
@@ -54,6 +54,9 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
index = currentIndex; index = currentIndex;
var markup = buildSlideMarkup(slide); var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides); renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
sendCommandState(slide); sendCommandState(slide);
if (!isPaused) { if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000)); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
@@ -90,6 +93,9 @@ function showCurrent() {
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(activeSlides, index); syncRtmpWarmups(activeSlides, index);
} }
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(activeSlides, index);
}
if (!activeSlides.length) { if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
@@ -211,6 +217,9 @@ function refresh() {
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(nextActiveSlides, index); syncRtmpWarmups(nextActiveSlides, index);
} }
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(nextActiveSlides, index);
}
if (nextActiveSlides.length < 2) { if (nextActiveSlides.length < 2) {
pendingPlaylistUpdate = { pendingPlaylistUpdate = {
slides: nextSlides, slides: nextSlides,
+58 -6
View File
@@ -84,7 +84,7 @@ function getCurrentRenderKey(activeSlides) {
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|'); return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
} }
// Pick the current slide and the next slide for webpage preloading. // Pick the next slide for webpage preloading.
function getWebpagePreloadSlides(sourceSlides, targetIndex) { function getWebpagePreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) { if (!availableSlides.length) {
@@ -97,19 +97,71 @@ function getWebpagePreloadSlides(sourceSlides, targetIndex) {
} }
const preloadSlides = []; const preloadSlides = [];
const currentSlide = availableSlides[normalizedIndex];
const nextSlide = availableSlides[normalizedIndex + 1]; const nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) { if (nextSlide) {
preloadSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
preloadSlides.push(nextSlide); preloadSlides.push(nextSlide);
} }
return preloadSlides; return preloadSlides;
} }
// Pick the next slide to warm its markup before it becomes visible.
function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const preloadSlides = [];
const nextSlide = availableSlides[normalizedIndex + 1];
if (nextSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
if (window.__pulseThumbnailPreview) {
return;
}
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
return;
}
const slide = preloadSlides[0];
const preloadSignature = [
currentPlaylistSignature || '',
slide && slide.id ? slide.id : '',
slide && slide.template_id ? slide.template_id : '',
slide && slide.modified_at ? slide.modified_at : '',
window.innerWidth + 'x' + window.innerHeight,
videoRegionRenderVersion || 0
].join('|');
if (scheduleSlideMarkupPreload.signature === preloadSignature) {
return;
}
scheduleSlideMarkupPreload.signature = preloadSignature;
window.setTimeout(function () {
if (scheduleSlideMarkupPreload.signature !== preloadSignature) {
return;
}
primeSlideMarkup(slide);
}, 0);
}
// Mount hidden iframe preloads for the chosen webpage URLs. // Mount hidden iframe preloads for the chosen webpage URLs.
function syncWebpagePreloads(sourceSlides, targetIndex) { function syncWebpagePreloads(sourceSlides, targetIndex) {
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex)); const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
+47 -1
View File
@@ -893,6 +893,45 @@ function getSlideMarkupCacheKey(slide) {
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|'); return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
} }
function restorePlayerCanvasDimensions(width, height) {
if (!document || !document.documentElement) {
return;
}
var style = document.documentElement.style;
if (width) {
style.setProperty('--player-canvas-width', width);
} else {
style.removeProperty('--player-canvas-width');
}
if (height) {
style.setProperty('--player-canvas-height', height);
} else {
style.removeProperty('--player-canvas-height');
}
}
function primeSlideMarkup(slide) {
if (!slide) {
return '';
}
var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) {
return cachedMarkup;
}
var previousWidth = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-width') || '') : '';
var previousHeight = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-height') || '') : '';
try {
return buildSlideMarkupForState(slide, false);
} finally {
restorePlayerCanvasDimensions(previousWidth.trim(), previousHeight.trim());
}
}
function notifyVideoRegionSourceReady() { function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') { if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1; videoRegionRenderVersion += 1;
@@ -919,9 +958,12 @@ function setCachedSlideMarkup(slide, markup) {
// Slide rendering and markup cache helpers. // Slide rendering and markup cache helpers.
// Choose the right slide renderer and cache the result. // Choose the right slide renderer and cache the result.
function buildSlideMarkup(slide) { function buildSlideMarkupForState(slide, updateCurrentState) {
if (updateCurrentState) {
lastRenderedSlide = slide || null; lastRenderedSlide = slide || null;
syncBlackoutState(); syncBlackoutState();
}
var cachedMarkup = getCachedSlideMarkup(slide); var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) { if (cachedMarkup) {
return cachedMarkup; return cachedMarkup;
@@ -938,3 +980,7 @@ function buildSlideMarkup(slide) {
setCachedSlideMarkup(slide, markup); setCachedSlideMarkup(slide, markup);
return markup; return markup;
} }
function buildSlideMarkup(slide) {
return buildSlideMarkupForState(slide, true);
}
+5 -10
View File
@@ -304,13 +304,9 @@ function getRtmpWarmupSlides(sourceSlides, targetIndex) {
} }
var warmupSlides = []; var warmupSlides = [];
var currentSlide = availableSlides[normalizedIndex];
var nextSlide = availableSlides[normalizedIndex + 1]; var nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) { if (nextSlide) {
warmupSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
warmupSlides.push(nextSlide); warmupSlides.push(nextSlide);
} }
@@ -693,12 +689,11 @@ function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, plac
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({ var hls = new window.Hls({
enableWorker: true, enableWorker: true,
lowLatencyMode: true, liveSyncDurationCount: 6,
liveSyncDurationCount: 4, liveMaxLatencyDurationCount: 12,
liveMaxLatencyDurationCount: 8, maxBufferLength: 30,
maxBufferLength: 20,
maxLiveSyncPlaybackRate: 1, maxLiveSyncPlaybackRate: 1,
backBufferLength: 30 backBufferLength: 60
}); });
video.__rtmpHls = hls; video.__rtmpHls = hls;
hls.attachMedia(video); hls.attachMedia(video);
+3 -3
View File
@@ -122,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
setVideoSourceAvailability(requestedSrc, true); setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
var requestedState = getVideoSourceAvailability(requestedSrc); var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -132,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) { if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
scheduleVideoSourceProbe(regionKey, requestedSrc, false); scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -141,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) { if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc); logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
} }
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>'; return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
} }
return ''; return '';
+179 -7
View File
@@ -3,7 +3,7 @@
const fs = require('fs'); const fs = require('fs');
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -12,6 +12,17 @@ function isTransientDbError(error) {
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1); return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
} }
function isBridgeFetchError(error) {
const message = String(error && error.message || '').toLowerCase();
return Boolean(error && (
message.indexOf('fetch failed') !== -1 ||
message.indexOf('network error') !== -1 ||
message.indexOf('econnreset') !== -1 ||
message.indexOf('econnrefused') !== -1 ||
message.indexOf('enotfound') !== -1
));
}
function registerPlayerRoutes(app, options) { function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
@@ -22,14 +33,68 @@ function registerPlayerRoutes(app, options) {
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : 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 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 playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1'; const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) { if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and 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.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
if (req.headers['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
}
if (req.headers['if-none-match']) {
headers['if-none-match'] = String(req.headers['if-none-match']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
function requirePageAuth(allowedScopes) { function requirePageAuth(allowedScopes) {
return function (req, res, next) { return function (req, res, next) {
if (!sharedSecret) { if (!sharedSecret) {
@@ -114,6 +179,26 @@ function registerPlayerRoutes(app, options) {
}); });
app.get('/api/media/config', requireRequestAuth, function (_req, res) { app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (thinClientBaseUrl) {
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
})
}).then(async function (response) {
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
}).catch(function (_error) {
res.status(502).json({ error: 'Thin client unavailable.' });
});
return;
}
res.json({ res.json({
mediaDir: mediaDir, mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads') uploadDir: path.join(mediaDir, 'uploads')
@@ -213,9 +298,35 @@ function registerPlayerRoutes(app, options) {
app.get('/screen/:slug', function (req, res) { app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); res.set('Pragma', 'no-cache');
if (thinClientBaseUrl) {
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(), {
method: 'GET',
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) {
if (!response || response.status >= 400) {
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, null));
}
const data = await readJsonResponse(response);
if (!data) {
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, null));
}
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
if (!isBridgeFetchError(error)) {
console.error(error);
}
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
return;
}
const { bindPlayerToScreen } = require('./onboarding'); const { bindPlayerToScreen } = require('./onboarding');
if (playerIdentifier) { if (playerDeviceId) {
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug) void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
.catch(function (error) { .catch(function (error) {
console.error(error); console.error(error);
}); });
@@ -231,6 +342,19 @@ function registerPlayerRoutes(app, options) {
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try { try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
method: 'GET'
});
if (!response) {
return res.status(502).send('Thin client unavailable');
}
res.status(response.status);
res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
return res.send(await response.text());
}
const slide = await common.fetchSlideById(pool, Number(req.params.id)); const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) { if (!slide) {
return res.status(404).send('Slide not found'); return res.status(404).send('Slide not found');
@@ -273,6 +397,29 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try { try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
return res.send(await response.text());
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
if (!data.screen) { if (!data.screen) {
@@ -293,6 +440,29 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try { try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
return res.send(await response.text());
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const announcement = typeof common.fetchActiveAnnouncement === 'function' const announcement = typeof common.fetchActiveAnnouncement === 'function'
? await common.fetchActiveAnnouncement(pool, req.params.slug) ? await common.fetchActiveAnnouncement(pool, req.params.slug)
@@ -335,6 +505,7 @@ function registerPlayerRoutes(app, options) {
const connections = playerRuntime.snapshotConnections(req.params.slug); const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null; let screen = null;
let screenLookupFailed = false; let screenLookupFailed = false;
if (pool && typeof pool.query === 'function') {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
@@ -344,6 +515,7 @@ function registerPlayerRoutes(app, options) {
throw error; throw error;
} }
} }
}
res.json({ res.json({
screen: screen, screen: screen,
screenSlug: req.params.slug, screenSlug: req.params.slug,
@@ -374,7 +546,7 @@ function registerPlayerRoutes(app, options) {
const isRedirectCommand = command === 'redirect'; const isRedirectCommand = command === 'redirect';
let screen = null; let screen = null;
let screenLookupFailed = false; let screenLookupFailed = false;
if (!isRedirectCommand) { if (!isRedirectCommand && pool && typeof pool.query === 'function') {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
+5 -1
View File
@@ -62,7 +62,7 @@ async function start() {
const playerActionService = createPlayerActionService({ const playerActionService = createPlayerActionService({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: webConfig.playerInternalBaseUrl playerInternalBaseUrl: webConfig.thinClientBaseUrl
}); });
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
@@ -71,6 +71,7 @@ async function start() {
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: webConfig.playerInternalBaseUrl, playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
thinClientBaseUrl: webConfig.thinClientBaseUrl,
uploadDir: webConfig.uploadsDir, uploadDir: webConfig.uploadsDir,
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
@@ -155,6 +156,8 @@ async function start() {
}, },
hasAnyPermission: hasAnyPermission, hasAnyPermission: hasAnyPermission,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
mediaDir: webConfig.mediaDir,
uploadSyncService: webBootstrap.uploadSyncService,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
@@ -207,6 +210,7 @@ async function start() {
initializeBackgroundTasks: initializeBackgroundTasks, initializeBackgroundTasks: initializeBackgroundTasks,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
server: server, server: server,
webBaseUrl: webConfig.webBaseUrl,
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
}); });
+12 -56
View File
@@ -9,6 +9,7 @@ function createWebBootstrap(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim(); const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = 5000; const dashboardRefreshIntervalMs = 5000;
const formatDashboardDate = options && options.formatDashboardDate; const formatDashboardDate = options && options.formatDashboardDate;
@@ -25,49 +26,8 @@ function createWebBootstrap(options) {
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map(); const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
let dashboardRefreshInFlight = null; let dashboardRefreshInFlight = null;
let broadcastDashboardState = null; let broadcastDashboardState = null;
let playerInternalBaseUrl = null; function getPlayerSnapshotSocketUrl(slug) {
let playerInternalBaseUrlPromise = null; const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
try {
const [rows] = await pool.query(
`SELECT internal_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1`
);
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
} catch (_error) {
return configuredPlayerInternalBaseUrl || null;
}
})().then(function (baseUrl) {
playerInternalBaseUrl = baseUrl || null;
playerInternalBaseUrlPromise = null;
return playerInternalBaseUrl;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
});
return playerInternalBaseUrlPromise;
}
async function getPlayerSnapshotSocketUrl(slug) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) { if (!resolvedPlayerInternalBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.'); throw new Error('Unable to resolve the player internal base URL.');
} }
@@ -78,16 +38,6 @@ function createWebBootstrap(options) {
return url.toString(); return url.toString();
} }
function storePlayerSnapshot(slug, connections) {
const normalizedSlug = String(slug || '').trim();
const normalizedConnections = Array.isArray(connections) ? connections : [];
playerSnapshotCache.set(normalizedSlug, {
slug: normalizedSlug,
count: normalizedConnections.length,
connections: normalizedConnections
});
}
function clearPlayerSnapshotSocket(slug) { function clearPlayerSnapshotSocket(slug) {
const key = String(slug || '').trim(); const key = String(slug || '').trim();
playerSnapshotSockets.delete(key); playerSnapshotSockets.delete(key);
@@ -104,12 +54,12 @@ function createWebBootstrap(options) {
} }
playerSnapshotSockets.set(key, null); playerSnapshotSockets.set(key, null);
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'GET', method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(key)}/events` pathname: `/ws/screens/${encodeURIComponent(key)}/events`
}); });
socketUrlPromise.then(function (socketUrl) {
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
const socket = new WebSocket(socketUrl, { const socket = new WebSocket(socketUrl, {
headers: authHeaders headers: authHeaders
}); });
@@ -121,7 +71,11 @@ function createWebBootstrap(options) {
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return; return;
} }
storePlayerSnapshot(key, payload.connections || []); playerSnapshotCache.set(key, {
slug: key,
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
if (broadcastDashboardState) { if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) { broadcastDashboardState().catch(function (error) {
console.error(error); console.error(error);
@@ -155,6 +109,7 @@ function createWebBootstrap(options) {
const dashboardStateService = createDashboardStateService({ const dashboardStateService = createDashboardStateService({
pool: pool, pool: pool,
common: common, common: common,
thinClientBaseUrl: configuredThinClientBaseUrl,
playerSnapshotCache: playerSnapshotCache, playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets, playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
@@ -298,6 +253,7 @@ function createWebBootstrap(options) {
return { return {
upload: upload, upload: upload,
uploadSyncService: uploadSyncService, uploadSyncService: uploadSyncService,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
buildDashboardState: buildDashboardState, buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
@@ -1,16 +1,18 @@
const { resolvePlayerRegistration } = require('#src/data/player-registry');
const TASK = { const TASK = {
taskType: 'slide-thumbnail-refresh' taskType: 'slide-thumbnail-refresh'
}; };
async function fetchPlayerInternalBaseUrl(pool) { async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
const [rows] = await pool.query( const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
`SELECT internal_base_url if (configured) {
FROM d_players return configured;
WHERE device_id = '1' }
LIMIT 1`
);
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier());
return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null;
} }
function registerSlideThumbnailRefreshTask(options) { function registerSlideThumbnailRefreshTask(options) {
@@ -19,6 +21,7 @@ function registerSlideThumbnailRefreshTask(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.'); throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
@@ -32,18 +35,19 @@ function registerSlideThumbnailRefreshTask(options) {
throw new Error('Slide id is required.'); throw new Error('Slide id is required.');
} }
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
if (!playerInternalBaseUrl) { if (!webBaseUrl) {
throw new Error('Player internal base URL is required.'); throw new Error('Web base URL is required.');
} }
return captureSlideThumbnail({ return captureSlideThumbnail({
pool: pool, pool: pool,
common: common, common: common,
mediaDir: mediaDir, mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl, baseUrl: webBaseUrl,
slideId: slideId, slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null previousThumbnailPath: payload.previousThumbnailPath || null,
fontStylesheetHref: payload.fontStylesheetHref || ''
}); });
}); });
} }
@@ -1,16 +1,18 @@
const { resolvePlayerRegistration } = require('#src/data/player-registry');
const TASK = { const TASK = {
taskType: 'template-slide-thumbnail-refresh' taskType: 'template-slide-thumbnail-refresh'
}; };
async function fetchPlayerInternalBaseUrl(pool) { async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
const [rows] = await pool.query( const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
`SELECT internal_base_url if (configured) {
FROM d_players return configured;
WHERE device_id = '1' }
LIMIT 1`
);
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier());
return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null;
} }
function registerTemplateSlideThumbnailRefreshTask(options) { function registerTemplateSlideThumbnailRefreshTask(options) {
@@ -19,6 +21,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.'); throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
@@ -32,9 +35,9 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
throw new Error('Template id is required.'); throw new Error('Template id is required.');
} }
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
if (!playerInternalBaseUrl) { if (!webBaseUrl) {
throw new Error('Player internal base URL is required.'); throw new Error('Web base URL is required.');
} }
const [slides] = await pool.query( const [slides] = await pool.query(
@@ -52,7 +55,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
pool: pool, pool: pool,
common: common, common: common,
mediaDir: mediaDir, mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl, baseUrl: webBaseUrl,
slideId: slideId, slideId: slideId,
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
}); });
@@ -18,20 +18,26 @@ function registerFontSweepTask(options) {
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
throw new Error('registerFontSweepTask requires the font sweep dependencies.'); throw new Error('registerFontSweepTask requires the font sweep dependencies.');
} }
const metadataPromise = typeof getPlayerTaskMetadata === 'function'
? Promise.resolve(getPlayerTaskMetadata())
: Promise.resolve({});
return metadataPromise.then(function (metadata) {
backgroundTaskQueue.registerRecurringTask({ backgroundTaskQueue.registerRecurringTask({
key: TASK.key, key: TASK.key,
title: TASK.title, title: TASK.title,
category: TASK.category, category: TASK.category,
intervalMs: TASK.intervalMs, intervalMs: TASK.intervalMs,
metadata: { metadata: Object.assign({
mediaDir: mediaDir mediaDir: mediaDir
}, }, metadata || {}),
run: async function () { run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir); const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
@@ -63,6 +69,7 @@ function registerFontSweepTask(options) {
} }
} }
}); });
});
} }
module.exports = { registerFontSweepTask }; module.exports = { registerFontSweepTask };
@@ -8,22 +8,30 @@ const TASK = {
function registerInitialFontSyncTask(options) { function registerInitialFontSyncTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
} }
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return backgroundTaskQueue.enqueueTask({ return backgroundTaskQueue.enqueueTask({
key: TASK.key, key: TASK.key,
title: 'Initial font sync', title: 'Initial font sync',
category: TASK.category, category: TASK.category,
taskType: 'font-sync', taskType: 'font-sync',
metadata: Object.assign({}, metadata || {}),
payload: { payload: {
mode: 'initial', mode: 'initial',
uploadDir: mediaDir, uploadDir: mediaDir,
operations: collectFontLibrarySyncOperations(mediaDir) operations: collectFontLibrarySyncOperations(mediaDir)
}, },
persist: true persist: true
});
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial font sync:', error); console.warn('Unable to queue initial font sync:', error);
}); });
@@ -6,21 +6,29 @@ const TASK = {
function registerInitialMediaSyncTask(options) { function registerInitialMediaSyncTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
} }
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return backgroundTaskQueue.enqueueTask({ return backgroundTaskQueue.enqueueTask({
key: TASK.key, key: TASK.key,
title: 'Initial media sync', title: 'Initial media sync',
category: TASK.category, category: TASK.category,
taskType: 'media-sync', taskType: 'media-sync',
metadata: Object.assign({}, metadata || {}),
payload: { payload: {
mode: 'initial', mode: 'initial',
uploadDir: mediaDir uploadDir: mediaDir
}, },
persist: true persist: true
});
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial media sync:', error); console.warn('Unable to queue initial media sync:', error);
}); });
+4
View File
@@ -6,6 +6,8 @@ function createWebConfig() {
const thumbnailsDir = path.join(mediaDir, 'thumbnails'); const thumbnailsDir = path.join(mediaDir, 'thumbnails');
const assetDir = path.join(__dirname, '..', 'public'); const assetDir = path.join(__dirname, '..', 'public');
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); 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 sessionCookieName = 'digital_signage_session'; const sessionCookieName = 'digital_signage_session';
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
@@ -19,6 +21,8 @@ function createWebConfig() {
thumbnailsDir: thumbnailsDir, thumbnailsDir: thumbnailsDir,
assetDir: assetDir, assetDir: assetDir,
playerInternalBaseUrl: playerInternalBaseUrl, playerInternalBaseUrl: playerInternalBaseUrl,
thinClientBaseUrl: thinClientBaseUrl,
webBaseUrl: webBaseUrl,
sessionCookieName: sessionCookieName, sessionCookieName: sessionCookieName,
sessionMaxAgeMs: sessionMaxAgeMs, sessionMaxAgeMs: sessionMaxAgeMs,
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
+68 -3
View File
@@ -6,6 +6,10 @@ function normalizeClientName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function normalizePlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) { function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
return (screens || []).map(function (screen) { return (screens || []).map(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
@@ -18,11 +22,12 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
}); });
} }
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) { function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate) {
return (screens || []).flatMap(function (screen) { return (screens || []).flatMap(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return (connectionState.connections || []).map(function (connection) { return (connectionState.connections || []).map(function (connection) {
const deviceId = String(connection.deviceId || '').trim(); const deviceId = String(connection.deviceId || '').trim();
const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl);
return Object.assign({}, connection, { return Object.assign({}, connection, {
screen_slug: screen.slug, screen_slug: screen.slug,
screen_name: screen.name, screen_name: screen.name,
@@ -30,12 +35,55 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
playlist_name: screen.playlist_name || null, playlist_name: screen.playlist_name || null,
connectedAtLabel: formatDashboardDate(connection.connectedAt), connectedAtLabel: formatDashboardDate(connection.connectedAt),
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
player_url: screen.player_url || String(connection.page || '').trim() || null player_identifier: playerIdentifierByBaseUrl && playerBaseUrl ? (playerIdentifierByBaseUrl[playerBaseUrl] || null) : null,
player_url: playerBaseUrl || null
}); });
}); });
}); });
} }
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 buildKioskLauncherPlayers(playerRegistrations, staleSeconds) {
return (Array.isArray(playerRegistrations) ? playerRegistrations : [])
.filter(function (player) {
return Boolean(player && String(player.identifier || '').trim() && String(player.public_base_url || '').trim() && isRecentPlayerRegistration(player, staleSeconds));
})
.map(function (player) {
return {
player_identifier: String(player.identifier || '').trim(),
player_url: String(player.public_base_url || '').trim().replace(/\/$/, '')
};
});
}
async function fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds) {
const staleSeconds = Math.max(30, Number(connectedPlayerCountStaleSeconds || 60));
if (!pool || typeof pool.query !== 'function' || !Number.isFinite(staleSeconds)) {
return null;
}
try {
const [rows] = await pool.query(
`SELECT COUNT(*) AS connected_count
FROM d_players
WHERE last_seen_at IS NOT NULL
AND last_seen_at >= DATE_SUB(NOW(), INTERVAL ${staleSeconds} SECOND)`
);
const count = Number(rows && rows[0] && rows[0].connected_count);
return Number.isFinite(count) && count >= 0 ? count : null;
} catch (_error) {
return null;
}
}
function compareScreenNames(left, right) { function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim(); const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim(); const rightName = String(right && right.name || '').trim();
@@ -51,6 +99,7 @@ function compareScreenNames(left, right) {
function createDashboardStateService(options) { function createDashboardStateService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const connectedPlayerCountStaleSeconds = options && options.connectedPlayerCountStaleSeconds;
const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotCache = options && options.playerSnapshotCache;
const playerSnapshotSockets = options && options.playerSnapshotSockets; const playerSnapshotSockets = options && options.playerSnapshotSockets;
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
@@ -63,9 +112,13 @@ function createDashboardStateService(options) {
async function buildDashboardState() { async function buildDashboardState() {
const data = await common.fetchAdminData(pool); const data = await common.fetchAdminData(pool);
const screensData = data.screens || []; const screensData = data.screens || [];
const connectedPlayersCount = await fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds);
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function' const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool) ? await common.fetchScreenPlayerUrls(pool)
: {}; : {};
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
? await common.fetchPlayerRegistrations(pool)
: [];
screensData.forEach(function (screen) { screensData.forEach(function (screen) {
try { try {
ensurePlayerSnapshotSubscription(screen.slug); ensurePlayerSnapshotSubscription(screen.slug);
@@ -95,6 +148,15 @@ function createDashboardStateService(options) {
} }
}); });
const playerIdentifierByBaseUrl = {};
(Array.isArray(playerRegistrations) ? playerRegistrations : []).forEach(function (player) {
const baseUrl = normalizePlayerBaseUrl(player && player.public_base_url);
const identifier = normalizeClientName(player && player.identifier);
if (baseUrl && identifier) {
playerIdentifierByBaseUrl[baseUrl] = identifier;
}
});
const connectionsBySlug = {}; const connectionsBySlug = {};
screensData.forEach(function (screen) { screensData.forEach(function (screen) {
const cached = playerSnapshotCache.get(String(screen.slug || '').trim()); const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
@@ -110,7 +172,8 @@ function createDashboardStateService(options) {
}); });
}) })
.sort(compareScreenNames); .sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate); const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate);
const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN; return socket && socket.readyState === WebSocket.OPEN;
}); });
@@ -119,8 +182,10 @@ function createDashboardStateService(options) {
playlists: data.playlists || [], playlists: data.playlists || [],
screens: screens, screens: screens,
clients: clients, clients: clients,
kioskPlayers: kioskPlayers,
slides: data.slides || [], slides: data.slides || [],
playerServiceConnected: playerServiceConnected, playerServiceConnected: playerServiceConnected,
connectedPlayersCount: connectedPlayersCount !== null ? connectedPlayersCount : 0,
connectedClientsCount: screens.reduce(function (total, screen) { connectedClientsCount: screens.reduce(function (total, screen) {
return total + Number(screen.player_connection_count || 0); return total + Number(screen.player_connection_count || 0);
}, 0) }, 0)
+3 -1
View File
@@ -2,6 +2,8 @@
module.exports = { module.exports = {
createUploadSyncService: require('./upload-sync').createUploadSyncService, createUploadSyncService: require('./upload-sync').createUploadSyncService,
captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail, captureSlideThumbnail: function captureSlideThumbnail(options) {
return require('./slide-thumbnails').captureSlideThumbnail(options);
},
fontLibrary: require('./font-library') fontLibrary: require('./font-library')
}; };
@@ -0,0 +1,156 @@
// Pure thumbnail preview payload helpers shared by routes and screenshot capture.
const {
escapeHtml,
renderEditorJsContent,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, '');
}
function resolveAssetUrl(baseUrl, value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
return raw;
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return raw;
}
if (raw.startsWith('/')) {
return normalizedBaseUrl + raw;
}
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
}
function getThumbnailCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null;
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function getRegionContent(slide, region) {
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
return content && typeof content === 'object' ? content : { value: content };
}
function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0;
const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0;
const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0;
const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0;
return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';';
}
function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
}
function buildTextRegionMarkup(region, regionContent) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
const renderedBody = renderEditorJsContent(regionContent.value || '');
if (!hasVisibleContent(renderedBody)) {
return '';
}
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
}
function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-image-region" style="' + region.baseStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" /></div>'
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-video-region" style="' + region.baseStyle + '"><video class="slide-preview-video" src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video></div>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>'
: '';
}
if (regionType === 'qr-code') {
const src = String(regionContent.qr_preview || '').trim();
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
return src
? '<div class="slide-preview-region slide-preview-qr-code-region" style="' + region.baseStyle + radiusStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" /></div>'
: '';
}
if (regionType === 'html') {
const html = String(rawValue || '').trim();
return html
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>'
: '';
}
if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source';
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
}
return buildTextRegionMarkup(region, regionContent);
}
function buildThumbnailPreviewMarkup(slide, baseUrl) {
const template = slide && slide.template ? slide.template : null;
if (!template) {
return '';
}
const canvasSize = getThumbnailCanvasSize(slide);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
});
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl);
}).join('');
}
function buildThumbnailPreviewPayload(slide, options) {
const template = slide && slide.template ? slide.template : null;
const canvasSize = getThumbnailCanvasSize(slide);
return {
thumbnailPreview: true,
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
};
}
module.exports = {
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
};
+91 -23
View File
@@ -2,14 +2,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const sharp = require('sharp');
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
const { const {
escapeHtml, escapeHtml,
mediaKind, mediaKind,
@@ -73,6 +65,23 @@ function getRegionContent(slide, region) {
return content && typeof content === 'object' ? content : { value: content }; return content && typeof content === 'object' ? content : { value: content };
} }
function getThumbnailCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null;
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0;
const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0;
const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0;
const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0;
return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';';
}
function hasVisibleContent(html) { function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
} }
@@ -86,7 +95,7 @@ function buildTextRegionMarkup(region, regionContent) {
return ''; return '';
} }
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>'; return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
} }
function buildRegionInnerHtml(region, regionContent, baseUrl) { function buildRegionInnerHtml(region, regionContent, baseUrl) {
@@ -96,21 +105,21 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'image') { if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
return src return src
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />' ? '<div class="slide-preview-region slide-preview-image-region" style="' + region.baseStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" /></div>'
: ''; : '';
} }
if (regionType === 'video') { if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
return src return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>' ? '<div class="slide-preview-region slide-preview-video-region" style="' + region.baseStyle + '"><video class="slide-preview-video" src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video></div>'
: ''; : '';
} }
if (regionType === 'webpage') { if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
return src return src
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' ? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>'
: ''; : '';
} }
@@ -119,26 +128,75 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0))); const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : ''; const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
return src return src
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '"' + radiusStyle + ' />' ? '<div class="slide-preview-region slide-preview-qr-code-region" style="' + region.baseStyle + radiusStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" /></div>'
: ''; : '';
} }
if (regionType === 'html') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html return html
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>' ? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>'
: ''; : '';
} }
if (regionType === 'rtmp') { if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source'; const label = String(rawValue || '').trim() || 'RTMP source';
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>'; return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
} }
return buildTextRegionMarkup(region, regionContent); return buildTextRegionMarkup(region, regionContent);
} }
async function loadChromium() {
const chromiumModule = await import('@sparticuz/chromium');
const resolved = chromiumModule && chromiumModule.default ? chromiumModule.default : chromiumModule;
return resolved;
}
async function loadPuppeteer() {
return require('puppeteer-core');
}
function loadSharp() {
return require('sharp');
}
function buildThumbnailPreviewMarkup(slide, baseUrl) {
const template = slide && slide.template ? slide.template : null;
if (!template) {
return '';
}
const canvasSize = getThumbnailCanvasSize(slide);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
});
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl);
}).join('');
}
function buildThumbnailPreviewPayload(slide, options) {
const template = slide && slide.template ? slide.template : null;
const canvasSize = getThumbnailCanvasSize(slide);
return {
thumbnailPreview: true,
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
};
}
async function launchBrowser() { async function launchBrowser() {
const puppeteer = await loadPuppeteer();
const chromium = await loadChromium();
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate); return fs.existsSync(candidate);
}) || ''; }) || '';
@@ -178,6 +236,7 @@ async function launchBrowser() {
} }
async function captureSlideThumbnail(options) { async function captureSlideThumbnail(options) {
const sharp = loadSharp();
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
@@ -194,7 +253,6 @@ async function captureSlideThumbnail(options) {
throw new Error('Slide not found.'); throw new Error('Slide not found.');
} }
const canvasSize = getCanvasSize(slide);
const thumbnailDir = path.join(mediaDir, 'thumbnails'); const thumbnailDir = path.join(mediaDir, 'thumbnails');
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
@@ -206,11 +264,11 @@ async function captureSlideThumbnail(options) {
async function waitForThumbnailRender(page) { async function waitForThumbnailRender(page) {
await page.waitForFunction(function () { await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas')); return document.readyState === 'complete' && Boolean(document.querySelector('#popup-preview-canvas'));
}, { timeout: 30000 }); }, { timeout: 30000 });
await page.waitForFunction(function () { await page.waitForFunction(function () {
var canvas = document.querySelector('.slide-canvas'); var canvas = document.querySelector('#popup-preview-canvas');
if (!canvas) { if (!canvas) {
return false; return false;
} }
@@ -244,18 +302,26 @@ async function captureSlideThumbnail(options) {
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
try { try {
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview'; const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview';
const previewUrl = baseUrl + previewPath; const previewUrl = baseUrl + previewPath;
const previewPayload = buildThumbnailPreviewPayload(slide, {
baseUrl: baseUrl,
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : ''
});
await page.setViewport({
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
deviceScaleFactor: 1
});
await page.setExtraHTTPHeaders(createRequestAuthHeaders({ await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET', method: 'GET',
pathname: previewPath pathname: previewPath
})); }));
await page.setViewport(PLAYER_VIEWPORT);
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await waitForThumbnailRender(page); await waitForThumbnailRender(page);
const canvas = await page.$('.slide-canvas'); const canvas = await page.$('#popup-preview-canvas');
if (!canvas) { if (!canvas) {
throw new Error('Player render did not produce a slide canvas.'); throw new Error('Popup preview did not produce a slide canvas.');
} }
await canvas.screenshot({ path: fullSizePath }); await canvas.screenshot({ path: fullSizePath });
} finally { } finally {
@@ -298,5 +364,7 @@ async function captureSlideThumbnail(options) {
} }
module.exports = { module.exports = {
captureSlideThumbnail: captureSlideThumbnail captureSlideThumbnail: captureSlideThumbnail,
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
}; };
+1
View File
@@ -25,6 +25,7 @@ async function initializeWebServer(options) {
uploadSyncService: webBootstrap.uploadSyncService, uploadSyncService: webBootstrap.uploadSyncService,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
mediaDir: mediaDir, mediaDir: mediaDir,
webBaseUrl: options && options.webBaseUrl ? options.webBaseUrl : null,
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
}); });
+1 -1
View File
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') {
return next(); return next();
} }
+10 -1
View File
@@ -1,4 +1,13 @@
(function () { (function () {
function buildLivePlayerFeedLabel(connectedPlayersCount) {
var count = Number(connectedPlayersCount || 0);
if (count <= 0) {
return 'Player feed online - no players connected';
}
return 'Live player feed - ' + count + ' player' + (count === 1 ? '' : 's') + ' connected';
}
function updateSidebarStatus(status, label) { function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot'); var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text'); var text = document.getElementById('sidebar-status-text');
@@ -72,7 +81,7 @@
if (!screenCount) { if (!screenCount) {
updateSidebarStatus('unknown', 'No screens configured'); updateSidebarStatus('unknown', 'No screens configured');
} else { } else {
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), buildLivePlayerFeedLabel(payload.state && payload.state.connectedPlayersCount));
} }
} }
} catch (_error) { } catch (_error) {
+3 -8
View File
@@ -111,7 +111,7 @@ module.exports = function registerManageRoutes(app, deps) {
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name)); const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]); const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, { redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens', closeUrl: '/screens',
newUrl: '/screens/new', newUrl: '/screens/new',
@@ -144,17 +144,12 @@ module.exports = function registerManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id; const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim(); const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]); await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) { if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh'); await notifyPlayerScreens([previousSlug], 'refresh');
} }
if (previousSlug && previousSlug !== slug) { if (previousSlug && previousSlug !== slug) {
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function' const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchScreenPlayerRecord(pool, previousSlug)
: null;
const playerBaseUrl = playerRecord && playerRecord.public_base_url
? String(playerRecord.public_base_url).replace(/\/$/, '')
: typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchPlayerPublicBaseUrl(pool) ? await common.fetchPlayerPublicBaseUrl(pool)
: ''; : '';
await forwardPlayerCommand(previousSlug, { await forwardPlayerCommand(previousSlug, {
+33
View File
@@ -0,0 +1,33 @@
// Internal sync trigger routes for player-driven queue flushes.
const { verifyRequestAuth } = require('#src/request-auth');
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
module.exports = function registerInternalSyncRoutes(app, deps) {
const uploadSyncService = deps && deps.uploadSyncService;
const mediaDir = String(deps && deps.mediaDir || '').trim();
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) {
throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
}
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try {
await uploadSyncService.runMediaSyncTask({
mode: 'initial',
uploadDir: mediaDir
});
await uploadSyncService.flushPendingPlayerUploadSyncs();
res.json({ ok: true });
} catch (error) {
next(error);
}
});
};
@@ -143,6 +143,30 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
return buildQueryString(nextQuery); return buildQueryString(nextQuery);
} }
function buildTaskPlayerLabel(metadata) {
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
if (playerLabel) {
return playerLabel;
}
if (playerIdentifier && playerPublicBaseUrl) {
return `${playerIdentifier} (${playerPublicBaseUrl})`;
}
if (playerIdentifier) {
return playerIdentifier;
}
if (playerPublicBaseUrl) {
return playerPublicBaseUrl;
}
return '';
}
function buildQueuePageViewModel(data, message, currentUser) { function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || []; const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -171,7 +195,10 @@ function buildQueuePageViewModel(data, message, currentUser) {
'finishedAt', 'finishedAt',
function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; } function (item) { return item && item.metadata && item.metadata.sourceId; },
function (item) { return item && item.metadata && item.metadata.playerLabel; },
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
])(task); ])(task);
}); });
const sortedTasks = sortRows(filteredTasks, function (task) { const sortedTasks = sortRows(filteredTasks, function (task) {
@@ -194,7 +221,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
return task && task.finishedAt; return task && task.finishedAt;
} }
if (sort === 'source') { if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
return String(value || '').trim(); return String(value || '').trim();
}).join(' '); }).join(' ');
} }
@@ -234,11 +261,16 @@ function buildQueuePageViewModel(data, message, currentUser) {
}); });
const visibleTasksWithSources = visibleTasks.map(function (task) { const visibleTasksWithSources = visibleTasks.map(function (task) {
const playerLabel = buildTaskPlayerLabel(task.metadata);
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':');
return Object.assign({}, task, { return Object.assign({}, task, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '', sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '', sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '' sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '',
playerLabel: playerLabel,
taskDescription: taskDescription
}); });
}); });
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId); const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
@@ -287,7 +319,10 @@ function buildScheduledPageViewModel(data, message, currentUser) {
'lastError', 'lastError',
function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; } function (item) { return item && item.metadata && item.metadata.sourceId; },
function (item) { return item && item.metadata && item.metadata.playerLabel; },
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
])(task); ])(task);
}); });
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) { const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
@@ -309,7 +344,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
}).join(' '); }).join(' ');
} }
if (sort === 'source') { if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
return String(value || '').trim(); return String(value || '').trim();
}).join(' '); }).join(' ');
} }
@@ -325,7 +360,8 @@ function buildScheduledPageViewModel(data, message, currentUser) {
backgroundTasksMenuOpen: true, backgroundTasksMenuOpen: true,
recurringTasks: sortedRecurringTasks.map(function (task) { recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, { return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs) intervalLabel: formatIntervalLabel(task.intervalMs),
playerLabel: buildTaskPlayerLabel(task.metadata)
}); });
}), }),
summary: summary, summary: summary,
@@ -21,8 +21,10 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
playlists: data.playlists || [], playlists: data.playlists || [],
screens: data.screens || [], screens: data.screens || [],
clients: data.clients || [], clients: data.clients || [],
kioskPlayers: data.kioskPlayers || [],
slides: data.slides || [], slides: data.slides || [],
connectedClientsCount: Number(data.connectedClientsCount || 0), connectedClientsCount: Number(data.connectedClientsCount || 0),
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null, primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
scripts: ['js/dashboard/dashboard-page.js'] scripts: ['js/dashboard/dashboard-page.js']
}); });
@@ -4,7 +4,8 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
const viewScreen = Object.assign({ const viewScreen = Object.assign({
name: '', name: '',
slug: '', slug: '',
playlist_id: null playlist_id: null,
player_urls: []
}, screen || {}); }, screen || {});
return { return {
+43
View File
@@ -1,14 +1,32 @@
// Slide route registration and pagination wiring. // Slide route registration and pagination wiring.
const { renderFragment } = require('../../../view'); const { renderFragment } = require('../../../view');
const path = require('path');
const { verifyRequestAuth } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview');
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
}
module.exports = function registerSlidesRoutes(app, deps) { module.exports = function registerSlidesRoutes(app, deps) {
const pages = deps.pages; const pages = deps.pages;
const common = deps.common; const common = deps.common;
const pool = deps.pool; const pool = deps.pool;
const mediaDir = String(deps.mediaDir || path.join(__dirname, '..', '..', '..', '..', 'media')).trim();
const webBaseUrl = String(deps.webBaseUrl || '').trim().replace(/\/$/, '');
const { buildPagination } = require('../../../lib/pagination'); const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
@@ -35,5 +53,30 @@ module.exports = function registerSlidesRoutes(app, deps) {
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body' frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
})); }));
}); });
app.get('/api/internal/slide-thumbnails/:id/popup-preview', async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const payload = buildThumbnailPreviewPayload(slide, {
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
fontStylesheetHref: getFontStylesheetHref(mediaDir)
});
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(renderFragment('slides/popup-preview', {
title: 'Slide preview',
framePopupCard: true,
hideFrameHeader: true,
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body',
popupPreviewPayloadJson: safeJsonForScript(payload)
}));
} catch (error) {
next(error);
}
});
}; };
@@ -65,8 +65,8 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Task"> <td data-label="Task">
<div class="fw-semibold">{{title}}</div> <div class="fw-semibold">{{title}}</div>
{{#if key}} {{#if taskDescription}}
<div class="text-muted small text-break">{{key}}</div> <div class="text-muted small text-break">{{taskDescription}}</div>
{{/if}} {{/if}}
{{#if errorMessage}} {{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div> <div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
@@ -34,6 +34,9 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Task"> <td data-label="Task">
<div class="fw-semibold">{{title}}</div> <div class="fw-semibold">{{title}}</div>
{{#if playerLabel}}
<div class="text-muted small text-break">Player: {{playerLabel}}</div>
{{/if}}
<div class="text-muted small text-break">{{key}}</div> <div class="text-muted small text-break">{{key}}</div>
</td> </td>
<td data-label="Interval">{{intervalLabel}}</td> <td data-label="Interval">{{intervalLabel}}</td>
+3 -1
View File
@@ -9,7 +9,7 @@
<div class="card card-outline card-secondary mb-4 screen-command-card"> <div class="card card-outline card-secondary mb-4 screen-command-card">
<div class="card-header"> <div class="card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Screen-level controls</h3> <h3 class="card-title">Screen Group controls</h3>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -26,6 +26,7 @@
aria-label="Target screen group" aria-label="Target screen group"
data-screen-command-select data-screen-command-select
> >
<option value="" selected disabled>Select Screen Group</option>
{{#each screens}} {{#each screens}}
<option <option
value="{{slug}}" value="{{slug}}"
@@ -34,6 +35,7 @@
data-playlist-name="{{playlist_name}}" data-playlist-name="{{playlist_name}}"
>{{name}}</option> >{{name}}</option>
{{/each}} {{/each}}
<option value="__all__" data-screen-name="All screens" data-screen-target-all="true">All Screens</option>
</select> </select>
</div> </div>
</div> </div>
+18 -15
View File
@@ -34,8 +34,8 @@
{{/if}} {{/if}}
{{#if (hasPermission currentUser "clients.read")}} {{#if (hasPermission currentUser "clients.read")}}
<div class="dashboard-hero-stat"> <div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{connectedClientsCount}}</span> <span id="dashboard-player-count" class="dashboard-hero-stat-value">{{connectedPlayersCount}}</span>
<span class="dashboard-hero-stat-label">clients</span> <span class="dashboard-hero-stat-label">players</span>
</div> </div>
{{/if}} {{/if}}
</div> </div>
@@ -157,12 +157,25 @@
</div> </div>
<p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p> <p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p>
</div> </div>
<div class="d-grid gap-2">
<select class="form-select" id="dashboard-kiosk-launcher-player-select" data-kiosk-launcher-player-select>
<option value="">Select a player</option>
{{#each kioskPlayers}}
{{#if player_identifier}}
{{#if player_url}}
<option value="{{player_url}}" data-player-identifier="{{player_identifier}}">{{player_identifier}} - {{player_url}}</option>
{{/if}}
{{/if}}
{{/each}}
</select>
<p class="mb-0 text-muted small">The download will open the selected player's public URL when the kiosk starts.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2"> <div class="d-flex flex-column flex-sm-row gap-2">
<a class="btn btn-outline-primary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.bat" data-kiosk-launcher-download aria-disabled="true" tabindex="-1"> <a class="btn btn-outline-primary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" data-kiosk-launcher-download data-kiosk-launcher-download-base="/downloads/kiosk/pulse-signage-kiosk.bat" aria-disabled="true" tabindex="-1">
<i class="bi bi-windows me-2" aria-hidden="true"></i> <i class="bi bi-windows me-2" aria-hidden="true"></i>
<span>Download for Windows</span> <span>Download for Windows</span>
</a> </a>
<a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.sh" data-kiosk-launcher-download aria-disabled="true" tabindex="-1"> <a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" data-kiosk-launcher-download data-kiosk-launcher-download-base="/downloads/kiosk/pulse-signage-kiosk.sh" aria-disabled="true" tabindex="-1">
<i class="bi bi-terminal me-2" aria-hidden="true"></i> <i class="bi bi-terminal me-2" aria-hidden="true"></i>
<span>Download for Linux</span> <span>Download for Linux</span>
</a> </a>
@@ -178,8 +191,7 @@
<div class="card-header dashboard-screen-card-header"> <div class="card-header dashboard-screen-card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Screen group snapshot</h3> <h3 class="card-title">Screen group snapshot</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment, <p class="dashboard-card-subtitle">Playlist assignment and live connection state without the spreadsheet feel.</p>
and live connection count without the spreadsheet feel.</p>
</div> </div>
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a> <a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
</div> </div>
@@ -191,11 +203,6 @@
<div class="dashboard-screen-tile-top"> <div class="dashboard-screen-tile-top">
<div class="dashboard-screen-tile-text"> <div class="dashboard-screen-tile-text">
<h4 class="dashboard-screen-name">{{name}}</h4> <h4 class="dashboard-screen-name">{{name}}</h4>
{{#if player_url}}
<a class="dashboard-screen-link" href="{{player_url}}" target="_blank">{{player_url}}</a>
{{else}}
<span class="dashboard-screen-link empty">Not available</span>
{{/if}}
</div> </div>
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}"> <span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
{{#if player_connection_count}} {{#if player_connection_count}}
@@ -210,10 +217,6 @@
<dt>Playlist</dt> <dt>Playlist</dt>
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd> <dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
</div> </div>
<div>
<dt>Connections</dt>
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
</div>
</dl> </dl>
</article> </article>
{{/each}} {{/each}}
+1 -9
View File
@@ -23,7 +23,6 @@
<thead> <thead>
<tr> <tr>
<th data-table-sort-key="name">Name</th> <th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">Player URL</th>
<th data-table-sort-key="playlist">Playlist</th> <th data-table-sort-key="playlist">Playlist</th>
<th data-table-sort-key="player_connection_count">Connected</th> <th data-table-sort-key="player_connection_count">Connected</th>
<th>Actions</th> <th>Actions</th>
@@ -34,13 +33,6 @@
{{#each screens}} {{#each screens}}
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Name">{{name}}</td> <td data-label="Name">{{name}}</td>
<td data-label="Player URL">
{{#if player_url}}
<a href="{{player_url}}" target="_blank">{{player_url}}</a>
{{else}}
<span class="empty">Not available</span>
{{/if}}
</td>
<td data-label="Playlist">{{playlist_name}}</td> <td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients"> <td data-label="Connected clients">
{{#if (gt player_connection_count 0)}} {{#if (gt player_connection_count 0)}}
@@ -72,7 +64,7 @@
</tr> </tr>
{{/each}} {{/each}}
{{else}} {{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No screen groups yet.</td></tr> <tr data-table-search-empty-default><td colspan="4" class="empty">No screen groups yet.</td></tr>
{{/if}} {{/if}}
</tbody> </tbody>
</table> </table>
@@ -58,6 +58,12 @@
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div> <div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
</div> </div>
{{#if popupPreviewPayloadJson}}
<script>
window.__pulsePopupPreviewPayload = {{{popupPreviewPayloadJson}}};
</script>
{{/if}}
<script> <script>
(function () { (function () {
function startPreviewVideoPlayback(root) { function startPreviewVideoPlayback(root) {
@@ -127,6 +133,10 @@
var canvas = document.getElementById('popup-preview-canvas'); var canvas = document.getElementById('popup-preview-canvas');
function readPayload() { function readPayload() {
if (window.__pulsePopupPreviewPayload) {
return window.__pulsePopupPreviewPayload;
}
var hash = String(window.location.hash || '').replace(/^#/, ''); var hash = String(window.location.hash || '').replace(/^#/, '');
if (!hash) { if (!hash) {
return null; return null;
+10 -2
View File
@@ -18,7 +18,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
status: 'completed', status: 'completed',
createdAt: '2026-08-04T10:15:00.000Z', createdAt: '2026-08-04T10:15:00.000Z',
startedAt: '2026-08-04T10:16:00.000Z', startedAt: '2026-08-04T10:16:00.000Z',
finishedAt: '2026-08-04T10:17:00.000Z' finishedAt: '2026-08-04T10:17:00.000Z',
metadata: {
playerLabel: 'Player Alpha'
}
}, },
{ {
title: 'Older task', title: 'Older task',
@@ -53,7 +56,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
key: 'refresh', key: 'refresh',
intervalMs: 60000, intervalMs: 60000,
nextRunAt: '2026-08-04T10:15:00.000Z', nextRunAt: '2026-08-04T10:15:00.000Z',
lastRunAt: '2026-08-04T09:15:00.000Z' lastRunAt: '2026-08-04T09:15:00.000Z',
metadata: {
playerIdentifier: 'Player Beta'
}
} }
], ],
summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 } summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 }
@@ -66,7 +72,9 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/); assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
assert.match(queueHtml, /data-local-datetime-format="24h"/); assert.match(queueHtml, /data-local-datetime-format="24h"/);
assert.match(queueHtml, /Player Alpha:secret-key/);
assert.doesNotMatch(queueKeySearchHtml, /Example task/); assert.doesNotMatch(queueKeySearchHtml, /Example task/);
assert.match(queueDateSearchHtml, /Example task/); assert.match(queueDateSearchHtml, /Example task/);
assert.match(scheduledHtml, /data-local-datetime-format="24h"/); assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
assert.match(scheduledHtml, /Player:\s+Player Beta/);
}); });
+148
View File
@@ -0,0 +1,148 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
function createStyleStore() {
const values = Object.create(null);
return {
values,
getPropertyValue(name) {
return values[name] || '';
},
setProperty(name, value) {
values[name] = String(value || '');
},
removeProperty(name) {
delete values[name];
}
};
}
test('primeSlideMarkup restores the current canvas dimensions', () => {
const style = createStyleStore();
style.setProperty('--player-canvas-width', '1920px');
style.setProperty('--player-canvas-height', '1080px');
const sandbox = {
window: null,
document: {
documentElement: { style }
},
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
currentPlaylistSignature: 'signature',
currentPlaylistEtag: '',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
lastRenderedSlide: { id: 1 },
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
window: null,
innerWidth: 1280,
innerHeight: 720,
pulsePlayerRegionTypes: {
get() {
return null;
}
},
syncRenderCacheViewport() {},
syncBlackoutState() {},
setPlayerCanvasDimensions() {},
escapeHtml(value) {
return String(value || '');
}
};
sandbox.window = sandbox;
const renderingPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
loadScript(renderingPath, sandbox);
const slide = {
id: 2,
kind: 'image',
media_url: 'https://example.com/next.jpg',
modified_at: '2026-08-07T00:00:00.000Z'
};
const markup = sandbox.primeSlideMarkup(slide);
assert.match(markup, /<img src="https:\/\/example\.com\/next\.jpg"/);
assert.equal(style.getPropertyValue('--player-canvas-width'), '1920px');
assert.equal(style.getPropertyValue('--player-canvas-height'), '1080px');
assert.deepEqual(sandbox.lastRenderedSlide, { id: 1 });
});
test('scheduleSlideMarkupPreload warms the next slide only', () => {
const timers = [];
const calls = [];
const sandbox = {
window: null,
document: {
documentElement: { style: createStyleStore() }
},
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
currentPlaylistSignature: 'signature',
currentPlaylistEtag: '',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
innerWidth: 1280,
innerHeight: 720,
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
escapeHtml(value) {
return String(value || '');
},
syncRenderCacheViewport() {},
syncBlackoutState() {},
setPlayerCanvasDimensions() {},
primeSlideMarkup(slide) {
calls.push(slide.id);
return 'markup:' + slide.id;
},
setTimeout(fn) {
timers.push(fn);
return timers.length;
}
};
sandbox.window = sandbox;
const playlistPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
loadScript(playlistPath, sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
sandbox.scheduleSlideMarkupPreload([current, next, later], 0);
assert.equal(timers.length, 1);
timers.shift()();
assert.deepEqual(calls, [2]);
});
+221
View File
@@ -0,0 +1,221 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createPageAuthToken } = require('../src/request-auth');
const { registerPlayerOnboardingRoutes } = require('../src/player/onboarding');
const originalSharedSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
const originalFetch = global.fetch;
test.after(() => {
if (originalSharedSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSharedSecret;
}
global.fetch = originalFetch;
});
function createAppAndHandlers() {
const handlers = {};
const app = {
use() {},
get(path, ...routeHandlers) {
handlers[path] = function (req, res) {
return new Promise((resolve, reject) => {
const runHandler = (index) => {
const handler = routeHandlers[index];
if (!handler) {
resolve();
return;
}
let nextCalled = false;
const next = function () {
nextCalled = true;
return runHandler(index + 1);
};
let result;
try {
result = handler(req, res, next);
} catch (error) {
reject(error);
return;
}
Promise.resolve(result).then(function (value) {
if (!nextCalled) {
resolve(value);
}
}, reject);
};
runHandler(0);
});
};
},
post() {}
};
return { app, handlers };
}
function createResponse() {
const headers = {};
return {
headers,
statusCode: 200,
body: null,
set(name, value) {
headers[name] = value;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
type(value) {
headers['Content-Type'] = value;
return this;
},
json(value) {
this.body = value;
return this;
},
send(value) {
this.body = value;
return this;
}
};
}
function registerThinClientRoutes(fetchImpl) {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'bridge-secret';
global.fetch = fetchImpl;
const { app, handlers } = createAppAndHandlers();
registerPlayerOnboardingRoutes(app, {
app,
common: {
renderPlayerOnboardingLandingPage() {
return 'landing';
},
renderPlayerOnboardingFormPage() {
return 'form';
}
},
thinClientBaseUrl: 'http://bridge.test',
playerPublicBaseUrl: 'http://public.test'
});
return handlers;
}
test('remote onboarding status proxies to the bridge and rewrites playerUrl', async () => {
const fetchCalls = [];
const handlers = registerThinClientRoutes(async (url, init) => {
fetchCalls.push({ url, init });
return {
status: 200,
headers: {
get(name) {
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
}
},
async json() {
return {
deviceId: 'device123',
onboarded: true,
clientName: 'Lobby Player',
screenId: 7,
screenSlug: 'main-screen',
screenName: 'Main Screen',
playerUrl: null
};
}
};
});
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/status']({
headers: {
'x-pulse-page-auth': token
},
query: {
deviceId: 'device 123!?'
},
socket: {}
}, res);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/status?deviceId=device123');
assert.equal(fetchCalls[0].init.method, 'GET');
assert.equal(fetchCalls[0].init.headers['x-pulse-page-auth'], token);
assert.equal(res.statusCode, 200);
assert.equal(res.body.playerUrl, 'http://public.test/screen/main-screen');
assert.equal(res.body.screenSlug, 'main-screen');
});
test('remote onboarding status returns 502 when the bridge is unavailable', async () => {
const handlers = registerThinClientRoutes(async () => {
return null;
});
const token = createPageAuthToken({ scope: 'player', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/status']({
headers: {
'x-pulse-page-auth': token
},
query: {
deviceId: 'device123'
},
socket: {}
}, res);
assert.equal(res.statusCode, 502);
assert.deepEqual(res.body, { error: 'Player bridge unavailable.' });
});
test('remote onboarding screens proxy preserves the bridge response body', async () => {
const fetchCalls = [];
const handlers = registerThinClientRoutes(async (url, init) => {
fetchCalls.push({ url, init });
return {
status: 200,
headers: {
get(name) {
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
}
},
async text() {
return JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] });
}
};
});
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/screens']({
headers: {
'x-pulse-page-auth': token
},
query: {},
socket: {}
}, res);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/screens');
assert.equal(fetchCalls[0].init.method, 'GET');
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
assert.equal(res.body, JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] }));
});
@@ -0,0 +1,130 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) {
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
}
function createSandbox() {
const rafCallbacks = [];
const calls = {
syncRtmpRegions: 0,
initRegion: 0,
playRegionAnimations: 0
};
const app = {
children: [],
firstElementChild: null,
innerHTMLValue: '',
classList: {
contains() {
return false;
}
},
set innerHTML(value) {
this.innerHTMLValue = String(value || '');
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
},
get innerHTML() {
return this.innerHTMLValue;
},
appendChild(node) {
if (node) {
node.isConnected = true;
this.children.push(node);
this.firstElementChild = this.firstElementChild || node;
}
return node;
},
querySelectorAll() {
return [];
}
};
const sandbox = {
window: null,
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
requestAnimationFrame(callback) {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
app,
slides: [],
index: 0,
currentPlaylistSignature: 'signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
slideTransitionTimer: null,
slideFadeDurationMs: 560,
slideExpiresAt: null,
pausedRemainingMs: null,
timer: null,
lastRenderedSlide: null,
destroyRtmpRegions() {},
syncRtmpRegions() {
calls.syncRtmpRegions += 1;
},
initializeRegionInstances() {
calls.initializeRegionInstances += 1;
},
playRegionAnimations() {
calls.playRegionAnimations += 1;
},
pulsePlayerRegionTypes: {
list() {
return [
{
definition: {
initRegion() {
calls.initRegion += 1;
}
}
}
];
}
},
isThumbnailPreview() {
return false;
}
};
sandbox.window = sandbox;
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
return { sandbox, calls, rafCallbacks, app };
}
test('renderSlideMarkup runs post-render setup immediately', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox);
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
assert.equal(app.innerHTMLValue, '<div class="slide">visible</div>');
assert.equal(calls.syncRtmpRegions, 1);
assert.equal(calls.initRegion, 1);
assert.equal(calls.playRegionAnimations, 1);
assert.equal(rafCallbacks.length, 0);
assert.equal(returned, app.firstElementChild);
});
+98
View File
@@ -0,0 +1,98 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
test('webpage preloading only targets the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
escapeHtml(value) {
return String(value || '');
},
currentPlaylistSignature: 'signature',
slides: [],
index: 0,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
preloadSignature: '',
preloadContainer: null
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
test('rtmp warmups only target the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
fetch() {
throw new Error('not expected');
},
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
slideMarkupCache: Object.create(null),
slides: [],
index: 0,
setTimeout,
clearTimeout,
document: {
createElement() {
return {
className: '',
setAttribute() {},
appendChild() {},
parentNode: null,
addEventListener() {}
};
},
body: {
appendChild() {}
}
},
pulsePlayerRegionTypes: {
register() {}
}
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'regions', 'rtmp.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
+67
View File
@@ -0,0 +1,67 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeIdentifier, recordPlayerHeartbeat, upsertPlayerRegistration } = require('../src/data/player-registry');
test('normalizeIdentifier trims and limits friendly player labels', () => {
assert.equal(normalizeIdentifier(' Shop2 '), 'Shop2');
assert.equal(normalizeIdentifier(''), '');
assert.equal(normalizeIdentifier('x'.repeat(300)).length, 255);
});
test('player registry upserts include the identifier field', async () => {
const calls = [];
const pool = {
async query(sql, params) {
calls.push({ sql, params });
if (String(sql || '').includes('information_schema.COLUMNS')) {
return [[{ column_count: 1 }]];
}
if (String(sql || '').includes('INSERT INTO d_players')) {
return [{ affectedRows: 1 }];
}
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
return [[{
id: 1,
identifier: 'Shop2',
public_base_url: 'http://player.local',
internal_base_url: 'http://player:8081',
last_seen_at: '2026-08-07 00:00:00'
}]];
}
return [[]];
}
};
const registration = await upsertPlayerRegistration(pool, {
deviceId: 'device-01',
identifier: 'Shop2',
publicBaseUrl: 'http://player.local/',
internalBaseUrl: 'http://player:8081/'
});
const heartbeat = await recordPlayerHeartbeat(pool, {
deviceId: 'device-01',
identifier: 'Shop2',
publicBaseUrl: 'http://player.local/',
internalBaseUrl: 'http://player:8081/'
});
assert.deepEqual(registration, {
id: 1,
identifier: 'Shop2',
public_base_url: 'http://player.local',
internal_base_url: 'http://player:8081',
last_seen_at: '2026-08-07 00:00:00'
});
assert.deepEqual(heartbeat, registration);
assert.ok(calls.some(function (call) {
return /INSERT INTO d_players/.test(String(call.sql || '')) && /identifier/.test(String(call.sql || ''));
}));
assert.ok(calls.some(function (call) {
return /SELECT .*identifier/.test(String(call.sql || ''));
}));
assert.deepEqual((calls.find(function (call) {
return /INSERT INTO d_players/.test(String(call.sql || ''));
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
});
+144
View File
@@ -0,0 +1,144 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
test('system status shows connected player count in the live feed label', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
const elements = {
'sidebar-status-dot': {
classList: {
remove() {},
add() {}
},
setAttribute() {}
},
'sidebar-status-text': {
textContent: ''
}
};
const sockets = [];
const context = {
document: {
getElementById(id) {
return elements[id] || null;
}
},
window: {
WebSocket: true,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() {
return 1;
},
clearTimeout() {},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket(url) {
this.url = url;
sockets.push(this);
this.close = function () {};
},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(sockets.length, 1);
sockets[0].onmessage({
data: JSON.stringify({
type: 'dashboard-state',
state: {
screens: [{ slug: 'alpha' }],
playerServiceConnected: true,
connectedPlayersCount: 4
}
})
});
assert.equal(elements['sidebar-status-text'].textContent, 'Live player feed - 4 players connected');
});
test('system status shows an explicit idle label when no players are connected', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
const elements = {
'sidebar-status-dot': {
classList: {
remove() {},
add() {}
},
setAttribute() {}
},
'sidebar-status-text': {
textContent: ''
}
};
const sockets = [];
const context = {
document: {
getElementById(id) {
return elements[id] || null;
}
},
window: {
WebSocket: true,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() {
return 1;
},
clearTimeout() {},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket(url) {
this.url = url;
sockets.push(this);
this.close = function () {};
},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(sockets.length, 1);
sockets[0].onmessage({
data: JSON.stringify({
type: 'dashboard-state',
state: {
screens: [{ slug: 'alpha' }],
playerServiceConnected: true,
connectedPlayersCount: 0
}
})
});
assert.equal(elements['sidebar-status-text'].textContent, 'Player feed online - no players connected');
});
+2 -1
View File
@@ -36,7 +36,8 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
uniqueScreenSlug: async () => 'beta', uniqueScreenSlug: async () => 'beta',
fetchDuplicateName: async () => null, fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }), fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }) fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
}, },
pages, pages,
getAuditUserId() { return 7; }, getAuditUserId() { return 7; },