feat: UI access for custom views (#2020)

* style: consistent casing on menu

* refactor: bundle demo from code

* feat: allow uploading custom views
This commit is contained in:
Carlos Valente
2026-03-24 09:19:45 +01:00
committed by GitHub
parent 95536902f5
commit 7e3aaa8c30
39 changed files with 1799 additions and 817 deletions
@@ -4,7 +4,7 @@ import { type ErrorResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
import { defaultCss } from '../../bundle/bundledCss.js';
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
@@ -3,8 +3,8 @@ import { readFile, writeFile } from 'node:fs/promises';
import type { TranslationObject } from 'ontime-types';
import { defaultCss } from '../../bundle/bundledCss.js';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
/**
* Reads the user's css file
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { CustomViewError } from '../customViews.errors.js';
import { isValidCustomViewSlug, resolveCustomViewDirectory, validateHtmlContent } from '../customViews.service.js';
describe('isValidCustomViewSlug()', () => {
it('accepts valid slugs', () => {
expect(isValidCustomViewSlug('a')).toBe(true);
expect(isValidCustomViewSlug('my-view-1')).toBe(true);
expect(isValidCustomViewSlug('example123')).toBe(true);
});
it('rejects empty or too long', () => {
expect(isValidCustomViewSlug('')).toBe(false);
expect(isValidCustomViewSlug('a'.repeat(64))).toBe(false);
});
it('rejects invalid characters', () => {
expect(isValidCustomViewSlug('Hello')).toBe(false);
expect(isValidCustomViewSlug('my_view')).toBe(false);
expect(isValidCustomViewSlug('my view')).toBe(false);
expect(isValidCustomViewSlug('../escape')).toBe(false);
});
it('rejects leading or trailing hyphens', () => {
expect(isValidCustomViewSlug('-start')).toBe(false);
expect(isValidCustomViewSlug('end-')).toBe(false);
});
});
describe('resolveCustomViewDirectory()', () => {
it('throws CustomViewError on invalid slugs', () => {
expect(() => resolveCustomViewDirectory('Hello-World')).toThrow(CustomViewError);
expect(() => resolveCustomViewDirectory('../escape')).toThrow(CustomViewError);
expect(() => resolveCustomViewDirectory('')).toThrow(CustomViewError);
});
});
describe('validateHtmlContent()', () => {
it('accepts valid HTML with doctype', () => {
expect(() => validateHtmlContent('<!DOCTYPE html><html><body>hello</body></html>')).not.toThrow();
});
it('accepts valid HTML starting with html tag', () => {
expect(() => validateHtmlContent('<html><body>hello</body></html>')).not.toThrow();
});
it('accepts HTML with inline script and style', () => {
const html = '<!DOCTYPE html><html><head><style>body{}</style></head><body><script>alert(1)</script></body></html>';
expect(() => validateHtmlContent(html)).not.toThrow();
});
it('rejects content that is not HTML', () => {
expect(() => validateHtmlContent('just some text')).toThrow(CustomViewError);
expect(() => validateHtmlContent('{"json": true}')).toThrow(CustomViewError);
});
it('rejects external script imports', () => {
const html = '<!DOCTYPE html><html><body><script src="https://cdn.example.com/app.js"></script></body></html>';
expect(() => validateHtmlContent(html)).toThrow('External scripts are not allowed');
});
it('rejects external stylesheets', () => {
const html = '<!DOCTYPE html><html><head><link rel="stylesheet" href="styles.css"></head><body></body></html>';
expect(() => validateHtmlContent(html)).toThrow('External stylesheets are not allowed');
});
it('rejects iframes', () => {
const html = '<!DOCTYPE html><html><body><iframe src="https://example.com"></iframe></body></html>';
expect(() => validateHtmlContent(html)).toThrow('Iframes are not allowed');
});
it('allows link tags that are not stylesheets', () => {
const html = '<!DOCTYPE html><html><head><link rel="icon" href="data:,"></head><body></body></html>';
expect(() => validateHtmlContent(html)).not.toThrow();
});
});
@@ -0,0 +1,22 @@
import type { Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
export class CustomViewError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'CustomViewError';
}
}
export function handleCustomViewsError(error: unknown, res: Response<ErrorResponse>) {
if (error instanceof CustomViewError) {
res.status(error.statusCode).send({ message: error.message });
return;
}
res.status(500).send({ message: getErrorMessage(error) });
}
@@ -0,0 +1,48 @@
import type { NextFunction, Request, Response } from 'express';
import multer from 'multer';
import type { ErrorResponse } from 'ontime-types';
import { customViewMaxFileSize } from './customViews.service.js';
const allowedMimeTypes = new Set(['text/html', 'application/xhtml+xml', 'application/octet-stream']);
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: customViewMaxFileSize,
files: 1,
},
fileFilter: (_req, file, cb) => {
if (allowedMimeTypes.has(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type "${file.mimetype}"`));
}
},
}).single('indexHtml');
export function uploadCustomViewFile(req: Request, res: Response<ErrorResponse>, next: NextFunction) {
upload(req, res, (error: unknown) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).send({ message: `File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded` });
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_UNEXPECTED_FILE') {
res.status(400).send({ message: 'Unexpected upload field. Use "indexHtml"' });
return;
}
if (error instanceof Error) {
res.status(400).send({ message: error.message });
return;
}
res.status(400).send({ message: 'Could not process upload request' });
});
}
@@ -0,0 +1,72 @@
import express from 'express';
import type { Request, Response } from 'express';
import { type CustomViewsListResponse, type ErrorResponse, type MessageResponse } from 'ontime-types';
import { handleCustomViewsError } from './customViews.errors.js';
import { uploadCustomViewFile } from './customViews.middleware.js';
import {
deleteCustomView,
getCustomViewDownloadPath,
listCustomViews,
restoreDemoView,
uploadCustomView,
} from './customViews.service.js';
import { validateCustomViewSlugParam } from './customViews.validation.js';
export const router = express.Router();
router.get('/', async (_req: Request, res: Response<CustomViewsListResponse | ErrorResponse>) => {
try {
const views = await listCustomViews();
res.status(200).send({ views });
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.post('/restore-demo', async (_req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try {
const view = await restoreDemoView();
res.status(201).send({ message: `Restored demo view "${view.slug}"` });
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.post(
'/:slug/upload',
validateCustomViewSlugParam,
uploadCustomViewFile,
async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try {
const view = await uploadCustomView(req.params.slug, req.file);
res.status(201).send({ message: `Uploaded custom view "${view.slug}"` });
} catch (error) {
handleCustomViewsError(error, res);
}
},
);
router.get('/:slug/download', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
try {
const pathToFile = await getCustomViewDownloadPath(req.params.slug);
const fileName = `${req.params.slug}-index.html`;
res.download(pathToFile, fileName, (error: Error | null) => {
if (error && !res.headersSent) {
res.status(500).send({ message: 'Could not download custom view' });
}
});
} catch (error) {
handleCustomViewsError(error, res);
}
});
router.delete('/:slug', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
try {
await deleteCustomView(req.params.slug);
res.status(204).send();
} catch (error) {
handleCustomViewsError(error, res);
}
});
@@ -0,0 +1,175 @@
import { join, resolve } from 'node:path';
import { type CustomViewSummary } from 'ontime-types';
import { defaultDemoHtml } from '../../bundle/bundledDemoHtml.js';
import { publicDir } from '../../setup/index.js';
import {
createDirectory,
deleteDirectory,
ensureDirectory,
fileIsReadable,
isNodeError,
readDirectoryEntries,
replaceDirectory,
statIfExists,
writeToFile,
} from '../../utils/fileManagement.js';
import { CustomViewError } from './customViews.errors.js';
/**
* Patterns that indicate external resource loading.
* Custom views must be self-contained single HTML files with only inline CSS and JavaScript.
*/
const forbiddenHtmlPatterns: { pattern: RegExp; message: string }[] = [
{ pattern: /<script[^>]+src\s*=/i, message: 'External scripts are not allowed. Use inline <script> instead.' },
{
pattern: /<link[^>]+rel\s*=\s*["']?stylesheet["']?/i,
message: 'External stylesheets are not allowed. Use inline <style> instead.',
},
{ pattern: /<iframe[\s>]/i, message: 'Iframes are not allowed.' },
];
export function validateHtmlContent(content: string): void {
const htmlDoctype = /^\s*<!doctype\s+html[\s>]/i;
const htmlTag = /^\s*<html[\s>]/i;
if (!htmlDoctype.test(content) && !htmlTag.test(content)) {
throw new CustomViewError(
'File does not appear to be valid HTML. Expected <!DOCTYPE html> or <html> at the start.',
400,
);
}
for (const { pattern, message } of forbiddenHtmlPatterns) {
if (pattern.test(content)) {
throw new CustomViewError(message, 400);
}
}
}
const allowedSlugChars = /^[a-z0-9-]+$/;
export function isValidCustomViewSlug(slug: string): boolean {
if (typeof slug !== 'string') return false;
if (slug.length < 1 || slug.length > 63) return false;
if (!allowedSlugChars.test(slug)) return false;
if (slug.startsWith('-') || slug.endsWith('-')) return false;
return true;
}
export function resolveCustomViewDirectory(slug: string): string {
if (!isValidCustomViewSlug(slug)) {
throw new CustomViewError('Invalid name. Use lowercase letters, numbers, and dashes only.', 400);
}
return resolve(publicDir.externalDir, slug);
}
export function getPathToCustomView(slug: string): string {
return join(resolveCustomViewDirectory(slug), customViewIndexFilename);
}
export interface CustomViewUploadFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
export const customViewMaxFileSize = 4_000_000; // 4MB
export const customViewIndexFilename = 'index.html';
export function validateCustomViewUpload(file: CustomViewUploadFile | undefined): CustomViewUploadFile {
if (!file) {
throw new CustomViewError('File not found', 422);
}
const fileName = file.originalname.trim().toLowerCase();
if (fileName !== customViewIndexFilename) {
throw new CustomViewError('Only index.html uploads are supported', 400);
}
if (file.size === 0) {
throw new CustomViewError('Uploaded file is empty', 400);
}
if (file.size > customViewMaxFileSize) {
throw new CustomViewError(`File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded`, 413);
}
const content = file.buffer.toString('utf-8');
validateHtmlContent(content);
return file;
}
export async function listCustomViews(): Promise<CustomViewSummary[]> {
ensureDirectory(publicDir.externalDir);
const entries = await readDirectoryEntries(publicDir.externalDir);
const views: CustomViewSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || !isValidCustomViewSlug(entry.name)) continue;
const indexStats = await statIfExists(getPathToCustomView(entry.name));
if (!indexStats?.isFile()) continue;
views.push({ slug: entry.name });
}
return views.sort((a, b) => a.slug.localeCompare(b.slug));
}
export async function uploadCustomView(
slug: string,
file: CustomViewUploadFile | undefined,
): Promise<CustomViewSummary> {
const uploadFile = validateCustomViewUpload(file);
ensureDirectory(publicDir.externalDir);
const viewDirectory = resolveCustomViewDirectory(slug);
const indexFile = getPathToCustomView(slug);
try {
await createDirectory(viewDirectory);
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
throw new CustomViewError(`Name "${slug}" already exists`, 409);
}
throw error;
}
try {
await writeToFile(indexFile, uploadFile.buffer);
return { slug };
} catch (error) {
await deleteDirectory(viewDirectory);
throw error;
}
}
export async function getCustomViewDownloadPath(slug: string): Promise<string> {
const indexFile = getPathToCustomView(slug);
if (!(await fileIsReadable(indexFile))) {
throw new CustomViewError(`Custom view "${slug}" not found`, 404);
}
return indexFile;
}
const demoViewSlug = 'demo';
export async function restoreDemoView(): Promise<CustomViewSummary> {
ensureDirectory(publicDir.externalDir);
const viewDirectory = resolveCustomViewDirectory(demoViewSlug);
const indexFile = getPathToCustomView(demoViewSlug);
await replaceDirectory(viewDirectory);
await writeToFile(indexFile, defaultDemoHtml, { encoding: 'utf-8' });
return { slug: demoViewSlug };
}
export async function deleteCustomView(slug: string): Promise<void> {
await deleteDirectory(resolveCustomViewDirectory(slug));
}
@@ -0,0 +1,16 @@
import { param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { isValidCustomViewSlug } from './customViews.service.js';
export const validateCustomViewSlugParam = [
param('slug')
.isString()
.trim()
.notEmpty()
.customSanitizer((value: string) => value.toLowerCase())
.custom((value: string) => isValidCustomViewSlug(value))
.withMessage('Invalid name. Use lowercase letters, numbers, and dashes only.'),
requestValidationFunction,
];
+2
View File
@@ -3,6 +3,7 @@ import express from 'express';
import { router as assetsRouter } from './assets/assets.router.js';
import { router as automationsRouter } from './automation/automation.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as customViewsRouter } from './custom-views/customViews.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as excelRouter } from './excel/excel.router.js';
import { router as projectRouter } from './project-data/projectData.router.js';
@@ -18,6 +19,7 @@ export const appRouter = express.Router();
appRouter.use('/automations', automationsRouter);
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/custom-views', customViewsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundowns', rundownsRouter);
+305
View File
@@ -0,0 +1,305 @@
export const defaultDemoHtml = `<!doctype html>
<html lang="en">
<head>
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<style>
body {
margin: 0;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
background: #f6f6f6;
color: #222;
}
.container {
display: flex;
flex-direction: row;
gap: 12px;
padding: 10px 12px;
}
.container .column {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.title-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #eaeaea;
}
.logo-title {
display: flex;
align-items: center;
gap: 7px;
}
.logo-title img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card {
padding: 10px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.card summary.title {
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
margin-bottom: 2px;
}
h1.title,
summary.title {
font-size: 0.95em;
font-weight: 600;
margin: 0;
user-select: none;
}
summary.title {
cursor: pointer;
}
code {
font-size: 0.75em;
font-family: monospace;
background: #f4f4f4;
border-radius: 4px;
padding: 1.5px 3px;
display: inline-block;
white-space: pre;
width: 100%;
}
figure {
margin: 0;
padding: 0;
}
figcaption.description {
color: #555;
font-size: 0.75em;
font-style: italic;
}
</style>
</head>
<body>
<header class="title-card">
<div class="logo-title">
<img
src="https://www.getontime.no/images/icons/ontime-logo.png"
alt="Ontime logo"
onerror="this.style.display = 'none'"
/>
<h1 class="title">Ontime demo</h1>
</div>
<div>
<span>Last message received at</span>
<span id="clock">-</span>
</div>
<nav>
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
</nav>
</header>
<main class="container">
<section class="column">
<details class="card" open>
<summary class="title">Timer</summary>
<figure>
<figcaption class="description">Current timer values</figcaption>
<code id="timer">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Rundown</summary>
<figure>
<figcaption class="description">Progress of the current rundown</figcaption>
<code id="rundown">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Offset</summary>
<figure>
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
<code id="offset">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Event now</summary>
<figure>
<figcaption class="description">Currently loaded event</figcaption>
<code id="eventNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event next</summary>
<figure>
<figcaption class="description">Next scheduled event</figcaption>
<code id="eventNext">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Group now</summary>
<figure>
<figcaption class="description">Currently active group</figcaption>
<code id="groupNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event flag</summary>
<figure>
<figcaption class="description">Currently targeted flag</figcaption>
<code id="eventFlag">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Message</summary>
<figure>
<figcaption class="description">Messaging feature</figcaption>
<code id="message">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Aux timers</summary>
<figure>
<figcaption class="description">Auxiliary Timer 1</figcaption>
<code id="auxtimer1">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 2</figcaption>
<code id="auxtimer2">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 3</figcaption>
<code id="auxtimer3">-</code>
</figure>
</details>
</section>
</main>
<script>
const isSecure = window.location.protocol === 'https:';
const userProvidedSocketUrl = \`\${isSecure ? 'wss' : 'ws'}://\${window.location.host}\${getStageHash()}/ws\`;
connectSocket();
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
function connectSocket(socketUrl = userProvidedSocketUrl) {
const websocket = new WebSocket(socketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.warn('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(\`WebSocket: attempting reconnect \${reconnectAttempts}\`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
const { tag, payload } = JSON.parse(event.data);
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
}
let localData = {};
function handleOntimePayload(payload) {
localData = { ...localData, ...payload };
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
if ('message' in payload) updateDOM('message', formatObject(payload.message));
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
}
function updateDOM(field, payload) {
const domElement = document.getElementById(field);
if (domElement) {
domElement.innerText = payload;
}
}
const millisToSeconds = 1000;
const millisToMinutes = 1000 * 60;
const millisToHours = 1000 * 60 * 60;
function formatTimer(number) {
if (number == null) {
return '--:--:--';
}
const millis = Math.abs(number);
const isNegative = number < 0;
return \`\${isNegative ? '-' : ''}\${leftPad(millis / millisToHours)}:\${leftPad(
(millis % millisToHours) / millisToMinutes,
)}:\${leftPad((millis % millisToMinutes) / millisToSeconds)}\`;
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
function formatObject(data) {
return JSON.stringify(data, null, 2);
}
function getStageHash() {
const href = window.location.href;
if (!href.includes('getontime.no')) {
return '';
}
const hash = href.split('/');
const stageHash = hash.at(3);
return stageHash ? \`/\${stageHash}\` : '';
}
</script>
</body>
</html>`;
+406
View File
@@ -8,3 +8,409 @@ http://<ip-address>:<port>/external/<folder-name>
```
https://docs.getontime.no/features/custom-views/
## Demo
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
Here, we subscribe to the websocket and display all the data received in a grid.
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
### Getting the data
To subscribe to the websocket you will need:
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
#### Example
- Ontime URL: `https://cloud.getontime.no/stage-123`
- Ontime token: `token-from-share`
```js
// use wss since we are connecting to an https address
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
/**
* Connects to the websocket server
* NOTE: this demo does not handle reconnections or errors
* @param {string} socketUrl
*/
const connectSocket = (socketUrl) => {
const websocket = new WebSocket(socketUrl);
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
// runtime-data is sent on connect, with the full state
// runtime-patch is sent on every change to the state
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
};
```
### Runtime data
`runtime-data` contains a patch of all the data in the server
you would need to create a function that parses the patch and extract the data you need
In our case, we simply map the data to a DOM element with the same ID as the field name.
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
#### Example of handling the payload
```js
const handleOntimePayload = (payload) => {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
};
```
#### Payload example
See below what the payload looks like.
Note: all timer values are in milliseconds.
```jsonc
{
/** Current server clock value */
"clock": 37816011,
/**
* Gathers the current running timer state
*/
"timer": {
/** Additional time added to the running timer, can be negative */
"addedTime": 0,
/** Current running timer countdown */
"current": 3574976,
/** Total duration of the running event */
"duration": 3600000,
/** Time elapsed since the timer started */
"elapsed": 25024,
/** Timestamp of the expected finish time */
"expectedFinish": 41391285,
/** Current phase of the running event */
"phase": "default",
/** Timer's playback state */
"playback": "play",
/** Secondary timer, used to count to an event start in roll mode */
"secondaryTimer": null,
/** Timestamp when the timer started */
"startedAt": 37791285,
},
/**
* Offset represents our current position in relation to the planned time
* a positive value means that we have added extra time to the expected end
* aka behind schedule
*/
"offset": {
/** Current absolute offset: accounts for planned times */
"absolute": 40394840,
/** Current relative offset: only counts for generated offset since start */
"relative": -35997119,
/** Currently selected offset mode */
"mode": "absolute",
/** Timestamp of the expected start of the next flag */
"expectedFlagStart": 80594840,
/** Timestamp of the expected end of the current group */
"expectedGroupEnd": 83594840,
/** Timestamp of the expected end of the loaded rundown */
"expectedRundownEnd": 90794840,
},
/** Data object describes rundown schedule and the current progress */
"rundown": {
/** Index of the currently selected event */
"selectedEventIndex": 1,
/** Total number of events */
"numEvents": 7,
/** Timestamp of the rundown's planned start time */
"plannedStart": 0,
/** Timestamp of the rundown's planned end time */
"plannedEnd": 50400000,
/** Timestamp of when the rundown was actually started */
"actualStart": 76391959,
},
/** Data of currently loaded event */
"eventNow": {
/** Unique identifier for the event */
"id": "9bf60f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Pre-show Countdown",
/** Timestamp of the planned start time */
"timeStart": 36000000,
/** Timestamp of the planned end time */
"timeEnd": 39600000,
/** Planned event duration */
"duration": 3600000,
/** Strategy for time management */
"timeStrategy": "lock-end",
/** Whether the event is linked to the start of the previous */
"linkStart": false,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Music plays, holding slide on screens",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 600000,
/** Danger time */
"timeDanger": 300000,
/** Custom fields for the event */
"custom": { "Custom_Field": "Put additional info here" },
/** Triggers associated with the event */
"triggers": [],
},
/** Upcoming event data */
"eventNext": {
/** Unique identifier for the event */
"id": "c2697f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Welcome",
/** Timestamp of the planned start time */
"timeStart": 39600000,
/** Timestamp of the planned end time */
"timeEnd": 40200000,
/** Planned event duration */
"duration": 600000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Emma Thompson",
/** Colour code for the event */
"colour": "#FFCC78",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Data of currently targetted flag event */
"eventFlag": {
/** Unique identifier for the event */
"id": "fa593e",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": true,
/** Title of the event */
"title": "Session 1",
/** Timestamp of the planned start time */
"timeStart": 40200000,
/** Timestamp of the planned end time */
"timeEnd": 43200000,
/** Planned event duration */
"duration": 3000000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Liam Carter, Sophia Patel + PowerPoint",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.2",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Current group data */
"groupNow": {
/** Unique identifier for the group */
"id": "7eaf99",
/** Entry type */
"type": "group",
/** Title of the group */
"title": "Morning Sessions",
/** Note associated with the group */
"note": "",
/** ID of entries nested in the group */
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
/** Optional, user defined target duration */
"targetDuration": null,
/** Colour code for the group */
"colour": "#339E4E",
/** Custom fields for the group */
"custom": {},
/** Revision number for the entry */
"revision": 0,
/** Timestamp of the first event's planned start time */
"timeStart": 36000000,
/** Timestamp of the last event's planned end time */
"timeEnd": 43200000,
/** Accumulated events duration */
"duration": 7200000,
/** Whether the first event has its start time linked */
"isFirstLinked": false,
},
/** Message object with data */
"message": {
/** Timer view message data */
"timer": {
/** Text associated with the timer view */
"text": "",
/** Whether the message is visible */
"visible": false,
/** Whether the timer view is blinking */
"blink": false,
/** Whether the timer view is blacked out */
"blackout": false,
/** Secondary source for the view */
"secondarySource": null,
},
/** Secondary message text */
"secondary": "",
},
/** Auxiliary timer 1 */
"auxtimer1": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 2 */
"auxtimer2": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 3 */
"auxtimer3": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
}
```
## Links
- [Ontime Documentation](https://docs.getontime.no)
- [GitHub Repository](https://github.com/getontime/ontime)
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
-405
View File
@@ -1,405 +0,0 @@
## Demo
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
Here, we subscribe to the websocket and display all the data received in a grid.
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
### Getting the data
To subscribe to the websocket you will need:
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
#### Example
- Ontime URL: `https://cloud.getontime.no/stage-123`
- Ontime token: `token-from-share`
```js
// use wss since we are connecting to an https address
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
/**
* Connects to the websocket server
* NOTE: this demo does not handle reconnections or errors
* @param {string} socketUrl
*/
const connectSocket = (socketUrl) => {
const websocket = new WebSocket(socketUrl);
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
// runtime-data is sent on connect, with the full state
// runtime-patch is sent on every change to the state
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
};
```
### Runtime data
`runtime-data` contains a patch of all the data in the server
you would need to create a function that parses the patch and extract the data you need
In our case, we simply map the data to a DOM element with the same ID as the field name.
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
#### Example of handling the payload
```js
const handleOntimePayload = (payload) => {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
};
```
#### Payload example
See below what the payload looks like.
Note: all timer values are in milliseconds.
```jsonc
{
/** Current server clock value */
"clock": 37816011,
/**
* Gathers the current running timer state
*/
"timer": {
/** Additional time added to the running timer, can be negative */
"addedTime": 0,
/** Current running timer countdown */
"current": 3574976,
/** Total duration of the running event */
"duration": 3600000,
/** Time elapsed since the timer started */
"elapsed": 25024,
/** Timestamp of the expected finish time */
"expectedFinish": 41391285,
/** Current phase of the running event */
"phase": "default",
/** Timer's playback state */
"playback": "play",
/** Secondary timer, used to count to an event start in roll mode */
"secondaryTimer": null,
/** Timestamp when the timer started */
"startedAt": 37791285,
},
/**
* Offset represents our current position in relation to the planned time
* a positive value means that we have added extra time to the expected end
* aka behind schedule
*/
"offset": {
/** Current absolute offset: accounts for planned times */
"absolute": 40394840,
/** Current relative offset: only counts for generated offset since start */
"relative": -35997119,
/** Currently selected offset mode */
"mode": "absolute",
/** Timestamp of the expected start of the next flag */
"expectedFlagStart": 80594840,
/** Timestamp of the expected end of the current group */
"expectedGroupEnd": 83594840,
/** Timestamp of the expected end of the loaded rundown */
"expectedRundownEnd": 90794840,
},
/** Data object describes rundown schedule and the current progress */
"rundown": {
/** Index of the currently selected event */
"selectedEventIndex": 1,
/** Total number of events */
"numEvents": 7,
/** Timestamp of the rundown's planned start time */
"plannedStart": 0,
/** Timestamp of the rundown's planned end time */
"plannedEnd": 50400000,
/** Timestamp of when the rundown was actually started */
"actualStart": 76391959,
},
/** Data of currently loaded event */
"eventNow": {
/** Unique identifier for the event */
"id": "9bf60f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Pre-show Countdown",
/** Timestamp of the planned start time */
"timeStart": 36000000,
/** Timestamp of the planned end time */
"timeEnd": 39600000,
/** Planned event duration */
"duration": 3600000,
/** Strategy for time management */
"timeStrategy": "lock-end",
/** Whether the event is linked to the start of the previous */
"linkStart": false,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Music plays, holding slide on screens",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 600000,
/** Danger time */
"timeDanger": 300000,
/** Custom fields for the event */
"custom": { "Custom_Field": "Put additional info here" },
/** Triggers associated with the event */
"triggers": [],
},
/** Upcoming event data */
"eventNext": {
/** Unique identifier for the event */
"id": "c2697f",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": false,
/** Title of the event */
"title": "Welcome",
/** Timestamp of the planned start time */
"timeStart": 39600000,
/** Timestamp of the planned end time */
"timeEnd": 40200000,
/** Planned event duration */
"duration": 600000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Emma Thompson",
/** Colour code for the event */
"colour": "#FFCC78",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.1",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Data of currently targetted flag event */
"eventFlag": {
/** Unique identifier for the event */
"id": "fa593e",
/** Entry type */
"type": "event",
/** Whether the event is flagged */
"flag": true,
/** Title of the event */
"title": "Session 1",
/** Timestamp of the planned start time */
"timeStart": 40200000,
/** Timestamp of the planned end time */
"timeEnd": 43200000,
/** Planned event duration */
"duration": 3000000,
/** Strategy for time management */
"timeStrategy": "lock-duration",
/** Whether the event is linked to the start of the previous */
"linkStart": true,
/** Action to take at the end of the event */
"endAction": "none",
/** Type of timer used for the event */
"timerType": "count-down",
/** Whether the timer counts to the end */
"countToEnd": false,
/** Whether the event is skipped */
"skip": false,
/** Note associated with the event */
"note": "Liam Carter, Sophia Patel + PowerPoint",
/** Colour code for the event */
"colour": "#77C785",
/** Current delay inherited from the rundown schedule */
"delay": 0,
/** Day offset for the event */
"dayOffset": 0,
/** Time gap between events */
"gap": 0,
/** Cue number for the event */
"cue": "1.2",
/** Parent group ID */
"parent": "7eaf99",
/** Revision number for the entry */
"revision": 0,
/** Warning time */
"timeWarning": 120000,
/** Danger time */
"timeDanger": 60000,
/** Custom fields for the event */
"custom": {},
/** Triggers associated with the event */
"triggers": [],
},
/** Current group data */
"groupNow": {
/** Unique identifier for the group */
"id": "7eaf99",
/** Entry type */
"type": "group",
/** Title of the group */
"title": "Morning Sessions",
/** Note associated with the group */
"note": "",
/** ID of entries nested in the group */
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
/** Optional, user defined target duration */
"targetDuration": null,
/** Colour code for the group */
"colour": "#339E4E",
/** Custom fields for the group */
"custom": {},
/** Revision number for the entry */
"revision": 0,
/** Timestamp of the first event's planned start time */
"timeStart": 36000000,
/** Timestamp of the last event's planned end time */
"timeEnd": 43200000,
/** Accumulated events duration */
"duration": 7200000,
/** Whether the first event has its start time linked */
"isFirstLinked": false,
},
/** Message object with data */
"message": {
/** Timer view message data */
"timer": {
/** Text associated with the timer view */
"text": "",
/** Whether the message is visible */
"visible": false,
/** Whether the timer view is blinking */
"blink": false,
/** Whether the timer view is blacked out */
"blackout": false,
/** Secondary source for the view */
"secondarySource": null,
},
/** Secondary message text */
"secondary": "",
},
/** Auxiliary timer 1 */
"auxtimer1": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 2 */
"auxtimer2": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
/** Auxiliary timer 3 */
"auxtimer3": {
/** Duration of the timer */
"duration": 300000,
/** Current timer value */
"current": 300000,
/** Playback state (e.g., play, pause, stop) */
"playback": "stop",
/** Direction of the timer */
"direction": "count-down",
},
}
```
## Links
- [Ontime Documentation](https://docs.getontime.no)
- [GitHub Repository](https://github.com/getontime/ontime)
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
-157
View File
@@ -1,157 +0,0 @@
/*eslint-env browser*/
/**
* This is a very minimal example for a websocket client
* You could use this as a starting point to creating your own interfaces
*/
// Data that the user needs to provide depending on the Ontime URL
const isSecure = window.location.protocol === 'https:';
const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.host}${getStageHash()}/ws`;
connectSocket();
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
/**
* Connects to the websocket server
* @param {string} socketUrl
*/
function connectSocket(socketUrl = userProvidedSocketUrl) {
const websocket = new WebSocket(socketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.warn('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
// all objects from ontime are structured with tag and payload
const { tag, payload } = JSON.parse(event.data);
/**
* runtime-data is sent
* - on connect with the full state
* - and then on every update with a patch
*/
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
}
let localData = {};
/**
* Handles the ontime payload updates
* @param {object} payload - The payload object containing the updates
*/
function handleOntimePayload(payload) {
// 1. apply the patch into your local copy of the data
localData = { ...localData, ...payload };
// 2. update the UI with the new data
// ... timer data
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
// ... rundown data
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
// ... runtime
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
// ... relevant entries
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
// ... messages service
if ('message' in payload) updateDOM('message', formatObject(payload.message));
// ... extra timers
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
}
/**
* Updates the DOM with a given payload
* @param {string} field - The runtime data field
* @param {object} payload - The patch object for the field
*/
function updateDOM(field, payload) {
const domElement = document.getElementById(field);
if (domElement) {
domElement.innerText = payload;
}
}
// Time constants used for calculating times
const millisToSeconds = 1000;
const millisToMinutes = 1000 * 60;
const millisToHours = 1000 * 60 * 60;
/**
* Formats a timer value into a human-readable string
* @param {number} number - The timer value in milliseconds
* @returns {string} The formatted timer string
*/
function formatTimer(number) {
if (number == null) {
return '--:--:--';
}
const millis = Math.abs(number);
const isNegative = number < 0;
return `${isNegative ? '-' : ''}${leftPad(millis / millisToHours)}:${leftPad(
(millis % millisToHours) / millisToMinutes,
)}:${leftPad((millis % millisToMinutes) / millisToSeconds)}`;
/**
* Pads a number with leading zeros
* @param {number} number - The number to pad
* @returns {string} The padded number string
*/
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
/**
* Stringifies an object into a pretty string
* @param {object} data - The data object to format
* @returns {string} The formatted data string
*/
function formatObject(data) {
return JSON.stringify(data, null, 2);
}
/**
* Utility to handle a demo deployed in an ontime stage
* You can likely ignore this in your app
*
* an url looks like
* https://cloud.getontime.no/stage-hash/external/demo/ -> /stage-hash
* @returns {string} - The stage hash if the app is running in an ontime stage
*/
function getStageHash() {
const href = window.location.href;
if (!href.includes('getontime.no')) {
return '';
}
const hash = href.split('/');
const stageHash = hash.at(3);
return stageHash ? `/${stageHash}` : '';
}
-116
View File
@@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<link href="./styles.css" rel="stylesheet" />
</head>
<body>
<header class="title-card">
<div class="logo-title">
<img
src="https://www.getontime.no/images/icons/ontime-logo.png"
alt="Ontime logo"
onerror="this.style.display = 'none'"
/>
<h1 class="title">Ontime demo</h1>
</div>
<div>
<span>Last message received at</span>
<span id="clock">-</span>
</div>
<nav>
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
<div>See <a href="README.md">README.md</a> details.</div>
</nav>
</header>
<main class="container">
<section class="column">
<details class="card" open>
<summary class="title">Timer</summary>
<figure>
<figcaption class="description">Current timer values</figcaption>
<code id="timer">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Rundown</summary>
<figure>
<figcaption class="description">Progress of the current rundown</figcaption>
<code id="rundown">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Offset</summary>
<figure>
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
<code id="offset">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Event now</summary>
<figure>
<figcaption class="description">Currently loaded event</figcaption>
<code id="eventNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event next</summary>
<figure>
<figcaption class="description">Next scheduled event</figcaption>
<code id="eventNext">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Group now</summary>
<figure>
<figcaption class="description">Currently active group</figcaption>
<code id="groupNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event flag</summary>
<figure>
<figcaption class="description">Currently targeted flag</figcaption>
<code id="eventFlag">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Message</summary>
<figure>
<figcaption class="description">Messaging feature</figcaption>
<code id="message">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Aux timers</summary>
<figure>
<figcaption class="description">Auxiliary Timer 1</figcaption>
<code id="auxtimer1">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 2</figcaption>
<code id="auxtimer2">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 3</figcaption>
<code id="auxtimer3">-</code>
</figure>
</details>
</section>
</main>
<script src="./app.js" type="text/javascript"></script>
</body>
</html>
-91
View File
@@ -1,91 +0,0 @@
body {
margin: 0;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
background: #f6f6f6;
color: #222;
}
.container {
display: flex;
flex-direction: row;
gap: 12px;
padding: 10px 12px;
}
.container .column {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.title-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #eaeaea;
}
.logo-title {
display: flex;
align-items: center;
gap: 7px;
}
.logo-title img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card {
padding: 10px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.card summary.title {
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
margin-bottom: 2px;
}
h1.title,
summary.title {
font-size: 0.95em;
font-weight: 600;
margin: 0;
user-select: none;
}
summary.title {
cursor: pointer;
}
code {
font-size: 0.75em;
font-family: monospace;
background: #f4f4f4;
border-radius: 4px;
padding: 1.5px 3px;
display: inline-block;
white-space: pre;
width: 100%;
}
figure {
margin: 0;
padding: 0;
}
figcaption.description {
color: #555;
font-size: 0.75em;
font-style: italic;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { copyFileSync, existsSync, writeFileSync } from 'fs';
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
import { defaultTranslation } from '../bundle/bundledTranslations.js';
import { ensureDirectory } from '../utils/fileManagement.js';
import { publicDir, publicFiles, srcFiles } from './index.js';
+76 -2
View File
@@ -1,5 +1,5 @@
import { PathLike, constants, existsSync, mkdirSync } from 'fs';
import { copyFile, readdir, unlink } from 'fs/promises';
import { type Dirent, PathLike, type Stats, constants, existsSync, mkdirSync } from 'fs';
import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { basename, join, parse } from 'path';
import { consoleError } from './console.js';
@@ -152,3 +152,77 @@ export const deleteFile = async (filePath: string) => {
console.error('Could not delete file:', error);
});
};
export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
/**
* Returns file stats, or null if the path does not exist.
* Re-throws any error that is not ENOENT.
*/
export async function statIfExists(filePath: string): Promise<Stats | null> {
try {
return await stat(filePath);
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
/**
* Recursively deletes a directory and all its contents.
* No-op if the directory does not exist.
*/
export async function deleteDirectory(directoryPath: string): Promise<void> {
await rm(directoryPath, { recursive: true, force: true });
}
/**
* Removes a directory if it exists and creates it fresh.
*/
export async function replaceDirectory(directoryPath: string): Promise<void> {
await rm(directoryPath, { recursive: true, force: true });
await mkdir(directoryPath, { recursive: true });
}
/**
* Creates a directory. Throws if it already exists.
*/
export async function createDirectory(directoryPath: string): Promise<void> {
await mkdir(directoryPath);
}
/**
* Returns directory entries with file type information.
*/
export async function readDirectoryEntries(directoryPath: string): Promise<Dirent[]> {
return readdir(directoryPath, { withFileTypes: true });
}
/**
* Writes content to a file, creating it if it does not exist.
*/
export async function writeToFile(
filePath: string,
content: string | Buffer,
options?: { encoding?: BufferEncoding },
): Promise<void> {
await writeFile(filePath, content, options);
}
/**
* Returns true if the file exists and is readable, false if it does not exist.
* Re-throws any error that is not ENOENT.
*/
export async function fileIsReadable(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.R_OK);
return true;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return false;
throw error;
}
}