Add player control-plane and dashboard updates
This commit is contained in:
+57
-48
@@ -44,6 +44,29 @@ function formatPlayerConnectionLabel(deviceId, remoteAddress) {
|
||||
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
|
||||
}
|
||||
|
||||
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deviceIds = screenPlayerDeviceIds.get(key);
|
||||
if (!Array.isArray(deviceIds) || !deviceIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(new Set(deviceIds.map(function (value) {
|
||||
return normalizeDeviceId(value);
|
||||
}).filter(Boolean))).map(function (deviceId) {
|
||||
const socket = playerSockets.get(deviceId);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { deviceId: deviceId, socket: socket };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveWebBaseUrl(req) {
|
||||
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
if (configuredWebBaseUrl) {
|
||||
@@ -148,6 +171,28 @@ async function start() {
|
||||
return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : '';
|
||||
}
|
||||
|
||||
function removeConnectedPlayerSocket(socket) {
|
||||
if (!socket || !socket.playerDeviceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = playerSockets.get(socket.playerDeviceId);
|
||||
if (current !== socket) {
|
||||
return false;
|
||||
}
|
||||
|
||||
playerSockets.delete(socket.playerDeviceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function logPlayerDisconnect(socket) {
|
||||
if (!socket || !socket.playerDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`);
|
||||
}
|
||||
|
||||
function resolveMediaPath(fileName) {
|
||||
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
@@ -347,12 +392,7 @@ async function start() {
|
||||
return res.status(400).json({ error: 'Command is required.' });
|
||||
}
|
||||
|
||||
const [registeredPlayers] = await pool.query(
|
||||
`SELECT identifier, public_base_url
|
||||
FROM d_players
|
||||
ORDER BY modified_at DESC, identifier ASC`
|
||||
);
|
||||
const targetPlayers = Array.isArray(registeredPlayers) ? registeredPlayers : [];
|
||||
const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds);
|
||||
|
||||
if (!targetPlayers.length) {
|
||||
return res.status(404).json({ error: 'Player is not connected.' });
|
||||
@@ -365,40 +405,15 @@ async function start() {
|
||||
payload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const results = await Promise.all(targetPlayers.map(async function (player) {
|
||||
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
|
||||
if (!baseUrl) {
|
||||
return { ok: false, status: 502, error: 'Player base URL is not configured.' };
|
||||
}
|
||||
|
||||
const results = await Promise.all(targetPlayers.map(async function (target) {
|
||||
const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {});
|
||||
const response = await fetch(new URL(`/api/screens/${encodeURIComponent(slug)}/commands`, baseUrl).toString(), {
|
||||
method: 'POST',
|
||||
headers: Object.assign({
|
||||
'content-type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
}, createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: requestBody
|
||||
})),
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch (_error) {
|
||||
parsed = { ok: response.ok, raw: text };
|
||||
}
|
||||
const response = await sendPlayerCommandToSocket(target.socket, requestBody);
|
||||
|
||||
return Object.assign({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
playerIdentifier: String(player && player.identifier || '').trim(),
|
||||
playerUrl: baseUrl
|
||||
}, parsed && typeof parsed === 'object' ? parsed : {});
|
||||
ok: Boolean(response && response.ok),
|
||||
status: response && response.status ? response.status : (response && response.ok ? 200 : 502),
|
||||
playerIdentifier: String(target.deviceId || '').trim()
|
||||
}, response && typeof response === 'object' ? response : {});
|
||||
}));
|
||||
|
||||
res.json({
|
||||
@@ -704,20 +719,14 @@ async function start() {
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
if (socket.playerDeviceId) {
|
||||
const current = playerSockets.get(socket.playerDeviceId);
|
||||
if (current === socket) {
|
||||
playerSockets.delete(socket.playerDeviceId);
|
||||
}
|
||||
if (removeConnectedPlayerSocket(socket)) {
|
||||
logPlayerDisconnect(socket);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
if (socket.playerDeviceId) {
|
||||
const current = playerSockets.get(socket.playerDeviceId);
|
||||
if (current === socket) {
|
||||
playerSockets.delete(socket.playerDeviceId);
|
||||
}
|
||||
if (removeConnectedPlayerSocket(socket)) {
|
||||
logPlayerDisconnect(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -906,7 +915,7 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl };
|
||||
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets };
|
||||
|
||||
if (require.main === module) {
|
||||
start().catch(function (error) {
|
||||
|
||||
@@ -48,6 +48,23 @@ async function start() {
|
||||
playerRuntime.installWebsocket(server);
|
||||
app.use(express.json());
|
||||
|
||||
let hasLoggedPlayerStartup = false;
|
||||
|
||||
function logPlayerStartup(connectionState) {
|
||||
if (hasLoggedPlayerStartup) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasLoggedPlayerStartup = true;
|
||||
console.info('[player] startup', {
|
||||
mode: isRemotePlayer ? 'bridge client' : 'local',
|
||||
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
|
||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null,
|
||||
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null,
|
||||
bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
|
||||
function resolveLocalMediaFilePath(fileName) {
|
||||
@@ -241,6 +258,10 @@ async function start() {
|
||||
});
|
||||
|
||||
socket.on('open', function () {
|
||||
logPlayerStartup({
|
||||
connected: true
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'register',
|
||||
deviceId: PLAYER_DEVICE_ID,
|
||||
@@ -322,6 +343,12 @@ async function start() {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRemotePlayer) {
|
||||
logPlayerStartup({
|
||||
connected: false
|
||||
});
|
||||
}
|
||||
|
||||
const stopThinClientRegistration = startThinClientRegistration();
|
||||
|
||||
server.listen(PORT, function () {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
var localForm = document.getElementById("onboarding-local-form");
|
||||
var localMessage = document.getElementById("onboarding-message");
|
||||
var localScreenSelect = document.getElementById("onboarding-screen-select");
|
||||
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
|
||||
function parseResponseError(response) {
|
||||
return response.text().then(function (text) {
|
||||
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||
@@ -60,7 +61,11 @@
|
||||
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||
}
|
||||
function loadQr(deviceId) {
|
||||
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
|
||||
if (!qr) { return; }
|
||||
qr.onerror = function () {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
};
|
||||
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
|
||||
}
|
||||
function submitOnboarding(deviceId, clientName, screenSlug) {
|
||||
return fetch("/api/onboarding", {
|
||||
@@ -133,6 +138,9 @@
|
||||
});
|
||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||
if (redirected) { return; }
|
||||
if (qr && !qr.getAttribute("src")) {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
}
|
||||
loadQr(deviceId);
|
||||
setStatus("Waiting for onboarding to finish.");
|
||||
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||
|
||||
@@ -144,11 +144,14 @@ function createPlayerPlaylistService(options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||
const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
const duration = videoRegions.reduce(function (longest, region) {
|
||||
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
|
||||
return regionDuration > longest ? regionDuration : longest;
|
||||
}, 0);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
|
||||
@@ -46,7 +46,7 @@ function renderOnboardingLandingBody() {
|
||||
' <div class="onboarding-layout">',
|
||||
' <div class="onboarding-qr-pane">',
|
||||
' <div class="onboarding-qr-frame">',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" />',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
|
||||
' </div>',
|
||||
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
|
||||
' </div>',
|
||||
|
||||
Vendored
+43
-4
@@ -22,8 +22,8 @@ function createWebBootstrap(options) {
|
||||
|
||||
const dashboardWs = new WebSocketServer({ noServer: true });
|
||||
const dashboardClients = new Set();
|
||||
const playerSnapshotCache = new Map();
|
||||
const playerSnapshotSockets = new Map();
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
function getPlayerSnapshotSocketUrl(slug) {
|
||||
@@ -44,6 +44,10 @@ function createWebBootstrap(options) {
|
||||
}
|
||||
|
||||
function ensurePlayerSnapshotSubscription(slug) {
|
||||
if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) {
|
||||
return options.ensurePlayerSnapshotSubscription(slug);
|
||||
}
|
||||
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || playerSnapshotSockets.has(key)) {
|
||||
return;
|
||||
@@ -130,12 +134,44 @@ function createWebBootstrap(options) {
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
let lastDashboardState = null;
|
||||
|
||||
function getFallbackDashboardState() {
|
||||
return lastDashboardState || {
|
||||
playlists: [],
|
||||
screens: [],
|
||||
clients: [],
|
||||
kioskPlayers: [],
|
||||
slides: [],
|
||||
playerServiceConnected: false,
|
||||
connectedPlayersCount: 0,
|
||||
connectedClientsCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDashboardState() {
|
||||
try {
|
||||
const state = await buildDashboardState();
|
||||
lastDashboardState = state;
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (lastDashboardState) {
|
||||
console.error(error);
|
||||
return lastDashboardState;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const state = await buildDashboardState();
|
||||
const state = await resolveDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
return getFallbackDashboardState();
|
||||
});
|
||||
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
|
||||
}
|
||||
|
||||
@@ -145,7 +181,10 @@ function createWebBootstrap(options) {
|
||||
}
|
||||
|
||||
dashboardRefreshInFlight = (async function () {
|
||||
const state = await buildDashboardState();
|
||||
const state = await resolveDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
return getFallbackDashboardState();
|
||||
});
|
||||
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
|
||||
for (const socket of dashboardClients) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
|
||||
@@ -120,7 +120,11 @@ function createDashboardStateService(options) {
|
||||
? await common.fetchPlayerRegistrations(pool)
|
||||
: [];
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
try {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
} catch (_error) {
|
||||
// Keep building dashboard state when one player snapshot subscription fails.
|
||||
}
|
||||
});
|
||||
|
||||
const [onboardingRows] = await pool.query(
|
||||
|
||||
@@ -40,6 +40,14 @@ function normalizePlayerRowBaseUrl(player) {
|
||||
return normalizeBaseUrl(player && player.internal_base_url);
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
@@ -68,7 +76,11 @@ function createUploadSyncService(options) {
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
const metadata = await getPlayerTaskMetadata();
|
||||
return metadata && metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||
if (!metadata || metadata.playerActive === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||
}
|
||||
|
||||
async function getPlayerTaskMetadata() {
|
||||
@@ -85,26 +97,45 @@ function createUploadSyncService(options) {
|
||||
if (pool && typeof fetchPlayerRegistrations === 'function') {
|
||||
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||
const players = await fetchPlayerRegistrations(pool);
|
||||
const exactPlayer = Array.isArray(players)
|
||||
? players.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
})
|
||||
: null;
|
||||
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||
const preferredPlayer = registeredPlayers.find(function (player) {
|
||||
const recentPlayers = registeredPlayers.filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
});
|
||||
const preferredPlayer = recentPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || exactPlayer || registeredPlayers[0] || null;
|
||||
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
|
||||
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
|
||||
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
|
||||
if (resolvedInternalBaseUrl || resolvedPublicBaseUrl || resolvedIdentifier) {
|
||||
}) || recentPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || recentPlayers[0] || null;
|
||||
if (preferredPlayer) {
|
||||
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
|
||||
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
|
||||
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: resolvedIdentifier || null,
|
||||
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
|
||||
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
|
||||
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null
|
||||
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
|
||||
if (registeredPlayers.length) {
|
||||
const stalePlayer = registeredPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || registeredPlayers[0] || null;
|
||||
const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer);
|
||||
const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url);
|
||||
const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = staleInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: staleIdentifier || null,
|
||||
playerPublicBaseUrl: stalePublicBaseUrl || null,
|
||||
playerInternalBaseUrl: staleInternalBaseUrl || null,
|
||||
playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null,
|
||||
playerActive: false
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
@@ -117,7 +148,8 @@ function createUploadSyncService(options) {
|
||||
playerIdentifier: getConfiguredPlayerIdentifier() || null,
|
||||
playerPublicBaseUrl: null,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null
|
||||
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
})().then(function (metadata) {
|
||||
@@ -377,6 +409,10 @@ function createUploadSyncService(options) {
|
||||
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function isPlayerUnavailableResponse(response) {
|
||||
return Boolean(response) && Number(response.status) === 503;
|
||||
}
|
||||
|
||||
function queuePlayerUploadSync(operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
@@ -404,13 +440,13 @@ function createUploadSyncService(options) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -435,7 +471,7 @@ function createUploadSyncService(options) {
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -444,7 +480,9 @@ function createUploadSyncService(options) {
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -456,13 +494,13 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -475,7 +513,7 @@ function createUploadSyncService(options) {
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -483,7 +521,9 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -500,9 +540,10 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
@@ -683,15 +724,23 @@ function createUploadSyncService(options) {
|
||||
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
|
||||
? pendingEntries[0].metadata
|
||||
: await getPlayerTaskMetadata();
|
||||
if (playerMetadata && playerMetadata.playerActive === false) {
|
||||
pendingPlayerUploadSyncs.clear();
|
||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
||||
return;
|
||||
}
|
||||
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
|
||||
? playerMetadata.playerInternalBaseUrl
|
||||
: await getPlayerInternalBaseUrl();
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
let success = false;
|
||||
if (operation.type === 'delete') {
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
} else {
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
}
|
||||
if (success) {
|
||||
successCount += 1;
|
||||
|
||||
@@ -25,6 +25,29 @@ function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
async function fetchRecentPlayerRegistrations(pool) {
|
||||
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const players = await fetchPlayerRegistrations(pool);
|
||||
return (Array.isArray(players) ? players : []).filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const pool = options && options.pool;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
@@ -57,10 +80,10 @@ function createPlayerActionService(options) {
|
||||
})
|
||||
: null;
|
||||
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||
const preferredPlayer = registeredPlayers.find(function (player) {
|
||||
const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || exactPlayer || registeredPlayers[0] || null;
|
||||
}) || registeredPlayers[0] || null;
|
||||
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
|
||||
if (resolvedBaseUrl) {
|
||||
playerInternalBaseUrl = resolvedBaseUrl;
|
||||
@@ -87,24 +110,26 @@ function createPlayerActionService(options) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) {
|
||||
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!targetBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
if (connectionId) {
|
||||
payload.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -126,6 +151,11 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
@@ -164,29 +194,66 @@ function createPlayerActionService(options) {
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const recentPlayers = await fetchRecentPlayerRegistrations(pool);
|
||||
const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) {
|
||||
return normalizeBaseUrl(player && player.public_base_url);
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!targetBaseUrls.length) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (resolvedPlayerInternalBaseUrl) {
|
||||
targetBaseUrls.push(resolvedPlayerInternalBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetBaseUrls.length) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}));
|
||||
|
||||
const mergedConnections = [];
|
||||
let screen = null;
|
||||
let degraded = false;
|
||||
results.forEach(function (result) {
|
||||
if (!result) {
|
||||
degraded = true;
|
||||
return;
|
||||
}
|
||||
if (!screen && result.screen) {
|
||||
screen = result.screen;
|
||||
}
|
||||
if (Array.isArray(result.connections)) {
|
||||
mergedConnections.push.apply(mergedConnections, result.connections);
|
||||
}
|
||||
if (result.degraded) {
|
||||
degraded = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { connections: [] };
|
||||
});
|
||||
return {
|
||||
screen: screen,
|
||||
screenSlug: slug,
|
||||
count: mergedConnections.length,
|
||||
connections: mergedConnections,
|
||||
degraded: degraded
|
||||
};
|
||||
}
|
||||
|
||||
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||
@@ -234,6 +301,7 @@ function createPlayerActionService(options) {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||
|
||||
@@ -694,7 +694,8 @@
|
||||
|
||||
.table-pagination-page .app-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-pagination-page .app-content .container-fluid {
|
||||
@@ -730,7 +731,7 @@
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-height: var(--table-pagination-card-min-height, 0);
|
||||
max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,170 @@
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
var LIST_PAGE_SIZE = 25;
|
||||
var latestDashboardState = null;
|
||||
var ALL_SCREENS_SLUG = '__all__';
|
||||
var ALL_SCREENS_LABEL = 'All screens';
|
||||
|
||||
function getClientSearchInput() {
|
||||
var table = document.getElementById('dashboard-clients-table');
|
||||
var container = table && typeof table.closest === 'function'
|
||||
? (table.closest('[data-table-search-container]') || table.closest('.card') || null)
|
||||
: null;
|
||||
|
||||
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
|
||||
}
|
||||
|
||||
function getClientListQueryState() {
|
||||
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
|
||||
var searchInput = getClientSearchInput();
|
||||
var searchValue = searchInput ? String(searchInput.value || '').trim() : String(searchParams.get('search') || '').trim();
|
||||
|
||||
return {
|
||||
search: searchValue,
|
||||
sort: String(searchParams.get('sort') || '').trim(),
|
||||
direction: String(searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc',
|
||||
page: Math.max(1, Math.floor(Number(searchParams.get('page') || 1) || 1))
|
||||
};
|
||||
}
|
||||
|
||||
function isClientSearchLoading() {
|
||||
return Boolean(document.querySelector('[data-table-search-loading="true"]'));
|
||||
}
|
||||
|
||||
function getComparableClientSortValue(rawValue) {
|
||||
var value = String(rawValue || '').trim();
|
||||
|
||||
if (!value) {
|
||||
return { type: 'empty', value: '' };
|
||||
}
|
||||
|
||||
var numericValue = Number(value.replace(/,/g, ''));
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
return { type: 'number', value: numericValue };
|
||||
}
|
||||
|
||||
var dateValue = Date.parse(value);
|
||||
if (!Number.isNaN(dateValue)) {
|
||||
return { type: 'date', value: dateValue };
|
||||
}
|
||||
|
||||
return { type: 'string', value: value.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareClientSortValues(leftValue, rightValue) {
|
||||
var left = getComparableClientSortValue(leftValue);
|
||||
var right = getComparableClientSortValue(rightValue);
|
||||
|
||||
if (left.type === 'empty' && right.type === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (left.type === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (right.type === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
if (left.type === right.type) {
|
||||
if (left.value < right.value) {
|
||||
return -1;
|
||||
}
|
||||
if (left.value > right.value) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return String(left.value).localeCompare(String(right.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function createClientSearchMatcher(searchTerm) {
|
||||
var query = String(searchTerm || '').trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return function () {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return function (client) {
|
||||
var searchableValues = [
|
||||
client && client.name,
|
||||
client && client.client_name,
|
||||
client && client.clientId,
|
||||
client && client.deviceId,
|
||||
client && client.slug,
|
||||
client && client.screen_slug,
|
||||
client && client.screen_name,
|
||||
client && client.ipAddress,
|
||||
client && client.clientIp,
|
||||
client && client.status,
|
||||
client && client.currentSlideTitle
|
||||
];
|
||||
|
||||
return searchableValues.map(function (value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}).join(' ').indexOf(query) !== -1;
|
||||
};
|
||||
}
|
||||
|
||||
function sortClientsForTable(clients, sortKey, sortDirection) {
|
||||
var normalizedSortKey = String(sortKey || '').trim();
|
||||
var normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
var accessors = {
|
||||
client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); },
|
||||
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
||||
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
||||
ip: function (client) { return String(client && client.clientIp || '').trim(); },
|
||||
viewport: function (client) {
|
||||
var viewport = client && client.viewport;
|
||||
if (!viewport || !viewport.width || !viewport.height) {
|
||||
return '';
|
||||
}
|
||||
return String(Number(viewport.width) || 0) + 'x' + String(Number(viewport.height) || 0);
|
||||
},
|
||||
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
|
||||
};
|
||||
|
||||
function compareValues(leftValue, rightValue) {
|
||||
return compareClientSortValues(leftValue, rightValue);
|
||||
}
|
||||
|
||||
var sortKeys = accessors[normalizedSortKey]
|
||||
? [normalizedSortKey]
|
||||
: ['client'];
|
||||
|
||||
if (sortKeys[0] === 'client') {
|
||||
sortKeys.push('ip');
|
||||
} else if (sortKeys[0] === 'ip') {
|
||||
sortKeys.push('client');
|
||||
}
|
||||
|
||||
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||
for (var index = 0; index < sortKeys.length; index += 1) {
|
||||
var sortKeyName = sortKeys[index];
|
||||
var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
|
||||
|
||||
if (comparison !== 0) {
|
||||
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getVisibleClients(state) {
|
||||
var query = getClientListQueryState();
|
||||
var clients = Array.isArray(state && state.clients) ? state.clients.slice() : [];
|
||||
var searchMatcher = createClientSearchMatcher(query.search);
|
||||
|
||||
clients = clients.filter(searchMatcher);
|
||||
clients = sortClientsForTable(clients, query.sort, query.direction);
|
||||
|
||||
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
|
||||
}
|
||||
|
||||
function getClientMoveModalElements() {
|
||||
return {
|
||||
modal: document.getElementById('client-move-screen-modal'),
|
||||
@@ -17,7 +177,8 @@
|
||||
targetSelect: document.getElementById('client-move-screen-target'),
|
||||
connectionInput: document.querySelector('[data-client-move-connection-id]'),
|
||||
deviceInput: document.querySelector('[data-client-move-device-id]'),
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]')
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]'),
|
||||
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +211,7 @@
|
||||
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
||||
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
|
||||
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
||||
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
|
||||
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
|
||||
@@ -71,6 +233,9 @@
|
||||
if (elements.clientNameInput) {
|
||||
elements.clientNameInput.value = clientName;
|
||||
}
|
||||
if (elements.playerBaseUrlInput) {
|
||||
elements.playerBaseUrlInput.value = playerBaseUrl;
|
||||
}
|
||||
elements.targetSelect.value = '';
|
||||
if (elements.form.querySelector('button[type="submit"]')) {
|
||||
elements.form.querySelector('button[type="submit"]').disabled = false;
|
||||
@@ -91,12 +256,12 @@
|
||||
|
||||
return [
|
||||
'<div class="actions justify-content-end">',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}
|
||||
@@ -128,6 +293,10 @@
|
||||
if (connectionInput) {
|
||||
connectionInput.value = client.id || '';
|
||||
}
|
||||
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (playerBaseUrlInput) {
|
||||
playerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -143,6 +312,10 @@
|
||||
if (reloadInput) {
|
||||
reloadInput.value = client.id || '';
|
||||
}
|
||||
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (reloadPlayerBaseUrlInput) {
|
||||
reloadPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
|
||||
}
|
||||
@@ -175,6 +348,10 @@
|
||||
if (blackoutConnectionInput) {
|
||||
blackoutConnectionInput.value = client.id || '';
|
||||
}
|
||||
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (blackoutPlayerBaseUrlInput) {
|
||||
blackoutPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -196,6 +373,10 @@
|
||||
if (previousConnectionInput) {
|
||||
previousConnectionInput.value = client.id || '';
|
||||
}
|
||||
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (previousPlayerBaseUrlInput) {
|
||||
previousPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -217,6 +398,10 @@
|
||||
if (nextConnectionInput) {
|
||||
nextConnectionInput.value = client.id || '';
|
||||
}
|
||||
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (nextPlayerBaseUrlInput) {
|
||||
nextPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
@@ -255,7 +440,7 @@
|
||||
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||
|
||||
return [
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
|
||||
'<td data-label="Client"><div>' + clientName + '</div></td>',
|
||||
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||
@@ -267,6 +452,51 @@
|
||||
].join('');
|
||||
}
|
||||
|
||||
function updateClientRowCells(row, client, hasActionsColumn) {
|
||||
if (!row || !row.cells || row.cells.length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIpValue = normalizeDisplayIp(client.clientIp);
|
||||
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
|
||||
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
|
||||
var clientNameValue = getClientDisplayName(client);
|
||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||
|
||||
row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client)));
|
||||
row.setAttribute('data-client-id', escapeHtml(client.clientId || ''));
|
||||
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
|
||||
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
|
||||
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
|
||||
|
||||
setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
|
||||
setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
|
||||
setCellHtml(row.cells[2], currentSlide);
|
||||
setCellHtml(row.cells[3], clientIp);
|
||||
setCellHtml(row.cells[4], viewport);
|
||||
setCellHtml(row.cells[5], connectedAt);
|
||||
|
||||
syncClientActionCell(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
function createClientRowFromTemplate(client, hasActionsColumn) {
|
||||
var template = document.createElement('tbody');
|
||||
template.innerHTML = renderClientRow(client, hasActionsColumn);
|
||||
return template.firstElementChild || null;
|
||||
}
|
||||
|
||||
function setCellHtml(cell, html) {
|
||||
if (!cell || String(cell.innerHTML || '') === String(html || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cell.innerHTML = html;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderScreenTile(screen) {
|
||||
var clientCount = Number(screen.player_connection_count || 0);
|
||||
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||
@@ -327,76 +557,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateClientTable(state) {
|
||||
function updateClientTable(state, forceRender) {
|
||||
var tbody = document.getElementById('dashboard-clients-table-body');
|
||||
if (!tbody || !Array.isArray(state.clients)) {
|
||||
return;
|
||||
}
|
||||
if (!forceRender && isClientSearchLoading()) {
|
||||
return;
|
||||
}
|
||||
var table = document.getElementById('dashboard-clients-table');
|
||||
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
|
||||
if (!state.clients.length) {
|
||||
var visibleClients = getVisibleClients(state);
|
||||
|
||||
var canPatchRows = typeof tbody.querySelectorAll === 'function'
|
||||
&& typeof tbody.insertBefore === 'function'
|
||||
&& typeof tbody.removeChild === 'function'
|
||||
&& typeof document.createElement === 'function';
|
||||
|
||||
if (!visibleClients.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canPatchRows) {
|
||||
tbody.innerHTML = visibleClients.map(function (client) {
|
||||
return renderClientRow(client, hasActionsColumn);
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
var existingRows = {};
|
||||
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
|
||||
existingRows[row.getAttribute('data-client-key')] = row;
|
||||
existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row;
|
||||
});
|
||||
|
||||
Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) {
|
||||
if (!row.hasAttribute('data-client-key')) {
|
||||
row.parentNode.removeChild(row);
|
||||
var nextRows = visibleClients.map(function (client) {
|
||||
var rowKey = String(getClientRowKey(client) || '').trim();
|
||||
var row = existingRows[rowKey] || null;
|
||||
|
||||
if (!row) {
|
||||
row = createClientRowFromTemplate(client, hasActionsColumn);
|
||||
} else {
|
||||
updateClientRowCells(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
return row;
|
||||
}).filter(function (row) {
|
||||
return Boolean(row);
|
||||
});
|
||||
|
||||
state.clients.forEach(function (client, index) {
|
||||
var rowKey = getClientRowKey(client);
|
||||
var row = existingRows[rowKey];
|
||||
if (!row) {
|
||||
var tempBody = document.createElement('tbody');
|
||||
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
|
||||
row = tempBody.firstElementChild;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.setAttribute('data-client-key', rowKey);
|
||||
row.setAttribute('data-client-id', client.clientId || '');
|
||||
row.setAttribute('data-client-device-id', client.deviceId || '');
|
||||
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
|
||||
if (row.cells && row.cells.length >= 6) {
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIpValue = normalizeDisplayIp(client.clientIp);
|
||||
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
|
||||
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
|
||||
var clientNameValue = getClientDisplayName(client);
|
||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||
|
||||
row.cells[0].innerHTML = '<div>' + clientName + '</div>';
|
||||
row.cells[1].innerHTML = '<div>' + screenName + '</div>';
|
||||
row.cells[2].innerHTML = currentSlide;
|
||||
row.cells[3].innerHTML = clientIp;
|
||||
row.cells[4].innerHTML = viewport;
|
||||
row.cells[5].innerHTML = connectedAt;
|
||||
syncClientActionCell(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
nextRows.forEach(function (row, index) {
|
||||
var referenceNode = tbody.children[index] || null;
|
||||
if (referenceNode !== row) {
|
||||
tbody.insertBefore(row, referenceNode);
|
||||
}
|
||||
});
|
||||
|
||||
while (tbody.children.length > state.clients.length) {
|
||||
while (tbody.children.length > nextRows.length) {
|
||||
tbody.removeChild(tbody.lastElementChild);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function refreshClientTableFromLatestState() {
|
||||
if (!latestDashboardState) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateClientTable(latestDashboardState, true);
|
||||
}
|
||||
|
||||
function updateKioskLauncherModal(state) {
|
||||
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
|
||||
if (!modal) {
|
||||
@@ -674,6 +904,7 @@
|
||||
return;
|
||||
}
|
||||
latestDashboardState = state;
|
||||
window.webLatestDashboardState = latestDashboardState;
|
||||
updateStats(state);
|
||||
updateScreenGrid(state);
|
||||
updateScreenCommandControls(state);
|
||||
@@ -895,6 +1126,7 @@
|
||||
}
|
||||
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
window.webRefreshClientTableFromLatestState = refreshClientTableFromLatestState;
|
||||
|
||||
initClientRenameHandler();
|
||||
initClientMoveHandler();
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
'<div>' +
|
||||
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
|
||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
(function () {
|
||||
var minimumPaginationCardHeight = 20 * 16;
|
||||
|
||||
function rebindTableContainer(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
@@ -33,8 +35,18 @@
|
||||
var cardRect = card.getBoundingClientRect();
|
||||
var bottomInset = 16;
|
||||
var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset);
|
||||
var contentHeight = Math.max(0, card.scrollHeight || 0);
|
||||
var shouldApplyMinimum = contentHeight > minimumPaginationCardHeight;
|
||||
var cardHeight = Math.max(minimumPaginationCardHeight, availableHeight);
|
||||
|
||||
if (shouldApplyMinimum) {
|
||||
card.style.setProperty('--table-pagination-card-min-height', minimumPaginationCardHeight + 'px');
|
||||
} else {
|
||||
card.style.removeProperty('--table-pagination-card-min-height');
|
||||
}
|
||||
|
||||
card.style.removeProperty('--table-pagination-card-height');
|
||||
card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px');
|
||||
card.style.setProperty('--table-pagination-card-max-height', cardHeight + 'px');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +124,7 @@
|
||||
var currentUrl = new URL(window.location.href);
|
||||
var pendingSearchTimer = null;
|
||||
var requestSequence = 0;
|
||||
var pendingSearchRequests = 0;
|
||||
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
|
||||
|
||||
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
|
||||
@@ -133,6 +146,10 @@
|
||||
|
||||
requestSequence += 1;
|
||||
var sequenceId = requestSequence;
|
||||
pendingSearchRequests += 1;
|
||||
if (container) {
|
||||
container.setAttribute('data-table-search-loading', 'true');
|
||||
}
|
||||
|
||||
fetch(nextUrl.toString(), {
|
||||
method: 'GET',
|
||||
@@ -165,6 +182,11 @@
|
||||
}
|
||||
}).catch(function () {
|
||||
window.location.assign(nextUrl.toString());
|
||||
}).finally(function () {
|
||||
pendingSearchRequests = Math.max(0, pendingSearchRequests - 1);
|
||||
if (container && pendingSearchRequests === 0) {
|
||||
container.removeAttribute('data-table-search-loading');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
@@ -13,11 +14,74 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const requirePermission = deps.requirePermission;
|
||||
const ALL_SCREENS_SLUG = '__all__';
|
||||
|
||||
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) {
|
||||
if (typeof getScreenConnections !== 'function' || !screenSlug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getScreenConnections(screenSlug);
|
||||
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
||||
if (!liveConnections.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedConnectionId = String(connectionId || '').trim();
|
||||
const liveConnection = normalizedConnectionId
|
||||
? liveConnections.find(function (connection) {
|
||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||
return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId;
|
||||
})
|
||||
: null;
|
||||
const targetConnections = liveConnection ? [liveConnection] : liveConnections;
|
||||
|
||||
return Array.from(new Set(targetConnections.map(function (connection) {
|
||||
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
}).filter(Boolean)));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExplicitPlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
async function resolveAllPlayerBaseUrls() {
|
||||
if (!common || typeof common.fetchPlayerRegistrations !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const players = await common.fetchPlayerRegistrations(pool);
|
||||
return Array.from(new Set((Array.isArray(players) ? players : [])
|
||||
.filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
})
|
||||
.map(function (player) {
|
||||
return String(player && player.public_base_url || '').trim().replace(/\/$/, '');
|
||||
})
|
||||
.filter(Boolean)));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || '');
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
@@ -47,7 +111,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
await Promise.all(screenRows.map(function (screenRow) {
|
||||
return forwardPlayerCommand(String(screenRow && screenRow.slug || '').trim(), commandPayload);
|
||||
const screenSlug = String(screenRow && screenRow.slug || '').trim();
|
||||
return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) {
|
||||
if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||
return Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
|
||||
return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined);
|
||||
}));
|
||||
}
|
||||
|
||||
return forwardPlayerCommand(screenSlug, commandPayload);
|
||||
});
|
||||
}));
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
@@ -74,6 +147,28 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
|
||||
if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') {
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: { command: command };
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined);
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
}
|
||||
|
||||
return res.json(Object.assign({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null
|
||||
}, result && typeof result === 'object' ? result : {}));
|
||||
}
|
||||
|
||||
if (command === 'setclientname') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
@@ -256,17 +351,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
||||
})
|
||||
: null;
|
||||
const targetBaseUrl = String(
|
||||
const sourcePlayerBaseUrl = String(
|
||||
explicitPlayerBaseUrl ||
|
||||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
|
||||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
|
||||
''
|
||||
).trim().replace(/\/$/, '');
|
||||
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
} else {
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
}
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
@@ -292,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const result = connectionId
|
||||
? await forwardPlayerCommand(slug, commandPayload, connectionId)
|
||||
: await forwardPlayerCommand(slug, commandPayload);
|
||||
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId);
|
||||
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function'
|
||||
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
|
||||
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId);
|
||||
})).then(function (results) {
|
||||
return Array.isArray(results) && results.length ? results[0] : { ok: true };
|
||||
})
|
||||
: (connectionId
|
||||
? await forwardPlayerCommand(slug, commandPayload, connectionId)
|
||||
: await forwardPlayerCommand(slug, commandPayload));
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
|
||||
@@ -33,6 +33,7 @@ function registerRoutes(app, deps) {
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
mediaDir: deps.mediaDir,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
buildDashboardState: deps.buildDashboardState,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
requirePermission: deps.requirePermission,
|
||||
@@ -141,6 +142,7 @@ function registerSignageRoutes(app, deps) {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
isClientNameAvailable: deps.isClientNameAvailable,
|
||||
withClientNameReservation: deps.withClientNameReservation,
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
|
||||
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
@@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
|
||||
};
|
||||
|
||||
if (!accessors[normalizedSortKey]) {
|
||||
return Array.isArray(clients) ? clients.slice() : [];
|
||||
function compareValues(leftValue, rightValue) {
|
||||
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
|
||||
}
|
||||
|
||||
return sortRows(clients, function (client) {
|
||||
return accessors[normalizedSortKey](client);
|
||||
}, normalizedDirection);
|
||||
const sortKeys = accessors[normalizedSortKey]
|
||||
? [normalizedSortKey]
|
||||
: ['client'];
|
||||
|
||||
if (sortKeys[0] === 'client') {
|
||||
sortKeys.push('ip');
|
||||
} else if (sortKeys[0] === 'ip') {
|
||||
sortKeys.push('client');
|
||||
}
|
||||
|
||||
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||
for (let index = 0; index < sortKeys.length; index += 1) {
|
||||
const sortKeyName = sortKeys[index];
|
||||
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
|
||||
|
||||
if (comparison !== 0) {
|
||||
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
|
||||
@@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
const duration = videoRegions.reduce((longest, region) => {
|
||||
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
|
||||
return regionDuration > longest ? regionDuration : longest;
|
||||
}, 0);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,14 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat');
|
||||
const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh');
|
||||
@@ -87,13 +95,18 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
? await common.fetchPlayerRegistrations(pool)
|
||||
: [];
|
||||
|
||||
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).map(function (player) {
|
||||
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
}).map(function (player) {
|
||||
const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
|
||||
const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl);
|
||||
return Object.assign({}, player, {
|
||||
return {
|
||||
identifier: String(player && player.identifier || '').trim(),
|
||||
public_base_url: baseUrl || null,
|
||||
player_url: playerUrl || null
|
||||
});
|
||||
};
|
||||
}).sort(function (left, right) {
|
||||
return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<tbody id="dashboard-clients-table-body">
|
||||
{{#if clients.length}}
|
||||
{{#each clients}}
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
|
||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
@@ -129,33 +129,38 @@
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{#if (hasPermission currentUser 'clients.allow')}}
|
||||
{{#if (hasPermission ../currentUser 'clients.allow')}}
|
||||
<td data-label="Actions" class="text-end">
|
||||
<div class="actions justify-content-end">
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="previous" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-warning" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="next" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-warning" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-info" aria-label="Pause client" title="Pause client"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause</button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}" aria-label="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}" title="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}"><i class="bi {{#if blackout}}bi-eye{{else}}bi-eye-slash{{/if}} me-1" aria-hidden="true"></i>{{#if blackout}}Restore{{else}}Blackout{{/if}}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -185,6 +190,7 @@
|
||||
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
|
||||
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
|
||||
<input type="hidden" name="clientName" value="" data-client-move-client-name />
|
||||
<input type="hidden" name="playerBaseUrl" value="" data-client-move-player-base-url />
|
||||
<label class="form-label" for="client-move-screen-target">Target screen group</label>
|
||||
<select class="form-select" id="client-move-screen-target" name="targetScreenSlug" data-client-move-target required>
|
||||
{{#if screens.length}}
|
||||
|
||||
@@ -45,36 +45,32 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Player URLs</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body table-responsive p-0">
|
||||
{{#if screen.player_urls.length}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each screen.player_urls}}
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>URL</th>
|
||||
<td data-label="Player" class="text-break">{{identifier}}</td>
|
||||
<td class="text-break">
|
||||
{{#if player_url}}
|
||||
<a href="{{player_url}}" target="_blank" rel="noreferrer">{{player_url}}</a>
|
||||
{{else}}
|
||||
<span class="text-muted">Not available</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each screen.player_urls}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{identifier}}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{#if player_url}}
|
||||
<a href="{{player_url}}" target="_blank" rel="noreferrer">{{player_url}}</a>
|
||||
{{else}}
|
||||
<span class="text-muted">Not available</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="mb-0 text-muted">No player URLs are available yet.</p>
|
||||
<p class="mb-0 p-3 text-muted">No player URLs are available yet.</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user