Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

## Unreleased

## [2.2.0] - 2025-05-09

- [#142](https://github.com/os2display/display-client/pull/142)
- Added support for previews.

## [2.1.2] - 2024-11-20

- [#140](https://github.com/os2display/display-client/pull/140)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ All endpoint should be configured without a trailing slash. The endpoints `apiEn
left empty if the api is hosted from the root of the same domain as the client. E.g. if the api is at https://example.org and the client is at
https://example.org/client

## Preview

The client can be started in preview mode by setting the following url parameters:
```
preview=<screen|playlist|slide>
preview-id=<id of entity to preview>
preview-token=<token for accessing data>
preview-tenant=<tenant id>
```

The preview will use the token and tenant for acessing the data from the api.

## Docker development setup

Start docker setup
Expand Down
67 changes: 49 additions & 18 deletions src/app.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import Screen from "./components/screen";
import ContentService from "./service/content-service";
import ConfigLoader from "./util/config-loader";
Expand All @@ -16,10 +17,13 @@ import constants from "./util/constants";
/**
* App component.
*
* @param {object} props The props.
* @param {string | null} props.preview Type of preview to enable.
* @param {string | null} props.previewId The id of the entity to preview.
* @returns {object}
* The component.
*/
function App() {
function App({ preview, previewId }) {
const [running, setRunning] = useState(false);
const [screen, setScreen] = useState("");
const [bindKey, setBindKey] = useState(null);
Expand Down Expand Up @@ -187,30 +191,52 @@ function App() {

useEffect(() => {
logger.info("Mounting App.");
if (preview !== null) {
document.addEventListener("screen", screenHandler);
document.addEventListener("contentEmpty", contentEmpty);
document.addEventListener("contentNotEmpty", contentNotEmpty);

if (preview === "screen") {
startContent(previewId);
return;
}
setRunning(true);
contentServiceRef.current = new ContentService();
contentServiceRef.current.start();
document.dispatchEvent(
new CustomEvent("startPreview", {
detail: {
mode: preview,
id: previewId,
},
})
);
} else {
document.addEventListener("keypress", handleKeyboard);
document.addEventListener("screen", screenHandler);
document.addEventListener("reauthenticate", reauthenticateHandler);
document.addEventListener("contentEmpty", contentEmpty);
document.addEventListener("contentNotEmpty", contentNotEmpty);

document.addEventListener("keypress", handleKeyboard);
document.addEventListener("screen", screenHandler);
document.addEventListener("reauthenticate", reauthenticateHandler);
document.addEventListener("contentEmpty", contentEmpty);
document.addEventListener("contentNotEmpty", contentNotEmpty);

tokenService.checkToken();
tokenService.checkToken();

ConfigLoader.loadConfig().then((config) => {
setDebug(config.debug ?? false);
});
ConfigLoader.loadConfig().then((config) => {
setDebug(config.debug ?? false);
});

releaseService.checkForNewRelease().finally(() => {
releaseService.setPreviousBootInUrl();
releaseService.startReleaseCheck();
releaseService.checkForNewRelease().finally(() => {
releaseService.setPreviousBootInUrl();
releaseService.startReleaseCheck();

checkLogin();
checkLogin();

appStorage.setPreviousBoot(new Date().getTime());
});
appStorage.setPreviousBoot(new Date().getTime());
});

statusService.setStatusInUrl();
statusService.setStatusInUrl();
}

/* eslint-disable-next-line consistent-return */
return function cleanup() {
logger.info("Unmounting App.");

Expand Down Expand Up @@ -261,4 +287,9 @@ function App() {
);
}

App.propTypes = {
preview: PropTypes.string,
previewId: PropTypes.string,
};

export default App;
12 changes: 8 additions & 4 deletions src/data-sync/api-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,25 @@ class ApiHelper {
let response;

try {
logger.info(`Fetching: ${this.endpoint + path}`);
const url = new URL(window.location.href);
const previewToken = url.searchParams.get('preview-token');
const previewTenant = url.searchParams.get('preview-tenant');

logger.log('info', `Fetching: ${this.endpoint + path}`);

const token = appStorage.getToken();
const tenantKey = appStorage.getTenantKey();

if (!token || !tenantKey) {
if ((!token || !tenantKey) && (!previewToken || !previewTenant)) {
logger.error('Token or tenantKey not set.');

return null;
}

response = await fetch(this.endpoint + path, {
headers: {
authorization: `Bearer ${token}`,
'Authorization-Tenant-Key': tenantKey,
authorization: `Bearer ${previewToken ?? token}`,
'Authorization-Tenant-Key': previewTenant ?? tenantKey,
},
});

Expand Down
34 changes: 34 additions & 0 deletions src/data-sync/pull-strategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,40 @@ class PullStrategy {
document.dispatchEvent(event);
}

getPath(id) {
return this.apiHelper.getPath(id);
}

async getTemplateData(slide) {
return new Promise((resolve) => {
const templatePath = slide.templateInfo['@id'];

this.apiHelper.getPath(templatePath).then((data) => {
resolve(data);
});
});
}

async getFeedData(slide) {
return new Promise((resolve) => {
if (!slide?.feed?.feedUrl) {
resolve([]);
} else {
this.apiHelper.getPath(slide.feed.feedUrl).then((data) => {
resolve(data);
});
}
});
}

async getMediaData(media) {
return new Promise((resolve) => {
this.apiHelper.getPath(media).then((data) => {
resolve(data);
});
});
}

/**
* Start the data synchronization.
*/
Expand Down
12 changes: 12 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';

const url = new URL(window.location.href);
const preview = url.searchParams.get('preview');
const previewId = url.searchParams.get('preview-id');

const container = document.getElementById('root');
const root = createRoot(container);

root.render(<App preview={preview} previewId={previewId} />);
6 changes: 5 additions & 1 deletion src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./app";

const url = new URL(window.location.href);
const preview = url.searchParams.get("preview");
const previewId = url.searchParams.get("preview-id");

const container = document.getElementById("root");
const root = createRoot(container);

root.render(<App />);
root.render(<App preview={preview} previewId={previewId} />);
100 changes: 98 additions & 2 deletions src/service/content-service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import sha256 from 'crypto-js/sha256';
import Base64 from 'crypto-js/enc-base64';
import PullStrategy from '../data-sync/pull-strategy';
import {
screenForPlaylistPreview,
screenForSlidePreview,
} from '../util/preview';
import logger from '../logger/logger';
import DataSync from '../data-sync/data-sync';
import ScheduleService from './schedule-service';
Expand Down Expand Up @@ -81,14 +86,17 @@ class ContentService {

this.stopSyncHandler();

logger.log(
'info',
`Event received: Start data synchronization from ${data?.screenPath}`
);
if (data?.screenPath) {
logger.info(
`Event received: Start data synchronization from ${data.screenPath}`
);
this.startSyncing(data.screenPath);
} else {
logger.info('Event received: Start data synchronization');
this.startSyncing();
logger.log('error', 'Error: screenPath not set.');
}
}

Expand Down Expand Up @@ -176,6 +184,7 @@ class ContentService {
document.addEventListener('content', this.contentHandler);
document.addEventListener('regionReady', this.regionReadyHandler);
document.addEventListener('regionRemoved', this.regionRemovedHandler);
document.addEventListener('startPreview', this.startPreview);
}

/**
Expand All @@ -189,6 +198,93 @@ class ContentService {
document.removeEventListener('content', this.contentHandler);
document.removeEventListener('regionReady', this.regionReadyHandler);
document.removeEventListener('regionRemoved', this.regionRemovedHandler);
document.removeEventListener('startPreview', this.startPreview);
}

/**
* Start preview.
*
* @param {CustomEvent} event The event.
*/
async startPreview(event) {
const data = event.detail;
const { mode, id } = data;
logger.log('info', `Starting preview. Mode: ${mode}, ID: ${id}`);

const config = await ConfigLoader.loadConfig();

if (mode === 'screen') {
this.startSyncing(`/v2/screen/${id}`);
} else if (mode === 'playlist') {
const pullStrategy = new PullStrategy({
endpoint: config.apiEndpoint,
});

const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`);

const playlistSlidesResponse = await pullStrategy.getPath(
playlist.slides
);

playlist.slidesData = playlistSlidesResponse['hydra:member'].map(
(playlistSlide) => playlistSlide.slide
);

// eslint-disable-next-line no-restricted-syntax
for (const slide of playlist.slidesData) {
// eslint-disable-next-line no-await-in-loop
await ContentService.attachReferencesToSlide(pullStrategy, slide);
}

const screen = screenForPlaylistPreview(playlist);

document.dispatchEvent(
new CustomEvent('content', {
detail: {
screen,
},
})
);
} else if (mode === 'slide') {
const pullStrategy = new PullStrategy({
endpoint: config.apiEndpoint,
});

const slide = await pullStrategy.getPath(`/v2/slides/${id}`);

// eslint-disable-next-line no-await-in-loop
await ContentService.attachReferencesToSlide(pullStrategy, slide);

const screen = screenForSlidePreview(slide);

document.dispatchEvent(
new CustomEvent('content', {
detail: {
screen,
},
})
);
} else {
logger.error(`Unsupported preview mode: ${mode}.`);
}
}

static async attachReferencesToSlide(strategy, slide) {
/* eslint-disable no-param-reassign */
slide.templateData = await strategy.getTemplateData(slide);
slide.feedData = await strategy.getFeedData(slide);

slide.mediaData = {};
// eslint-disable-next-line no-restricted-syntax
for (const media of slide.media) {
// eslint-disable-next-line no-await-in-loop
slide.mediaData[media] = await strategy.getMediaData(media);
}

if (typeof slide.theme === 'string' || slide.theme instanceof String) {
slide.theme = await strategy.getPath(slide.theme);
}
/* eslint-enable no-param-reassign */
}

/**
Expand Down
12 changes: 10 additions & 2 deletions src/service/token-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,19 @@ class TokenService {
checkToken = () => {
const expiredState = this.getExpireState();

if ([constants.NO_EXPIRE, constants.NO_ISSUED_AT, constants.NO_TOKEN].includes(expiredState)) {
if (
[
constants.NO_EXPIRE,
constants.NO_ISSUED_AT,
constants.NO_TOKEN,
].includes(expiredState)
) {
// Ignore. No token saved in storage.
} else if (expiredState === constants.TOKEN_EXPIRED) {
statusService.setError(constants.ERROR_TOKEN_EXPIRED);
} else if (expiredState === constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED) {
} else if (
expiredState === constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED
) {
statusService.setError(
constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED
);
Expand Down
Loading