diff --git a/packages/live-status-gateway/.eslintrc.json b/packages/live-status-gateway/.eslintrc.json
index 82be0193db..1b89104e9a 100644
--- a/packages/live-status-gateway/.eslintrc.json
+++ b/packages/live-status-gateway/.eslintrc.json
@@ -13,7 +13,7 @@
"rules": {
"prettier/prettier": ["error", { "endOfLine": "auto" }],
"node/no-unpublished-import": ["error", {
- "allowModules": ["jest-mock-extended"]
+ "allowModules": ["jest-mock-extended", "type-fest"]
}]
},
"parserOptions": {
diff --git a/packages/live-status-gateway/README.md b/packages/live-status-gateway/README.md
index 25a2ecc297..2d93c8ffe6 100644
--- a/packages/live-status-gateway/README.md
+++ b/packages/live-status-gateway/README.md
@@ -49,3 +49,9 @@ ws.addEventListener('error', (error) => {
console.log('socket error', error);
});
```
+
+### Timing accuracy
+
+The Live Status Gateway provides certain values in the form of timestamps, referencing both past and future events. These timestamps are particularly useful, for instance, in creating countdown timers. It's important to note that these values are relative to the system clock of the machine hosting Sofie Core.
+
+For optimal accuracy, we strongly recommend that external systems and applications leveraging these timestamps implement a method for time synchronization. This synchronization should align with the same time source used by Sofie Core — whether at the operating system level (e.g., utilizing a system-wide NTP client) or at the application level.
diff --git a/packages/live-status-gateway/api/schemas/activePlaylist.yaml b/packages/live-status-gateway/api/schemas/activePlaylist.yaml
index b0788f903a..59f5615b95 100644
--- a/packages/live-status-gateway/api/schemas/activePlaylist.yaml
+++ b/packages/live-status-gateway/api/schemas/activePlaylist.yaml
@@ -20,7 +20,10 @@ $defs:
type: string
currentPart:
description: The current Part - if empty, no part in the active playlist is live
- $ref: '#/$defs/part'
+ $ref: '#/$defs/currentPart'
+ currentSegment:
+ description: The Segment of the current Part - if empty, no part in the active playlist is live
+ $ref: '#/$defs/currentSegment'
nextPart:
description: The next Part - if empty, no part will follow live part
$ref: '#/$defs/part'
@@ -34,7 +37,7 @@ $defs:
type: array
items:
$ref: '#/$defs/adLib'
- required: [event, id, name, rundownIds, currentPart, nextPart, adLibs, globalAdLibs]
+ required: [event, id, name, rundownIds, currentPart, currentSegment, nextPart, adLibs, globalAdLibs]
additionalProperties: false
examples:
- event: activePlaylist
@@ -42,38 +45,78 @@ $defs:
name: 'Playlist 0'
rundownIds: ['y9HauyWkcxQS3XaAOsW40BRLLsI_']
currentPart:
- $ref: '#/$defs/part/examples'
+ $ref: '#/$defs/currentPart/examples/0'
+ currentSegment:
+ $ref: '#/$defs/currentSegment/examples/0'
nextPart:
- $ref: '#/$defs/part/examples'
+ $ref: '#/$defs/part/examples/0'
adLibs:
$ref: '#/$defs/adLib/examples'
globals:
$ref: '#/$defs/adLib/examples'
+ partBase:
+ type: object
+ properties:
+ id:
+ description: Unique id of the part
+ type: string
+ name:
+ description: User name of the part
+ type: string
+ segmentId:
+ description: Unique id of the segment this part belongs to
+ type: string
+ autoNext:
+ description: If this part will progress to the next automatically
+ type: boolean
+ default: false
+ required: [id, name, segmentId]
+ additionalProperties: false
+ examples:
+ - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_'
+ name: 'Intro'
+ segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_'
+ autoNext: false
part:
oneOf:
- - type: object
- properties:
- id:
- description: Unique id of the part
- type: string
- name:
- description: User name of the part
- type: string
- segmentId:
- description: Unique id of the segment this part belongs to
- type: string
- autoNext:
- description: Should this part progress to the next automatically
- type: boolean
- default: false
- required: [id, name, segmentId]
- additionalProperties: false
+ - $ref: '#/$defs/partBase'
- type: 'null'
examples:
- id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_'
name: 'Intro'
segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_'
autoNext: false
+ currentPart:
+ oneOf:
+ - allOf:
+ - $ref: '#/$defs/partBase'
+ - type: object
+ properties:
+ timing:
+ description: Timing information about the current part
+ type: object
+ properties:
+ startTime:
+ description: Unix timestamp of when the part started (milliseconds)
+ type: number
+ expectedDurationMs:
+ description: Expected duration of the part (milliseconds)
+ type: number
+ projectedEndTime:
+ description: Unix timestamp of when the part is projected to end (milliseconds). A sum of `startTime` and `expectedDurationMs`.
+ type: number
+ required: [startTime, expectedDurationMs, projectedEndTime]
+ required: [timing]
+ - type: 'null'
+ examples:
+ - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_'
+ name: 'Intro'
+ segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_'
+ autoNext: false
+ timing:
+ startTime: 1600000060000
+ expectedDurationMs: 15000
+ projectedEndTime: 1600000075000
adLib:
type: object
properties:
@@ -118,3 +161,31 @@ $defs:
- name: pvw
label: Preview
tags: ['music_video']
+ currentSegment:
+ type: object
+ properties:
+ id:
+ description: Unique id of the segment
+ type: string
+ timing:
+ description: Timing information about the current segment
+ type: object
+ properties:
+ expectedDurationMs:
+ description: Expected duration of the segment
+ type: number
+ budgetDurationMs:
+ description: Budget duration of the segment
+ type: number
+ projectedEndTime:
+ description: Unix timestamp of when the segment is projected to end (milliseconds). The time this segment started, offset by its budget duration, if the segment has a defined budget duration. Otherwise, the time the current part started, offset by the difference between expected durations of all parts in this segment and the as-played durations of the parts that already stopped.
+ type: number
+ required: [expectedDurationMs, projectedEndTime]
+ required: [id, timing]
+ additionalProperties: false
+ examples:
+ - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_'
+ timing:
+ expectedDurationMs: 15000
+ budgetDurationMs: 20000
+ projectedEndTime: 1600000075000
diff --git a/packages/live-status-gateway/api/schemas/segments.yaml b/packages/live-status-gateway/api/schemas/segments.yaml
index 4ba041d195..0949e0b3c1 100644
--- a/packages/live-status-gateway/api/schemas/segments.yaml
+++ b/packages/live-status-gateway/api/schemas/segments.yaml
@@ -17,7 +17,7 @@ $defs:
type: array
items:
$ref: '#/$defs/segment'
- required: [event, id, segments]
+ required: [event, rundownPlaylistId, segments]
additionalProperties: false
examples:
- event: segments
@@ -39,9 +39,22 @@ $defs:
name:
description: Name of the segment
type: string
- required: [id, rundownId, name]
+ timing:
+ type: object
+ properties:
+ expectedDurationMs:
+ description: Expected duration of the segment (milliseconds)
+ type: number
+ budgetDurationMs:
+ description: Budget duration of the segment (milliseconds)
+ type: number
+ required: [expectedDurationMs]
+ required: [id, rundownId, name, timing]
additionalProperties: false
examples:
- id: 'OKAgZmZ0Buc99lE_2uPPSKVbMrQ_'
rundownId: 'y9HauyWkcxQS3XaAOsW40BRLLsI_'
name: 'Segment 0'
+ timing:
+ expectedDurationMs: 15000
+ budgetDurationMs: 20000
diff --git a/packages/live-status-gateway/package.json b/packages/live-status-gateway/package.json
index 75deac341e..aa4b10f05b 100644
--- a/packages/live-status-gateway/package.json
+++ b/packages/live-status-gateway/package.json
@@ -69,7 +69,8 @@
"@asyncapi/generator": "1.9.13",
"@asyncapi/html-template": "0.26.0",
"@asyncapi/nodejs-ws-template": "0.9.25",
- "jest-mock-extended": "^3.0.5"
+ "jest-mock-extended": "^3.0.5",
+ "type-fest": "^4.5.0"
},
"lint-staged": {
"*.{css,json,md,scss}": [
diff --git a/packages/live-status-gateway/sample-client/.eslintrc.json b/packages/live-status-gateway/sample-client/.eslintrc.json
new file mode 100644
index 0000000000..bb7dd23ebd
--- /dev/null
+++ b/packages/live-status-gateway/sample-client/.eslintrc.json
@@ -0,0 +1,5 @@
+{
+ "env": {
+ "browser": true
+ }
+}
diff --git a/packages/live-status-gateway/sample-client/index.html b/packages/live-status-gateway/sample-client/index.html
new file mode 100644
index 0000000000..94eb84896f
--- /dev/null
+++ b/packages/live-status-gateway/sample-client/index.html
@@ -0,0 +1,16 @@
+
+
+
+ Live Status Gateway client
+
+
+
+
+
+
+
+
diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js
new file mode 100644
index 0000000000..137291ef02
--- /dev/null
+++ b/packages/live-status-gateway/sample-client/script.js
@@ -0,0 +1,146 @@
+const ws = new WebSocket(`ws://localhost:8080`)
+ws.addEventListener('message', (message) => {
+ const data = JSON.parse(message.data)
+ switch (data.event) {
+ case 'pong':
+ handlePong(data)
+ break
+ case 'heartbeat':
+ handleHeartbeat(data)
+ break
+ case 'subscriptionStatus':
+ handleSubscriptionStatus(data)
+ break
+ case 'studio':
+ handleStudio(data)
+ break
+ case 'activePlaylist':
+ handleActivePlaylist(data)
+ break
+ case 'segments':
+ handleSegments(data)
+ break
+ }
+})
+
+ws.addEventListener('open', () => {
+ console.log('socket open')
+
+ ws.send(JSON.stringify({ event: 'subscribe', subscription: { name: 'activePlaylist' }, reqid: 1 }))
+
+ ws.send(JSON.stringify({ event: 'subscribe', subscription: { name: 'segments' }, reqid: 2 }))
+})
+
+ws.addEventListener('close', () => {
+ console.log('socket close')
+})
+
+ws.addEventListener('error', (error) => {
+ console.log('socket error', error)
+})
+
+function handlePong() {
+ //
+}
+
+function handleHeartbeat() {
+ //
+}
+
+function handleSubscriptionStatus() {
+ //
+}
+
+function handleStudio() {
+ //
+}
+
+const TIME_OF_DAY_SPAN_ID = 'time-of-day'
+const SEGMENT_DURATION_SPAN_CLASS = 'segment-duration'
+const SEGMENT_REMAINIG_SPAN_ID = 'segment-remaining'
+const PART_REMAINIG_SPAN_ID = 'part-remaining'
+const SEGMENTS_DIV_ID = 'segments'
+const ENABLE_SYNCED_TICKS = true
+
+let activePlaylist = {}
+
+function handleActivePlaylist(data) {
+ activePlaylist = data
+}
+
+setInterval(() => {
+ const segmentRemainingEl = document.getElementById(SEGMENT_REMAINIG_SPAN_ID)
+ const partRemainingEl = document.getElementById(PART_REMAINIG_SPAN_ID)
+ const segmentEndTime = activePlaylist.currentSegment && activePlaylist.currentSegment.timing.projectedEndTime
+ const partEndTime = activePlaylist.currentPart && activePlaylist.currentPart.timing.projectedEndTime
+
+ const currentSegmentId = activePlaylist.currentPart && activePlaylist.currentPart.segmentId
+ const now = ENABLE_SYNCED_TICKS ? Math.floor(Date.now() / 1000) * 1000 : Date.now()
+ if (currentSegmentId && activePlaylist.currentPart) {
+ const currentSegmentEl = document.getElementById(activePlaylist.currentPart.segmentId)
+ if (currentSegmentEl) {
+ const durationEl = currentSegmentEl.querySelector('.' + SEGMENT_DURATION_SPAN_CLASS)
+ durationEl.textContent = formatMillisecondsToTime(segmentEndTime - now)
+ }
+ }
+ if (segmentEndTime) segmentRemainingEl.textContent = formatMillisecondsToTime(segmentEndTime - now)
+ if (partEndTime) partRemainingEl.textContent = formatMillisecondsToTime(Math.ceil(partEndTime / 1000) * 1000 - now)
+ updateClock()
+}, 100)
+
+function updateClock() {
+ const now = new Date()
+ const hours = now.getHours()
+ const minutes = now.getMinutes()
+ const seconds = now.getSeconds()
+ const formattedTime = formatMillisecondsToTime(hours * 3600000 + minutes * 60000 + seconds * 1000)
+
+ const clockElement = document.getElementById(TIME_OF_DAY_SPAN_ID)
+ if (clockElement) {
+ clockElement.textContent = formattedTime
+ }
+}
+
+function handleSegments(data) {
+ const targetDiv = document.getElementById(SEGMENTS_DIV_ID)
+
+ if (targetDiv) {
+ const existingUl = targetDiv.querySelector('ul')
+ if (existingUl) {
+ targetDiv.removeChild(existingUl)
+ }
+
+ const ul = document.createElement('ul')
+
+ data.segments.forEach((segment) => {
+ const li = document.createElement('li')
+ li.id = segment.id
+ const spanElement = document.createElement('span')
+ spanElement.classList = [SEGMENT_DURATION_SPAN_CLASS]
+ spanElement.textContent = formatMillisecondsToTime(
+ segment.timing.budgetDurationMs || segment.timing.expectedDurationMs
+ )
+ const textNodeAfter = document.createTextNode(' ' + segment.name)
+ li.appendChild(spanElement)
+ li.appendChild(textNodeAfter)
+ ul.appendChild(li)
+ })
+
+ targetDiv.appendChild(ul)
+ }
+}
+
+function formatMillisecondsToTime(milliseconds) {
+ const isNegative = milliseconds < 0
+ milliseconds = Math.abs(milliseconds)
+
+ const totalSeconds = Math.round(milliseconds / 1000)
+ const totalMinutes = Math.floor(totalSeconds / 60)
+ const totalHours = Math.floor(totalMinutes / 60)
+
+ const formattedHours = String(totalHours).padStart(2, '0')
+ const formattedMinutes = String(totalMinutes % 60).padStart(2, '0')
+ const formattedSeconds = String(totalSeconds % 60).padStart(2, '0')
+
+ return `${isNegative ? '+' : ''}${formattedHours}:${formattedMinutes}:${formattedSeconds}`
+}
diff --git a/packages/live-status-gateway/src/collections/adLibActions.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts
similarity index 89%
rename from packages/live-status-gateway/src/collections/adLibActions.ts
rename to packages/live-status-gateway/src/collections/adLibActionsHandler.ts
index f710687dcc..7148ba2b18 100644
--- a/packages/live-status-gateway/src/collections/adLibActions.ts
+++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts
@@ -5,13 +5,13 @@ import { CoreConnection } from '@sofie-automation/server-core-integration'
import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction'
import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance'
import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString'
-import { PartInstanceName } from './partInstances'
import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections'
import _ = require('underscore')
+import { SelectedPartInstances } from './partInstancesHandler'
export class AdLibActionsHandler
extends CollectionBase
- implements Collection, CollectionObserver