Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.

## Unreleased

- Added support for previews.
- [#140](https://github.com/os2display/display-client/pull/140)
- Fixed issue where campaign end resulted in blank screen.

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
Loading