From 87df656470e85a23f7617d3e1e1cddc209041524 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 10 Aug 2025 10:40:04 +0200 Subject: [PATCH] docs: demo project is runtime data documentation --- apps/server/src/external/demo/README.md | 402 ++++++++++++++++++ apps/server/src/external/demo/app.js | 145 +++++-- apps/server/src/external/demo/index.html | 116 ++++- apps/server/src/external/demo/styles.css | 97 ++++- .../src/definitions/runtime/Offset.type.ts | 13 +- .../definitions/runtime/TimerState.type.ts | 21 +- 6 files changed, 720 insertions(+), 74 deletions(-) diff --git a/apps/server/src/external/demo/README.md b/apps/server/src/external/demo/README.md index 5b5bb1a2c..233bfb982 100644 --- a/apps/server/src/external/demo/README.md +++ b/apps/server/src/external/demo/README.md @@ -1,3 +1,405 @@ ## 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/) diff --git a/apps/server/src/external/demo/app.js b/apps/server/src/external/demo/app.js index ef7394b60..710eef9d3 100644 --- a/apps/server/src/external/demo/app.js +++ b/apps/server/src/external/demo/app.js @@ -4,37 +4,22 @@ * You could use this as a starting point to creating your own interfaces */ -const mts = 1000; // millis to seconds -const mtm = 1000 * 60; // millis to minutes -const mth = 1000 * 60 * 60; // millis to hours +// 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.hostname}:${window.location.port}/ws`; -const leftPad = (number) => { - return Math.floor(number).toString().padStart(2, '0'); -}; - -const formatTimer = (number) => { - const millis = Math.abs(number); - const isNegative = number < 0; - return `${isNegative ? '-' : ''}${leftPad(millis / mth)}:${leftPad((millis % mth) / mtm)}:${leftPad( - (millis % mtm) / mts, - )}`; -}; - -function updateTimerElement(playback, timerValue) { - const timerElement = document.getElementById('timer'); - if (playback === 'stop') { - timerElement.innerText = '--:--:--'; - } else { - timerElement.innerText = formatTimer(timerValue); - } -} +connectSocket(); let reconnectTimeout; const reconnectInterval = 1000; let reconnectAttempts = 0; -const connectSocket = () => { - const websocket = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/ws`); +/** + * Connects to the websocket server + * @param {string} socketUrl + */ +function connectSocket(socketUrl = userProvidedSocketUrl) { + const websocket = new WebSocket(socketUrl); websocket.onopen = () => { clearTimeout(reconnectTimeout); @@ -57,27 +42,97 @@ const connectSocket = () => { }; websocket.onmessage = (event) => { - const data = JSON.parse(event.data); + // all objects from ontime are structured with tag and payload + const { tag, payload } = JSON.parse(event.data); - // all objects from ontime are structured with type and payload - const { type, payload } = data; - - // we only need to read message type of ontime - switch (type) { - case 'ontime': { - // destructure known data from ontime - // see https://docs.getontime.no/api/data/runtime-data/ - const { current, playback } = payload.timer; - updateTimerElement(playback, current); - break; - } - case 'ontime-timer': { - const { current, playback } = payload; - updateTimerElement(playback, current); - break; - } + /** + * runtime-data is sent + * - on connect with the full state + * - and then on every update with a patch + */ + if (tag === 'runtime-data') { + handleOntimePayload(payload); } }; -}; +} -connectSocket(); +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(number) { + return Math.floor(number).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); +} diff --git a/apps/server/src/external/demo/index.html b/apps/server/src/external/demo/index.html index a469f4db5..f4a68812f 100644 --- a/apps/server/src/external/demo/index.html +++ b/apps/server/src/external/demo/index.html @@ -1,14 +1,114 @@ + - - - - ontime demo - + + + + + ontime demo + -
- - +
+
+ Ontime logo +

Ontime demo

+
+
+ Last message received at + - +
+ +
+ +
+
+
+ Timer +
+
Current timer values
+ - +
+
+
+ Rundown +
+
Progress of the current rundown
+ - +
+
+
+ Offset +
+
Runtime offset and timings for upcoming targets
+ - +
+
+
+
+
+ Event now +
+
Currently loaded event
+ - +
+
+
+ Event next +
+
Next scheduled event
+ - +
+
+
+
+
+ Group now +
+
Currently active group
+ - +
+
+
+ Event flag +
+
Currently targeted flag
+ - +
+
+
+
+
+ Message +
+
Messaging feature
+ - +
+
+
+ Aux timers +
+
Auxiliary Timer 1
+ - +
+
+
Auxiliary Timer 2
+ - +
+
+
Auxiliary Timer 3
+ - +
+
+
+
+ + + + \ No newline at end of file diff --git a/apps/server/src/external/demo/styles.css b/apps/server/src/external/demo/styles.css index 6d267ceda..5f6a310a1 100644 --- a/apps/server/src/external/demo/styles.css +++ b/apps/server/src/external/demo/styles.css @@ -1,16 +1,91 @@ body { - overflow: hidden; 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; } -div { - height: 100vh; - width: 100vw; - display: flex; - justify-content: center; - align-items: center; - color: azure; +.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; - font-size: 20vw; - background-color: black; -} \ No newline at end of file + 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; +} diff --git a/packages/types/src/definitions/runtime/Offset.type.ts b/packages/types/src/definitions/runtime/Offset.type.ts index 85c6581f1..ea3f6e419 100644 --- a/packages/types/src/definitions/runtime/Offset.type.ts +++ b/packages/types/src/definitions/runtime/Offset.type.ts @@ -5,11 +5,22 @@ export enum OffsetMode { Relative = 'relative', } +/** + * 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 + */ export type Offset = { - absolute: number; // a positive value means that we are in over time aka behind schedule + /** Current absolute offset: accounts for planned times */ + absolute: number; + /** Current relative offset: only counts for generated offset since start */ relative: number; + /** Currently selected offset mode */ mode: OffsetMode; + /** Timestamp of the expected start of the next flag */ expectedGroupEnd: MaybeNumber; + /** Timestamp of the expected end of the current group */ expectedRundownEnd: MaybeNumber; + /** Timestamp of the expected end of the loaded rundown */ expectedFlagStart: MaybeNumber; }; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 8be3c2f64..039af9008 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -11,23 +11,26 @@ export enum TimerPhase { Pending = 'pending', } +/** + * Gathers the current running timer state + */ export type TimerState = { - /** time added by user, can be negative */ + /** Additional time added to the running timer, can be negative */ addedTime: number; - /** running countdown */ + /** Current running timer countdown */ current: MaybeNumber; - /** normalised duration of current event */ + /** Total duration of the running event */ duration: MaybeNumber; - /** elapsed time in current timer */ + /** Time elapsed since the timer started */ elapsed: MaybeNumber; - /** time we expect timer to finish */ + /** Timestamp of the expected finish time */ expectedFinish: MaybeNumber; - /** phase of of the running event */ + /** Current phase of the running event */ phase: TimerPhase; - /** playback state of the event */ + /** Timer's playback state */ playback: Playback; - /** used for roll mode */ + /** Secondary timer, used to count to an event start in roll mode */ secondaryTimer: MaybeNumber; - /** only if timer has already started */ + /** Timestamp when the timer started */ startedAt: MaybeNumber; };