From 288c26890cc57c0cdb36057dafa69a17758228eb Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Mar 2026 03:14:09 +0000 Subject: [PATCH 01/13] Add T-Timers to LSG API (expose underlying data model) This adds basic T-Timer support to the Live Status Gateway API by exposing the underlying data model structure directly, rather than denormalizing/reshaping it. Structure: - TimerMode: Configuration (countdown/freeRun/timeOfDay) - TimerState: Runtime data (paused/running, zeroTime/duration) - TTimerStatus: Combines mode, state, label, configured flag This keeps the transformation layer thin - the API just passes through the well-designed internal structures. The types match the corelib TimerState and RundownTTimerMode types exactly. Benefits over previous denormalized approach: - 90% less transformation code - Single source of truth for TimerState structure - Easier to add timeOfDay mode support later - Self-documenting (reuses existing inline docs) Schema generation: Added tTimers example to activePlaylistEvent-example.yaml to fix AsyncAPI parser validation. The parser requires all required fields to be present in examples. Tests: Updated activePlaylist.spec.ts to include tTimers in all cases --- .../activePlaylistEvent-example.yaml | 24 +++ .../activePlaylistEvent.yaml | 9 +- .../api/components/tTimers/tTimerIndex.yaml | 6 + .../api/components/tTimers/tTimerStatus.yaml | 30 ++++ .../api/components/tTimers/timerMode.yaml | 57 +++++++ .../api/components/tTimers/timerState.yaml | 47 +++++ .../src/generated/asyncapi.yaml | 160 ++++++++++++++++++ .../src/generated/schema.ts | 111 ++++++++++++ .../topics/__tests__/activePlaylist.spec.ts | 15 ++ .../src/topics/activePlaylistTopic.ts | 49 ++++++ 10 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml create mode 100644 packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml create mode 100644 packages/live-status-gateway-api/api/components/tTimers/timerMode.yaml create mode 100644 packages/live-status-gateway-api/api/components/tTimers/timerState.yaml diff --git a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml index d09a8222ef..fad8d7d1fc 100644 --- a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml +++ b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml @@ -15,3 +15,27 @@ timing: $ref: '../../timing/activePlaylistTiming/activePlaylistTiming-example.yaml' quickLoop: $ref: '../../quickLoop/activePlaylistQuickLoop/activePlaylistQuickLoop-example.yaml' +tTimers: + - index: 1 + label: 'Segment Timer' + configured: true + mode: + type: countdown + duration: 120000 + stopAtZero: true + state: + paused: false + zeroTime: 1706371920000 + - index: 2 + label: 'Show Timer' + configured: true + mode: + type: freeRun + state: + paused: false + zeroTime: 1706371800000 + - index: 3 + label: '' + configured: false + mode: null + state: null diff --git a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml index 21e7277c14..fbaf22ffe4 100644 --- a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml +++ b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml @@ -46,7 +46,14 @@ $defs: $ref: '../../timing/activePlaylistTiming/activePlaylistTiming.yaml#/$defs/activePlaylistTiming' quickLoop: $ref: '../../quickLoop/activePlaylistQuickLoop/activePlaylistQuickLoop.yaml#/$defs/activePlaylistQuickLoop' - required: [event, id, externalId, name, rundownIds, currentPart, currentSegment, nextPart, timing] + tTimers: + description: Status of the 3 T-timers in the playlist + type: array + items: + $ref: '../../tTimers/tTimerStatus.yaml#/$defs/tTimerStatus' + minItems: 3 + maxItems: 3 + required: [event, id, externalId, name, rundownIds, currentPart, currentSegment, nextPart, timing, tTimers] additionalProperties: false examples: - $ref: './activePlaylistEvent-example.yaml' diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml new file mode 100644 index 0000000000..aab940cecd --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml @@ -0,0 +1,6 @@ +$defs: + tTimerIndex: + type: integer + title: TTimerIndex + description: Timer index (1-3). The playlist always has 3 T-timer slots. + enum: [1, 2, 3] diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml new file mode 100644 index 0000000000..4c8fd67e00 --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml @@ -0,0 +1,30 @@ +$defs: + tTimerStatus: + type: object + title: TTimerStatus + description: Status of a single T-timer in the playlist + properties: + index: + $ref: './tTimerIndex.yaml#/$defs/tTimerIndex' + label: + type: string + description: User-defined label for the timer + configured: + type: boolean + description: Whether the timer has been configured (mode and state are not null) + mode: + description: >- + Timer configuration/mode defining the timer's behavior. + Null if not configured. + oneOf: + - type: 'null' + - $ref: './timerMode.yaml#/$defs/timerMode' + state: + description: >- + Current runtime state of the timer. + Null if not configured. + oneOf: + - type: 'null' + - $ref: './timerState.yaml#/$defs/timerState' + required: [index, label, configured, mode, state] + additionalProperties: false diff --git a/packages/live-status-gateway-api/api/components/tTimers/timerMode.yaml b/packages/live-status-gateway-api/api/components/tTimers/timerMode.yaml new file mode 100644 index 0000000000..fbd30ca48d --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/timerMode.yaml @@ -0,0 +1,57 @@ +$defs: + timerModeCountdown: + type: object + title: TimerModeCountdown + description: Countdown timer mode - counts down from a duration to zero + properties: + type: + type: string + const: countdown + duration: + type: number + description: The original countdown duration in milliseconds (used for reset) + stopAtZero: + type: boolean + description: Whether timer stops at zero or continues into negative values + required: [type, duration, stopAtZero] + additionalProperties: false + + timerModeFreeRun: + type: object + title: TimerModeFreeRun + description: Free-running timer mode - counts up from start time + properties: + type: + type: string + const: freeRun + required: [type] + additionalProperties: false + + timerModeTimeOfDay: + type: object + title: TimerModeTimeOfDay + description: Time-of-day timer mode - counts down/up to a specific time + properties: + type: + type: string + const: timeOfDay + targetRaw: + description: >- + The raw target string as provided when setting the timer + (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + oneOf: + - type: string + - type: number + stopAtZero: + type: boolean + description: Whether timer stops at zero or continues into negative values + required: [type, targetRaw, stopAtZero] + additionalProperties: false + + timerMode: + title: TimerMode + description: Configuration/mode of a T-timer (defines behavior type) + oneOf: + - $ref: '#/$defs/timerModeCountdown' + - $ref: '#/$defs/timerModeFreeRun' + - $ref: '#/$defs/timerModeTimeOfDay' diff --git a/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml b/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml new file mode 100644 index 0000000000..f2e369bf4f --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml @@ -0,0 +1,47 @@ +$defs: + timerStateRunning: + type: object + title: TimerStateRunning + description: Timer state when running (progressing with real time) + properties: + paused: + type: boolean + const: false + description: Whether the timer is paused + zeroTime: + type: number + description: >- + Unix timestamp (ms) when the timer reaches/reached zero. + For countdown timers, this is when time runs out. + For free-run timers, this is when the timer started. + Client calculates current value relative to this timestamp. + required: [paused, zeroTime] + additionalProperties: false + + timerStatePaused: + type: object + title: TimerStatePaused + description: Timer state when paused (frozen at a specific duration) + properties: + paused: + type: boolean + const: true + description: Whether the timer is paused + duration: + type: number + description: >- + Frozen duration value in milliseconds. + For countdown timers, this is remaining time. + For free-run timers, this is elapsed time. + required: [paused, duration] + additionalProperties: false + + timerState: + title: TimerState + description: >- + Runtime state of a timer, optimized for efficient client rendering. + When running, the client calculates current time from zeroTime. + When paused, the duration is frozen and sent directly. + oneOf: + - $ref: '#/$defs/timerStateRunning' + - $ref: '#/$defs/timerStatePaused' diff --git a/packages/live-status-gateway-api/src/generated/asyncapi.yaml b/packages/live-status-gateway-api/src/generated/asyncapi.yaml index 71d5675007..0b405e6aac 100644 --- a/packages/live-status-gateway-api/src/generated/asyncapi.yaml +++ b/packages/live-status-gateway-api/src/generated/asyncapi.yaml @@ -739,6 +739,141 @@ channels: running: true start: *a23 end: *a23 + tTimers: + description: Status of the 3 T-timers in the playlist + type: array + items: + type: object + title: TTimerStatus + description: Status of a single T-timer in the playlist + properties: + index: + type: integer + title: TTimerIndex + description: Timer index (1-3). The playlist always has 3 T-timer slots. + enum: + - 1 + - 2 + - 3 + label: + type: string + description: User-defined label for the timer + configured: + type: boolean + description: Whether the timer has been configured (mode and state are not null) + mode: + description: Timer configuration/mode defining the timer's behavior. Null if not + configured. + oneOf: + - type: "null" + - title: TimerMode + description: Configuration/mode of a T-timer (defines behavior type) + oneOf: + - type: object + title: TimerModeCountdown + description: Countdown timer mode - counts down from a duration to zero + properties: + type: + type: string + const: countdown + duration: + type: number + description: The original countdown duration in milliseconds (used for reset) + stopAtZero: + type: boolean + description: Whether timer stops at zero or continues into negative values + required: + - type + - duration + - stopAtZero + additionalProperties: false + - type: object + title: TimerModeFreeRun + description: Free-running timer mode - counts up from start time + properties: + type: + type: string + const: freeRun + required: + - type + additionalProperties: false + - type: object + title: TimerModeTimeOfDay + description: Time-of-day timer mode - counts down/up to a specific time + properties: + type: + type: string + const: timeOfDay + targetRaw: + description: The raw target string as provided when setting the timer (e.g. + "14:30", "2023-12-31T23:59:59Z", or a + timestamp number) + oneOf: + - type: string + - type: number + stopAtZero: + type: boolean + description: Whether timer stops at zero or continues into negative values + required: + - type + - targetRaw + - stopAtZero + additionalProperties: false + state: + description: Current runtime state of the timer. Null if not configured. + oneOf: + - type: "null" + - title: TimerState + description: Runtime state of a timer, optimized for efficient client rendering. + When running, the client calculates current time + from zeroTime. When paused, the duration is frozen + and sent directly. + oneOf: + - type: object + title: TimerStateRunning + description: Timer state when running (progressing with real time) + properties: + paused: + type: boolean + const: false + description: Whether the timer is paused + zeroTime: + type: number + description: Unix timestamp (ms) when the timer reaches/reached zero. For + countdown timers, this is when time runs + out. For free-run timers, this is when the + timer started. Client calculates current + value relative to this timestamp. + required: + - paused + - zeroTime + additionalProperties: false + - type: object + title: TimerStatePaused + description: Timer state when paused (frozen at a specific duration) + properties: + paused: + type: boolean + const: true + description: Whether the timer is paused + duration: + type: number + description: Frozen duration value in milliseconds. For countdown timers, this + is remaining time. For free-run timers, + this is elapsed time. + required: + - paused + - duration + additionalProperties: false + required: + - index + - label + - configured + - mode + - state + additionalProperties: false + minItems: 3 + maxItems: 3 required: - event - id @@ -749,6 +884,7 @@ channels: - currentSegment - nextPart - timing + - tTimers additionalProperties: false examples: - &a29 @@ -765,6 +901,30 @@ channels: category: Evening News timing: *a27 quickLoop: *a28 + tTimers: + - index: 1 + label: Segment Timer + configured: true + mode: + type: countdown + duration: 120000 + stopAtZero: true + state: + paused: false + zeroTime: 1706371920000 + - index: 2 + label: Show Timer + configured: true + mode: + type: freeRun + state: + paused: false + zeroTime: 1706371800000 + - index: 3 + label: "" + configured: false + mode: null + state: null examples: - payload: *a29 activePieces: diff --git a/packages/live-status-gateway-api/src/generated/schema.ts b/packages/live-status-gateway-api/src/generated/schema.ts index dca47bd84f..57d603da77 100644 --- a/packages/live-status-gateway-api/src/generated/schema.ts +++ b/packages/live-status-gateway-api/src/generated/schema.ts @@ -190,6 +190,10 @@ interface ActivePlaylistEvent { * Information about the current quickLoop, if any */ quickLoop?: ActivePlaylistQuickLoop + /** + * Status of the 3 T-timers in the playlist + */ + tTimers: TTimerStatus[] } interface CurrentPartStatus { @@ -477,6 +481,106 @@ enum QuickLoopMarkerType { PART = 'part', } +/** + * Status of a single T-timer in the playlist + */ +interface TTimerStatus { + /** + * Timer index (1-3). The playlist always has 3 T-timer slots. + */ + index: TTimerIndex + /** + * User-defined label for the timer + */ + label: string + /** + * Whether the timer has been configured (mode and state are not null) + */ + configured: boolean + /** + * Timer configuration/mode defining the timer's behavior. Null if not configured. + */ + mode: TimerModeCountdown | TimerModeFreeRun | TimerModeTimeOfDay | null + /** + * Current runtime state of the timer. Null if not configured. + */ + state: TimerStateRunning | TimerStatePaused | null +} + +/** + * Timer index (1-3). The playlist always has 3 T-timer slots. + */ +enum TTimerIndex { + NUMBER_1 = 1, + NUMBER_2 = 2, + NUMBER_3 = 3, +} + +/** + * Countdown timer mode - counts down from a duration to zero + */ +interface TimerModeCountdown { + type: 'countdown' + /** + * The original countdown duration in milliseconds (used for reset) + */ + duration: number + /** + * Whether timer stops at zero or continues into negative values + */ + stopAtZero: boolean +} + +/** + * Free-running timer mode - counts up from start time + */ +interface TimerModeFreeRun { + type: 'freeRun' +} + +/** + * Time-of-day timer mode - counts down/up to a specific time + */ +interface TimerModeTimeOfDay { + type: 'timeOfDay' + /** + * The raw target string as provided when setting the timer (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + targetRaw: string | number + /** + * Whether timer stops at zero or continues into negative values + */ + stopAtZero: boolean +} + +/** + * Timer state when running (progressing with real time) + */ +interface TimerStateRunning { + /** + * Whether the timer is paused + */ + paused: boolean + /** + * Unix timestamp (ms) when the timer reaches/reached zero. For countdown timers, this is when time runs out. For free-run timers, this is when the timer started. Client calculates current value relative to this timestamp. + */ + zeroTime: number +} + +/** + * Timer state when paused (frozen at a specific duration) + */ +interface TimerStatePaused { + /** + * Whether the timer is paused + */ + paused: boolean + /** + * Frozen duration value in milliseconds. For countdown timers, this is remaining time. For free-run timers, this is elapsed time. + */ + duration: number +} + interface ActivePiecesEvent { event: 'activePieces' /** @@ -948,6 +1052,13 @@ export { ActivePlaylistQuickLoop, QuickLoopMarker, QuickLoopMarkerType, + TTimerStatus, + TTimerIndex, + TimerModeCountdown, + TimerModeFreeRun, + TimerModeTimeOfDay, + TimerStateRunning, + TimerStatePaused, ActivePiecesEvent, SegmentsEvent, Segment, diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index 702ee867c6..0456f8a170 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -63,6 +63,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: 1, label: '', configured: false, mode: null, state: null }, + { index: 2, label: '', configured: false, mode: null, state: null }, + { index: 3, label: '', configured: false, mode: null, state: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method @@ -164,6 +169,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: 1, label: '', configured: false, mode: null, state: null }, + { index: 2, label: '', configured: false, mode: null, state: null }, + { index: 3, label: '', configured: false, mode: null, state: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method @@ -270,6 +280,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: 1, label: '', configured: false, mode: null, state: null }, + { index: 2, label: '', configured: false, mode: null, state: null }, + { index: 3, label: '', configured: false, mode: null, state: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index aa4b009329..671bfd1eaf 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -5,6 +5,7 @@ import { DBRundownPlaylist, QuickLoopMarker, QuickLoopMarkerType, + RundownTTimer, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { assertNever, literal } from '@sofie-automation/shared-lib/dist/lib/lib' @@ -30,6 +31,10 @@ import { ActivePlaylistQuickLoop, QuickLoopMarker as QuickLoopMarkerStatus, QuickLoopMarkerType as QuickLoopMarkerStatusType, + TTimerStatus, + TTimerIndex, + TimerMode, + TimerState, } from '@sofie-automation/live-status-gateway-api' import { CollectionHandlers } from '../liveStatusServer.js' @@ -51,6 +56,7 @@ const PLAYLIST_KEYS = [ 'timing', 'startedPlayback', 'quickLoop', + 'tTimers', ] as const type Playlist = PickKeys @@ -172,6 +178,7 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket ? this._activePlaylist.timing.expectedEnd : undefined, }, + tTimers: this.transformTTimers(this._activePlaylist.tTimers), }) : literal>({ event: 'activePlaylist', @@ -188,6 +195,7 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket timing: { timingMode: ActivePlaylistTimingMode.NONE, }, + tTimers: this.transformTTimers(null), }) this.sendMessage(subscribers, message) @@ -251,6 +259,47 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket } } + private transformTTimers(tTimers: RundownTTimer[] | null | undefined): [TTimerStatus, TTimerStatus, TTimerStatus] { + // Always return exactly 3 timers + if (!tTimers || tTimers.length === 0) { + return [ + { index: 1 as TTimerIndex, label: '', configured: false, mode: null, state: null }, + { index: 2 as TTimerIndex, label: '', configured: false, mode: null, state: null }, + { index: 3 as TTimerIndex, label: '', configured: false, mode: null, state: null }, + ] + } + + return [this.transformTTimer(tTimers[0]), this.transformTTimer(tTimers[1]), this.transformTTimer(tTimers[2])] + } + + private transformTTimer(timer: RundownTTimer): TTimerStatus { + const index = timer.index as TTimerIndex + + if (!timer.mode || !timer.state) { + return { + index, + label: timer.label, + configured: false, + mode: null, + state: null, + } + } + + // Transform mode - directly pass through + const mode: TimerMode = timer.mode as TimerMode + + // Transform state - directly pass through + const state: TimerState = timer.state as TimerState + + return { + index, + label: timer.label, + configured: true, + mode, + state, + } + } + private isDataInconsistent() { return ( this._currentPartInstance?._id !== this._activePlaylist?.currentPartInfo?.partInstanceId || From 63ed3a4337388ef2fbcc7adef18a762329936f65 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Mar 2026 09:34:15 +0000 Subject: [PATCH 02/13] Add projected state, pauseTime, and anchorPartId to T-Timers LSG API - Add pauseTime field to TimerState (both running and paused states) - Running: timestamp for automatic pause when part ends - Paused: typically null when already paused - Add projected field to TTimerStatus (TimerState | null) - Enables calculation of over/under diff relative to anchor - Running state means progressing towards anchor - Paused state means pushing/delaying anchor - Add anchorPartId field to TTimerStatus (string | null) - Target Part ID that timer is counting towards - Update example with realistic projected/pauseTime/anchorPartId values - Fix imports: use specific timer mode/state types and define local unions - Update all test cases to include new fields (projected, anchorPartId) - All tests passing (17/17) --- .../activePlaylistEvent-example.yaml | 11 ++ .../api/components/tTimers/tTimerStatus.yaml | 15 ++ .../api/components/tTimers/timerState.yaml | 14 ++ .../src/generated/asyncapi.yaml | 179 +++++++++++------- .../src/generated/schema.ts | 16 ++ .../topics/__tests__/activePlaylist.spec.ts | 90 ++++++++- .../src/topics/activePlaylistTopic.ts | 51 ++++- 7 files changed, 292 insertions(+), 84 deletions(-) diff --git a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml index fad8d7d1fc..58c596432a 100644 --- a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml +++ b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml @@ -26,6 +26,12 @@ tTimers: state: paused: false zeroTime: 1706371920000 + pauseTime: 1706371900000 + projected: + paused: false + zeroTime: 1706371925000 + pauseTime: null + anchorPartId: 'part_break_1' - index: 2 label: 'Show Timer' configured: true @@ -34,8 +40,13 @@ tTimers: state: paused: false zeroTime: 1706371800000 + pauseTime: null + projected: null + anchorPartId: null - index: 3 label: '' configured: false mode: null state: null + projected: null + anchorPartId: null diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml index 4c8fd67e00..25fc49b491 100644 --- a/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus.yaml @@ -26,5 +26,20 @@ $defs: oneOf: - type: 'null' - $ref: './timerState.yaml#/$defs/timerState' + projected: + description: >- + Projected timing for when we expect to reach an anchor part. + Used to calculate over/under diff. Has the same structure as state. + Running means progressing towards anchor, paused means pushing/delaying anchor. + oneOf: + - type: 'null' + - $ref: './timerState.yaml#/$defs/timerState' + anchorPartId: + description: >- + The ID of the target Part that this timer is counting towards (the "timing anchor"). + Optional - null if no anchor is set. + oneOf: + - type: string + - type: 'null' required: [index, label, configured, mode, state] additionalProperties: false diff --git a/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml b/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml index f2e369bf4f..10f3477ae9 100644 --- a/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml +++ b/packages/live-status-gateway-api/api/components/tTimers/timerState.yaml @@ -15,6 +15,13 @@ $defs: For countdown timers, this is when time runs out. For free-run timers, this is when the timer started. Client calculates current value relative to this timestamp. + pauseTime: + description: >- + Optional timestamp when the timer should automatically pause + (e.g., when current part ends and overrun begins). + oneOf: + - type: number + - type: 'null' required: [paused, zeroTime] additionalProperties: false @@ -33,6 +40,13 @@ $defs: Frozen duration value in milliseconds. For countdown timers, this is remaining time. For free-run timers, this is elapsed time. + pauseTime: + description: >- + Optional timestamp when the timer should pause. + Typically null when already paused. + oneOf: + - type: number + - type: 'null' required: [paused, duration] additionalProperties: false diff --git a/packages/live-status-gateway-api/src/generated/asyncapi.yaml b/packages/live-status-gateway-api/src/generated/asyncapi.yaml index 0b405e6aac..1d21b524b4 100644 --- a/packages/live-status-gateway-api/src/generated/asyncapi.yaml +++ b/packages/live-status-gateway-api/src/generated/asyncapi.yaml @@ -401,7 +401,7 @@ channels: pieces: description: All pieces in this part type: array - items: &a30 + items: &a31 type: object title: PieceStatus properties: @@ -475,7 +475,7 @@ channels: - segmentId - pieces examples: - - &a26 + - &a27 segmentId: n1mOVd5_K5tt4sfk6HYfTuwumGQ_ pieces: &a15 - *a13 @@ -523,7 +523,7 @@ channels: required: - timing examples: - - &a24 + - &a25 timing: *a14 segmentId: n1mOVd5_K5tt4sfk6HYfTuwumGQ_ pieces: *a15 @@ -537,7 +537,7 @@ channels: - type: object title: CurrentSegment allOf: - - &a32 + - &a33 title: SegmentBase type: object properties: @@ -556,7 +556,7 @@ channels: title: CurrentSegmentTiming description: Timing information about the current segment allOf: - - &a33 + - &a34 type: object title: SegmentTiming properties: @@ -632,7 +632,7 @@ channels: - timing - parts examples: - - &a25 + - &a26 timing: *a19 parts: - *a20 @@ -680,7 +680,7 @@ channels: - timingMode additionalProperties: false examples: - - &a27 + - &a28 timingMode: forward-time expectedStart: 1728895750727 expectedDurationMs: 180000 @@ -734,7 +734,7 @@ channels: - locked - running examples: - - &a28 + - &a29 locked: false running: true start: *a23 @@ -823,7 +823,8 @@ channels: description: Current runtime state of the timer. Null if not configured. oneOf: - type: "null" - - title: TimerState + - &a24 + title: TimerState description: Runtime state of a timer, optimized for efficient client rendering. When running, the client calculates current time from zeroTime. When paused, the duration is frozen @@ -844,6 +845,13 @@ channels: out. For free-run timers, this is when the timer started. Client calculates current value relative to this timestamp. + pauseTime: + description: Optional timestamp when the timer should automatically pause (e.g., + when current part ends and overrun + begins). + oneOf: + - type: number + - type: "null" required: - paused - zeroTime @@ -861,10 +869,30 @@ channels: description: Frozen duration value in milliseconds. For countdown timers, this is remaining time. For free-run timers, this is elapsed time. + pauseTime: + description: Optional timestamp when the timer should pause. Typically null when + already paused. + oneOf: + - type: number + - type: "null" required: - paused - duration additionalProperties: false + projected: + description: Projected timing for when we expect to reach an anchor part. Used + to calculate over/under diff. Has the same structure + as state. Running means progressing towards anchor, + paused means pushing/delaying anchor. + oneOf: + - type: "null" + - *a24 + anchorPartId: + description: The ID of the target Part that this timer is counting towards (the + "timing anchor"). Optional - null if no anchor is set. + oneOf: + - type: string + - type: "null" required: - index - label @@ -887,20 +915,20 @@ channels: - tTimers additionalProperties: false examples: - - &a29 + - &a30 event: activePlaylist id: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ externalId: 1ZIYVYL1aEkNEJbeGsmRXr5s8wtkyxfPRjNSTxZfcoEI name: Playlist 0 rundownIds: - y9HauyWkcxQS3XaAOsW40BRLLsI_ - currentPart: *a24 - currentSegment: *a25 - nextPart: *a26 + currentPart: *a25 + currentSegment: *a26 + nextPart: *a27 publicData: category: Evening News - timing: *a27 - quickLoop: *a28 + timing: *a28 + quickLoop: *a29 tTimers: - index: 1 label: Segment Timer @@ -912,6 +940,12 @@ channels: state: paused: false zeroTime: 1706371920000 + pauseTime: 1706371900000 + projected: + paused: false + zeroTime: 1706371925000 + pauseTime: null + anchorPartId: part_break_1 - index: 2 label: Show Timer configured: true @@ -920,13 +954,18 @@ channels: state: paused: false zeroTime: 1706371800000 + pauseTime: null + projected: null + anchorPartId: null - index: 3 label: "" configured: false mode: null state: null + projected: null + anchorPartId: null examples: - - payload: *a29 + - payload: *a30 activePieces: description: Topic for active pieces updates subscribe: @@ -951,20 +990,20 @@ channels: activePieces: description: Pieces that are currently active (on air) type: array - items: *a30 + items: *a31 required: - event - rundownPlaylistId - activePieces additionalProperties: false examples: - - &a31 + - &a32 event: activePieces rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ activePieces: - *a13 examples: - - payload: *a31 + - payload: *a32 segments: description: Topic for Segment updates subscribe: @@ -993,7 +1032,7 @@ channels: type: object title: Segment allOf: - - *a32 + - *a33 - type: object title: Segment properties: @@ -1007,7 +1046,7 @@ channels: name: description: Name of the segment type: string - timing: *a33 + timing: *a34 publicData: description: Optional arbitrary data required: @@ -1020,7 +1059,7 @@ channels: - name - timing examples: - - &a34 + - &a35 identifier: Segment 0 identifier rundownId: y9HauyWkcxQS3XaAOsW40BRLLsI_ name: Segment 0 @@ -1036,13 +1075,13 @@ channels: - rundownPlaylistId - segments examples: - - &a35 + - &a36 event: segments rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ segments: - - *a34 + - *a35 examples: - - payload: *a35 + - payload: *a36 adLibs: description: Topic for AdLibs updates subscribe: @@ -1072,7 +1111,7 @@ channels: items: title: AdLibStatus allOf: - - &a40 + - &a41 title: AdLibBase type: object properties: @@ -1107,7 +1146,7 @@ channels: - label additionalProperties: false examples: - - &a36 + - &a37 name: pvw label: Preview tags: @@ -1127,15 +1166,15 @@ channels: - sourceLayer - actionType examples: - - &a41 + - &a42 id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: &a37 - - *a36 - tags: &a38 + actionType: &a38 + - *a37 + tags: &a39 - music_video - publicData: &a39 + publicData: &a40 fileName: MV000123.mxf optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video @@ -1167,15 +1206,15 @@ channels: - segmentId - partId examples: - - &a42 + - &a43 segmentId: HsD8_QwE1ZmR5vN3XcK_Ab7y partId: JkL3_OpR6WxT1bF8Vq2_Zy9u id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: *a37 - tags: *a38 - publicData: *a39 + actionType: *a38 + tags: *a39 + publicData: *a40 optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video Clip","type":"object","properties":{"type":"adlib_action_video_clip","label":{"type":"string"},"clipId":{"type":"string"},"vo":{"type":"boolean"},"target":{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Object @@ -1199,9 +1238,9 @@ channels: items: title: GlobalAdLibStatus allOf: - - *a40 - examples: - *a41 + examples: + - *a42 required: - event - rundownPlaylistId @@ -1209,15 +1248,15 @@ channels: - globalAdLibs additionalProperties: false examples: - - &a43 + - &a44 event: adLibs rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ adLibs: - - *a42 + - *a43 globalAdLibs: - - *a41 + - *a42 examples: - - payload: *a43 + - payload: *a44 packages: description: Packages topic for websocket subscriptions. Packages are assets that need to be prepared by Sofie Package Manager or third-party systems @@ -1325,7 +1364,7 @@ channels: - pieceOrAdLibId additionalProperties: false examples: - - &a44 + - &a45 packageName: MV000123.mxf status: ok rundownId: y9HauyWkcxQS3XaAOsW40BRLLsI_ @@ -1343,7 +1382,7 @@ channels: - event: packages rundownPlaylistId: y9HauyWkcxQS3XaAOsW40BRLLsI_ packages: - - *a44 + - *a45 buckets: description: Buckets schema for websocket subscriptions subscribe: @@ -1379,7 +1418,7 @@ channels: items: title: BucketAdLibStatus allOf: - - *a40 + - *a41 - type: object title: BucketAdLibStatus properties: @@ -1390,14 +1429,14 @@ channels: required: - externalId examples: - - &a45 + - &a46 externalId: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: *a37 - tags: *a38 - publicData: *a39 + actionType: *a38 + tags: *a39 + publicData: *a40 optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video Clip","type":"object","properties":{"type":"adlib_action_video_clip","label":{"type":"string"},"clipId":{"type":"string"},"vo":{"type":"boolean"},"target":{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Object @@ -1421,22 +1460,22 @@ channels: - adLibs additionalProperties: false examples: - - &a46 + - &a47 id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: My Bucket adLibs: - - *a45 + - *a46 required: - event - buckets additionalProperties: false examples: - - &a47 + - &a48 event: buckets buckets: - - *a46 + - *a47 examples: - - payload: *a47 + - payload: *a48 notifications: description: Notifications topic for websocket subscriptions. subscribe: @@ -1500,7 +1539,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: &a48 + enum: &a49 - rundown - playlist - partInstance @@ -1512,7 +1551,7 @@ channels: type: string additionalProperties: false examples: - - &a49 + - &a50 type: rundown studioId: studio01 rundownId: rd123 @@ -1528,14 +1567,14 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a49 studioId: type: string playlistId: type: string additionalProperties: false examples: - - &a50 + - &a51 type: playlist studioId: studio01 playlistId: pl456 @@ -1552,7 +1591,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a49 studioId: type: string rundownId: @@ -1561,7 +1600,7 @@ channels: type: string additionalProperties: false examples: - - &a51 + - &a52 type: partInstance studioId: studio01 rundownId: rd123 @@ -1580,7 +1619,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a49 studioId: type: string rundownId: @@ -1591,7 +1630,7 @@ channels: type: string additionalProperties: false examples: - - &a52 + - &a53 type: pieceInstance studioId: studio01 rundownId: rd123 @@ -1607,17 +1646,17 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a49 additionalProperties: false examples: - - &a53 + - &a54 type: unknown examples: - - *a49 - *a50 - *a51 - *a52 - *a53 + - *a54 created: type: integer format: int64 @@ -1628,11 +1667,11 @@ channels: description: Unix timestamp of last modification additionalProperties: false examples: - - &a54 + - &a55 _id: notif123 severity: error message: disk.space.low - relatedTo: *a52 + relatedTo: *a53 created: 1694784932 modified: 1694784950 required: @@ -1640,9 +1679,9 @@ channels: - activeNotifications additionalProperties: false examples: - - &a55 + - &a56 event: notifications activeNotifications: - - *a54 + - *a55 examples: - - payload: *a55 + - payload: *a56 diff --git a/packages/live-status-gateway-api/src/generated/schema.ts b/packages/live-status-gateway-api/src/generated/schema.ts index 57d603da77..4c0ea4bdc7 100644 --- a/packages/live-status-gateway-api/src/generated/schema.ts +++ b/packages/live-status-gateway-api/src/generated/schema.ts @@ -505,6 +505,14 @@ interface TTimerStatus { * Current runtime state of the timer. Null if not configured. */ state: TimerStateRunning | TimerStatePaused | null + /** + * Projected timing for when we expect to reach an anchor part. Used to calculate over/under diff. Has the same structure as state. Running means progressing towards anchor, paused means pushing/delaying anchor. + */ + projected?: TimerStateRunning | TimerStatePaused | null + /** + * The ID of the target Part that this timer is counting towards (the "timing anchor"). Optional - null if no anchor is set. + */ + anchorPartId?: string | null } /** @@ -565,6 +573,10 @@ interface TimerStateRunning { * Unix timestamp (ms) when the timer reaches/reached zero. For countdown timers, this is when time runs out. For free-run timers, this is when the timer started. Client calculates current value relative to this timestamp. */ zeroTime: number + /** + * Optional timestamp when the timer should automatically pause (e.g., when current part ends and overrun begins). + */ + pauseTime?: number | null } /** @@ -579,6 +591,10 @@ interface TimerStatePaused { * Frozen duration value in milliseconds. For countdown timers, this is remaining time. For free-run timers, this is elapsed time. */ duration: number + /** + * Optional timestamp when the timer should pause. Typically null when already paused. + */ + pauseTime?: number | null } interface ActivePiecesEvent { diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index 0456f8a170..b14b46c1e9 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -64,9 +64,33 @@ describe('ActivePlaylistTopic', () => { }, quickLoop: undefined, tTimers: [ - { index: 1, label: '', configured: false, mode: null, state: null }, - { index: 2, label: '', configured: false, mode: null, state: null }, - { index: 3, label: '', configured: false, mode: null, state: null }, + { + index: 1, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 2, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 3, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, ], } @@ -170,9 +194,33 @@ describe('ActivePlaylistTopic', () => { }, quickLoop: undefined, tTimers: [ - { index: 1, label: '', configured: false, mode: null, state: null }, - { index: 2, label: '', configured: false, mode: null, state: null }, - { index: 3, label: '', configured: false, mode: null, state: null }, + { + index: 1, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 2, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 3, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, ], } @@ -281,9 +329,33 @@ describe('ActivePlaylistTopic', () => { }, quickLoop: undefined, tTimers: [ - { index: 1, label: '', configured: false, mode: null, state: null }, - { index: 2, label: '', configured: false, mode: null, state: null }, - { index: 3, label: '', configured: false, mode: null, state: null }, + { + index: 1, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 2, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 3, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, ], } diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 671bfd1eaf..131a98bc87 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -33,14 +33,21 @@ import { QuickLoopMarkerType as QuickLoopMarkerStatusType, TTimerStatus, TTimerIndex, - TimerMode, - TimerState, + TimerModeCountdown, + TimerModeFreeRun, + TimerModeTimeOfDay, + TimerStateRunning, + TimerStatePaused, } from '@sofie-automation/live-status-gateway-api' import { CollectionHandlers } from '../liveStatusServer.js' import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' import { Complete, PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' +// Union types for timer modes and states (not exported from API package) +type TimerMode = TimerModeCountdown | TimerModeFreeRun | TimerModeTimeOfDay +type TimerState = TimerStateRunning | TimerStatePaused + const THROTTLE_PERIOD_MS = 100 const PLAYLIST_KEYS = [ @@ -263,9 +270,33 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket // Always return exactly 3 timers if (!tTimers || tTimers.length === 0) { return [ - { index: 1 as TTimerIndex, label: '', configured: false, mode: null, state: null }, - { index: 2 as TTimerIndex, label: '', configured: false, mode: null, state: null }, - { index: 3 as TTimerIndex, label: '', configured: false, mode: null, state: null }, + { + index: 1 as TTimerIndex, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 2 as TTimerIndex, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, + { + index: 3 as TTimerIndex, + label: '', + configured: false, + mode: null, + state: null, + projected: null, + anchorPartId: null, + }, ] } @@ -282,6 +313,8 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket configured: false, mode: null, state: null, + projected: null, + anchorPartId: null, } } @@ -291,12 +324,20 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket // Transform state - directly pass through const state: TimerState = timer.state as TimerState + // Transform projected state - directly pass through + const projected: TimerState | null = timer.projectedState ? (timer.projectedState as TimerState) : null + + // Transform anchorPartId + const anchorPartId = timer.anchorPartId ? unprotectString(timer.anchorPartId) : null + return { index, label: timer.label, configured: true, mode, state, + projected, + anchorPartId, } } From f55d5b2c401b6b3d0aca7e7bdc4d8b1f848d77c1 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Mar 2026 11:08:50 +0000 Subject: [PATCH 03/13] Add T-Timers to LSG sample client --- .../sample-client/index.html | 4 + .../sample-client/script.js | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/packages/live-status-gateway/sample-client/index.html b/packages/live-status-gateway/sample-client/index.html index bcf2f8a303..b1771636cc 100644 --- a/packages/live-status-gateway/sample-client/index.html +++ b/packages/live-status-gateway/sample-client/index.html @@ -8,6 +8,10 @@
Time of day:
Segment remaining:
Part remaining:
+
+ T-Timers: +
+
Active Pieces:
diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js index a31abd9471..f655ff77d3 100644 --- a/packages/live-status-gateway/sample-client/script.js +++ b/packages/live-status-gateway/sample-client/script.js @@ -70,6 +70,7 @@ 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 T_TIMERS_DIV_ID = 't-timers' const ACTIVE_PIECES_SPAN_ID = 'active-pieces' const NEXT_PIECES_SPAN_ID = 'next-pieces' const SEGMENTS_DIV_ID = 'segments' @@ -86,6 +87,8 @@ function handleActivePlaylist(data) { '
  • ' + activePlaylist.nextPart.pieces.map((p) => `${p.name} [${p.tags || []}]`).join('
  • ') + '
    • ' + + handleTTimers(data.tTimers) } let activePieces = {} function handleActivePieces(data) { @@ -124,6 +127,7 @@ setInterval(() => { if (partEndTime) partRemainingEl.textContent = formatMillisecondsToTime(Math.ceil(partEndTime / 1000) * 1000 - now) updateClock() + updateTTimers(activePlaylist.tTimers) }, 100) function updateClock() { @@ -182,3 +186,96 @@ function formatMillisecondsToTime(milliseconds) { return `${isNegative ? '+' : ''}${formattedHours}:${formattedMinutes}:${formattedSeconds}` } + +function handleTTimers(tTimers) { + const tTimersDiv = document.getElementById(T_TIMERS_DIV_ID) + if (!tTimersDiv || !tTimers) return + + const ul = document.createElement('ul') + + tTimers.forEach((timer) => { + const li = document.createElement('li') + li.id = `t-timer-${timer.index}` + li.textContent = `Timer ${timer.index}:` + + const detailUl = document.createElement('ul') + + if (timer.configured) { + // Type + const typeLi = document.createElement('li') + typeLi.textContent = `Type: "${timer.mode.type}"` + detailUl.appendChild(typeLi) + + // Label + const labelLi = document.createElement('li') + labelLi.textContent = `Label: ${timer.label ? JSON.stringify(timer.label) : '(no label)'}` + detailUl.appendChild(labelLi) + + // Value + const valueLi = document.createElement('li') + valueLi.appendChild(document.createTextNode('Value: ')) + const valueSpan = document.createElement('span') + valueSpan.id = `t-timer-value-${timer.index}` + valueLi.appendChild(valueSpan) + detailUl.appendChild(valueLi) + + // Projected (if available) + if (timer.projected && timer.anchorPartId) { + const projectedLi = document.createElement('li') + projectedLi.id = `t-timer-projected-${timer.index}` + detailUl.appendChild(projectedLi) + } + } else { + // Show "Not set" for unconfigured timers + const notSetLi = document.createElement('li') + notSetLi.textContent = 'Not set' + detailUl.appendChild(notSetLi) + } + + li.appendChild(detailUl) + ul.appendChild(li) + }) + + tTimersDiv.innerHTML = '' + tTimersDiv.appendChild(ul) +} + +function updateTTimers(tTimers) { + if (!tTimers) return + + const now = ENABLE_SYNCED_TICKS ? Math.floor(Date.now() / 1000) * 1000 : Date.now() + + tTimers.forEach((timer) => { + if (!timer.configured) return + + const valueSpan = document.getElementById(`t-timer-value-${timer.index}`) + if (!valueSpan) return + + // Calculate current timer value + let currentTime + if (timer.state.paused) { + currentTime = timer.state.duration + } else { + currentTime = timer.state.zeroTime - now + } + + valueSpan.textContent = formatMillisecondsToTime(currentTime) + + // Update projected time if available + const projectedLi = document.getElementById(`t-timer-projected-${timer.index}`) + if (projectedLi && timer.projected) { + let projectedTime + if (timer.projected.paused) { + projectedTime = timer.projected.duration + } else { + projectedTime = timer.projected.zeroTime - now + } + + const diff = currentTime - projectedTime + const diffStr = formatMillisecondsToTime(Math.abs(diff)) + const status = diff > 0 ? 'under' : 'over' + + projectedLi.textContent = `Projected: ${formatMillisecondsToTime(projectedTime)} (${diffStr} ${status})` + } + }) +} From 14ce21ebdcd6d1b5a0194d9b4c95655c0ee1f66d Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Mar 2026 16:15:43 +0000 Subject: [PATCH 04/13] Support pauseTime in LSG example --- packages/live-status-gateway/sample-client/script.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js index f655ff77d3..353dfdf747 100644 --- a/packages/live-status-gateway/sample-client/script.js +++ b/packages/live-status-gateway/sample-client/script.js @@ -255,6 +255,9 @@ function updateTTimers(tTimers) { let currentTime if (timer.state.paused) { currentTime = timer.state.duration + } else if (timer.state.pauseTime && now >= timer.state.pauseTime) { + // Timer has reached its pauseTime - freeze at that moment + currentTime = timer.state.zeroTime - timer.state.pauseTime } else { currentTime = timer.state.zeroTime - now } @@ -267,6 +270,9 @@ function updateTTimers(tTimers) { let projectedTime if (timer.projected.paused) { projectedTime = timer.projected.duration + } else if (timer.projected.pauseTime && now >= timer.projected.pauseTime) { + // Projected timer has reached its pauseTime - freeze at that moment + projectedTime = timer.projected.zeroTime - timer.projected.pauseTime } else { projectedTime = timer.projected.zeroTime - now } From c4e7c4e6994423d98dd826eb9b8d21583b320caf Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 20 Mar 2026 11:13:25 +0000 Subject: [PATCH 05/13] Enhance LSG sample client T-Timer display with state debugging - Add formatTimestampToTimeOfDay() to display timestamps in HH:MM:SS format - Show timer state context: (paused), (pauseTime: HH:MM:SS), or (zeroTime: HH:MM:SS) - Factor in pauseTime when calculating timer display values to freeze countdown correctly - Apply same logic to both current and projected timer states --- .../sample-client/script.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js index 353dfdf747..47e9d38d81 100644 --- a/packages/live-status-gateway/sample-client/script.js +++ b/packages/live-status-gateway/sample-client/script.js @@ -187,6 +187,14 @@ function formatMillisecondsToTime(milliseconds) { return `${isNegative ? '+' : ''}${formattedHours}:${formattedMinutes}:${formattedSeconds}` } +function formatTimestampToTimeOfDay(timestamp) { + const date = new Date(timestamp) + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${hours}:${minutes}:${seconds}` +} + function handleTTimers(tTimers) { const tTimersDiv = document.getElementById(T_TIMERS_DIV_ID) if (!tTimersDiv || !tTimers) return @@ -255,33 +263,38 @@ function updateTTimers(tTimers) { let currentTime if (timer.state.paused) { currentTime = timer.state.duration + valueSpan.textContent = formatMillisecondsToTime(currentTime) + ' (paused)' } else if (timer.state.pauseTime && now >= timer.state.pauseTime) { // Timer has reached its pauseTime - freeze at that moment currentTime = timer.state.zeroTime - timer.state.pauseTime + valueSpan.textContent = formatMillisecondsToTime(currentTime) + ` (pauseTime: ${formatTimestampToTimeOfDay(timer.state.pauseTime)})` } else { currentTime = timer.state.zeroTime - now + valueSpan.textContent = formatMillisecondsToTime(currentTime) + ` (zeroTime: ${formatTimestampToTimeOfDay(timer.state.zeroTime)})` } - valueSpan.textContent = formatMillisecondsToTime(currentTime) - // Update projected time if available const projectedLi = document.getElementById(`t-timer-projected-${timer.index}`) if (projectedLi && timer.projected) { let projectedTime + let projectedInfo = '' if (timer.projected.paused) { projectedTime = timer.projected.duration + projectedInfo = ' (paused)' } else if (timer.projected.pauseTime && now >= timer.projected.pauseTime) { // Projected timer has reached its pauseTime - freeze at that moment projectedTime = timer.projected.zeroTime - timer.projected.pauseTime + projectedInfo = ` (pauseTime: ${formatTimestampToTimeOfDay(timer.projected.pauseTime)})` } else { projectedTime = timer.projected.zeroTime - now + projectedInfo = ` (zeroTime: ${formatTimestampToTimeOfDay(timer.projected.zeroTime)})` } const diff = currentTime - projectedTime const diffStr = formatMillisecondsToTime(Math.abs(diff)) const status = diff > 0 ? 'under' : 'over' - projectedLi.textContent = `Projected: ${formatMillisecondsToTime(projectedTime)} (${diffStr} ${status})` + projectedLi.textContent = `Projected: ${formatMillisecondsToTime(projectedTime)}${projectedInfo} (${diffStr} ${status})` } }) } From b9b5a9a5c42473a085bd3cdea182692fa2e34cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Tue, 24 Mar 2026 14:29:41 +0100 Subject: [PATCH 06/13] feat: adds the Duration timing mode interfaces --- .../src/documents/playlistTiming.ts | 24 ++++++++++++++++++- packages/corelib/src/playout/rundownTiming.ts | 5 ++++ .../activePlaylistTimingMode.yaml | 1 + .../src/generated/asyncapi.yaml | 1 + .../src/generated/schema.ts | 1 + .../src/topics/activePlaylistTopic.ts | 4 +++- 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/blueprints-integration/src/documents/playlistTiming.ts b/packages/blueprints-integration/src/documents/playlistTiming.ts index 672701d2ba..2f5db0371c 100644 --- a/packages/blueprints-integration/src/documents/playlistTiming.ts +++ b/packages/blueprints-integration/src/documents/playlistTiming.ts @@ -4,6 +4,7 @@ export enum PlaylistTimingType { None = 'none', ForwardTime = 'forward-time', BackTime = 'back-time', + Duration = 'duration', } export interface PlaylistTimingBase { @@ -49,4 +50,25 @@ export interface PlaylistTimingBackTime extends PlaylistTimingBase { expectedEnd: Time } -export type RundownPlaylistTiming = PlaylistTimingNone | PlaylistTimingForwardTime | PlaylistTimingBackTime +/** + * This mode is inteded for shows with a "floating start", + * meaning they will start based on when the show before them on the channel ends. + * In this mode, we will preserve the Duration and automatically calculate the expectedEnd + * based on the _actual_ start of the show (playlist.startedPlayback). + * + * The optional expectedStart property allows setting a start property of the show that will not affect + * timing calculations, only purpose is to drive UI and inform the users about the preliminary plan as + * planned in the editorial planning tool. + */ +export interface PlaylistTimingDuration extends PlaylistTimingBase { + type: PlaylistTimingType.Duration + /** A stipulated start time, to drive UIs pre-show, but not affecting calculations during the show. + */ + expectedStart?: Time + /** Planned duration of the rundown playlist + * When the show starts, an expectedEnd gets automatically calculated with this as an offset from that starting point + */ + expectedDuration: number +} + +export type RundownPlaylistTiming = PlaylistTimingNone | PlaylistTimingForwardTime | PlaylistTimingBackTime | PlaylistTimingDuration diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index ea2ec02e9b..c044b774f5 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -13,6 +13,7 @@ import { PlaylistTimingBackTime, + PlaylistTimingDuration, PlaylistTimingForwardTime, PlaylistTimingNone, PlaylistTimingType, @@ -33,6 +34,10 @@ export namespace PlaylistTiming { export function isPlaylistTimingBackTime(timing: RundownPlaylistTiming): timing is PlaylistTimingBackTime { return timing.type === PlaylistTimingType.BackTime } + + export function isPlaylistDurationTimed(timing: RundownPlaylistTiming): timing is PlaylistTimingDuration { + return timing.type === PlaylistTimingType.Duration + } export function getExpectedStart(timing: RundownPlaylistTiming): number | undefined { if (PlaylistTiming.isPlaylistTimingForwardTime(timing)) { diff --git a/packages/live-status-gateway-api/api/components/timing/activePlaylistTiming/activePlaylistTimingMode.yaml b/packages/live-status-gateway-api/api/components/timing/activePlaylistTiming/activePlaylistTimingMode.yaml index 90520f52a0..73dbf67dcb 100644 --- a/packages/live-status-gateway-api/api/components/timing/activePlaylistTiming/activePlaylistTimingMode.yaml +++ b/packages/live-status-gateway-api/api/components/timing/activePlaylistTiming/activePlaylistTimingMode.yaml @@ -5,3 +5,4 @@ enum: - none - forward-time - back-time + - duration diff --git a/packages/live-status-gateway-api/src/generated/asyncapi.yaml b/packages/live-status-gateway-api/src/generated/asyncapi.yaml index 1d21b524b4..b15aa30b9a 100644 --- a/packages/live-status-gateway-api/src/generated/asyncapi.yaml +++ b/packages/live-status-gateway-api/src/generated/asyncapi.yaml @@ -660,6 +660,7 @@ channels: - none - forward-time - back-time + - duration startedPlayback: description: Unix timestamp of when the playlist started (milliseconds) type: number diff --git a/packages/live-status-gateway-api/src/generated/schema.ts b/packages/live-status-gateway-api/src/generated/schema.ts index 4c0ea4bdc7..64c31f4411 100644 --- a/packages/live-status-gateway-api/src/generated/schema.ts +++ b/packages/live-status-gateway-api/src/generated/schema.ts @@ -423,6 +423,7 @@ enum ActivePlaylistTimingMode { NONE = 'none', FORWARD_MINUS_TIME = 'forward-time', BACK_MINUS_TIME = 'back-time', + DURATION = 'duration', } /** diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 131a98bc87..5f6043bd70 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -181,7 +181,7 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket ? this._activePlaylist.timing.expectedStart : undefined, expectedEnd: - this._activePlaylist.timing.type !== PlaylistTimingType.None + this._activePlaylist.timing.type !== PlaylistTimingType.None && this._activePlaylist.timing.type !== PlaylistTimingType.Duration ? this._activePlaylist.timing.expectedEnd : undefined, }, @@ -428,6 +428,8 @@ function translatePlaylistTimingType(type: PlaylistTimingType): ActivePlaylistTi return ActivePlaylistTimingMode.BACK_MINUS_TIME case PlaylistTimingType.ForwardTime: return ActivePlaylistTimingMode.FORWARD_MINUS_TIME + case PlaylistTimingType.Duration: + return ActivePlaylistTimingMode.DURATION default: assertNever(type) // Cast and return the value anyway, so that the application works From a62b7b0a0804307dd2e70789b88648849a255f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Tue, 24 Mar 2026 16:32:28 +0100 Subject: [PATCH 07/13] feat: monkey patch a first frontend implementation of the BBC-centric duration based mode Which sets Planned End based on Started + Duration --- packages/corelib/src/playout/rundownTiming.ts | 12 +++++++++++- .../RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index c044b774f5..938dfa5580 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -47,12 +47,14 @@ export namespace PlaylistTiming { timing.expectedStart || (timing.expectedDuration ? timing.expectedEnd - timing.expectedDuration : undefined) ) + } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { + return timing.expectedStart } else { return undefined } } - export function getExpectedEnd(timing: RundownPlaylistTiming): number | undefined { + export function getExpectedEnd(timing: RundownPlaylistTiming, startedPlayback?: number | undefined): number | undefined { if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { return timing.expectedEnd } else if (PlaylistTiming.isPlaylistTimingForwardTime(timing)) { @@ -60,6 +62,14 @@ export namespace PlaylistTiming { timing.expectedEnd || (timing.expectedDuration ? timing.expectedStart + timing.expectedDuration : undefined) ) + } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { + if (!startedPlayback) { + // before the show, estimate the end from start + dur + return timing.expectedStart ? timing.expectedStart + timing.expectedDuration : undefined + } else { + // after starting the show, project the end from startedPlayback + dur + return startedPlayback + timing.expectedDuration + } } else { return undefined } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 8b44c60416..4ade2e6884 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -15,7 +15,7 @@ export function RundownHeaderExpectedEnd({ const timingDurations = useTiming() const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing, playlist.startedPlayback) const now = timingDurations.currentTime ?? Date.now() // Use remainingPlaylistDuration which includes current part's remaining time From 5f1a3b18bee0a42c16f017adc88011e1701eac9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 25 Mar 2026 09:24:37 +0100 Subject: [PATCH 08/13] feat: adds diffing for Duration-mode --- packages/webui/src/client/lib/rundownTiming.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index a273b072ed..8b92316ad6 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -846,6 +846,10 @@ export function getPlaylistTimingDiff( frontAnchor = Math.max(currentTime, playlist.startedPlayback ?? Math.max(timing.expectedStart, currentTime)) } else if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { backAnchor = timingContext.nextRundownAnchor ?? timing.expectedEnd + } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { + const backAnchorTimeWithoutBreaks = timingContext.nextRundownAnchor ?? PlaylistTiming.getExpectedEnd(timing, playlist.startedPlayback) ?? currentTime + timing.expectedDuration + backAnchor = timingContext.nextRundownAnchor ?? backAnchorTimeWithoutBreaks + frontAnchor = Math.max(currentTime, playlist.startedPlayback || PlaylistTiming.getExpectedStart(timing) || 0) } let diff = PlaylistTiming.isPlaylistTimingNone(timing) From 11c86898796c05a1d0975ad8af1b3550ab48ea64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 25 Mar 2026 11:05:03 +0100 Subject: [PATCH 09/13] feat: calculate estimated end for Duration mode --- packages/corelib/src/playout/rundownTiming.ts | 4 ++-- .../RundownHeaderExpectedEnd.tsx | 22 +++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index 938dfa5580..d73d9becc4 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -54,7 +54,7 @@ export namespace PlaylistTiming { } } - export function getExpectedEnd(timing: RundownPlaylistTiming, startedPlayback?: number | undefined): number | undefined { + export function getExpectedEnd(timing: RundownPlaylistTiming, startedPlayback?: number): number | undefined { if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { return timing.expectedEnd } else if (PlaylistTiming.isPlaylistTimingForwardTime(timing)) { @@ -84,7 +84,7 @@ export namespace PlaylistTiming { return undefined } } - + export function sortTimings( a: ReadonlyDeep<{ timing: RundownPlaylistTiming }>, b: ReadonlyDeep<{ timing: RundownPlaylistTiming }> diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 4ade2e6884..be80843cb8 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -3,6 +3,7 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' +import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' export function RundownHeaderExpectedEnd({ playlist, @@ -14,15 +15,22 @@ export function RundownHeaderExpectedEnd({ const { t } = useTranslation() const timingDurations = useTiming() - const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing, playlist.startedPlayback) - const now = timingDurations.currentTime ?? Date.now() + // todo: this should live with the others in client/lib/rundwownTimings.ts + function getEstimatedEnd(timing: RundownPlaylistTiming, now: number, remainingPlaylistDuration?: number, startedPlayback?: number): number | undefined { + let frontAnchor = PlaylistTiming.getExpectedStart(timing) - // Use remainingPlaylistDuration which includes current part's remaining time - const estEnd = - timingDurations.remainingPlaylistDuration !== undefined ? Math.max(now, expectedStart ?? now) + timingDurations.remainingPlaylistDuration : null + if (PlaylistTiming.isPlaylistDurationTimed(timing)) { + frontAnchor = startedPlayback ??PlaylistTiming.getExpectedStart(timing) + } + + return remainingPlaylistDuration !== undefined ? Math.max(now, frontAnchor ?? now) + remainingPlaylistDuration : undefined + } - if (expectedEnd === undefined && estEnd === null) return null + const now = timingDurations.currentTime ?? Date.now() + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing, playlist.startedPlayback) + const estEnd = getEstimatedEnd(playlist.timing, now, timingDurations.remainingPlaylistDuration, playlist.startedPlayback) + + if (expectedEnd === undefined && estEnd === undefined) return null return (
      From 3ba0f34abf8775ccbc5ad44f05f745b0e37f1a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 26 Mar 2026 13:04:17 +0100 Subject: [PATCH 10/13] chore: refactors moving the new util function into rundownTimings where it belongs --- packages/corelib/src/playout/rundownTiming.ts | 9 +++++++++ .../RundownHeader/RundownHeaderExpectedEnd.tsx | 14 +------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index d73d9becc4..8fdf113c06 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -84,6 +84,15 @@ export namespace PlaylistTiming { return undefined } } + + export function getEstimatedEnd(timing: RundownPlaylistTiming, now: number, remainingPlaylistDuration?: number, startedPlayback?: number): number | undefined { + let frontAnchor = PlaylistTiming.getExpectedStart(timing) + if (PlaylistTiming.isPlaylistDurationTimed(timing)) { + frontAnchor = startedPlayback ??PlaylistTiming.getExpectedStart(timing) + } + + return remainingPlaylistDuration !== undefined ? Math.max(now, frontAnchor ?? now) + remainingPlaylistDuration : undefined + } export function sortTimings( a: ReadonlyDeep<{ timing: RundownPlaylistTiming }>, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index be80843cb8..cf12fdb77c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -3,7 +3,6 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' -import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' export function RundownHeaderExpectedEnd({ playlist, @@ -15,20 +14,9 @@ export function RundownHeaderExpectedEnd({ const { t } = useTranslation() const timingDurations = useTiming() - // todo: this should live with the others in client/lib/rundwownTimings.ts - function getEstimatedEnd(timing: RundownPlaylistTiming, now: number, remainingPlaylistDuration?: number, startedPlayback?: number): number | undefined { - let frontAnchor = PlaylistTiming.getExpectedStart(timing) - - if (PlaylistTiming.isPlaylistDurationTimed(timing)) { - frontAnchor = startedPlayback ??PlaylistTiming.getExpectedStart(timing) - } - - return remainingPlaylistDuration !== undefined ? Math.max(now, frontAnchor ?? now) + remainingPlaylistDuration : undefined - } - const now = timingDurations.currentTime ?? Date.now() const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing, playlist.startedPlayback) - const estEnd = getEstimatedEnd(playlist.timing, now, timingDurations.remainingPlaylistDuration, playlist.startedPlayback) + const estEnd = PlaylistTiming.getEstimatedEnd(playlist.timing, now, timingDurations.remainingPlaylistDuration, playlist.startedPlayback) if (expectedEnd === undefined && estEnd === undefined) return null From 1e430f4587b3e134bd73546a53fd2a95d0d786cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 27 Mar 2026 14:59:57 +0100 Subject: [PATCH 11/13] fix: corrects estimated time calculation, taking into account the mode before any planned start, as well as starting prematurely vs late --- packages/corelib/src/playout/rundownTiming.ts | 7 ++----- .../RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index 8fdf113c06..89254d4edd 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -86,12 +86,9 @@ export namespace PlaylistTiming { } export function getEstimatedEnd(timing: RundownPlaylistTiming, now: number, remainingPlaylistDuration?: number, startedPlayback?: number): number | undefined { - let frontAnchor = PlaylistTiming.getExpectedStart(timing) - if (PlaylistTiming.isPlaylistDurationTimed(timing)) { - frontAnchor = startedPlayback ??PlaylistTiming.getExpectedStart(timing) - } + let frontAnchor = startedPlayback ? now : Math.max(now, PlaylistTiming.getExpectedStart(timing) ?? now) - return remainingPlaylistDuration !== undefined ? Math.max(now, frontAnchor ?? now) + remainingPlaylistDuration : undefined + return remainingPlaylistDuration !== undefined ? frontAnchor + remainingPlaylistDuration : undefined } export function sortTimings( diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index cf12fdb77c..5a90703076 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -30,4 +30,4 @@ export function RundownHeaderExpectedEnd({ ) : null}
      ) -} +} \ No newline at end of file From 1f9507f8fbe1cdccd1357fb8a9b92cc2d362873b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 27 Mar 2026 15:15:01 +0100 Subject: [PATCH 12/13] chore: lint and formatting --- .../src/documents/playlistTiming.ts | 14 ++++++++----- packages/corelib/src/playout/rundownTiming.ts | 20 +++++++++++++------ .../RundownHeaderExpectedEnd.tsx | 11 +++++++--- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/blueprints-integration/src/documents/playlistTiming.ts b/packages/blueprints-integration/src/documents/playlistTiming.ts index 2f5db0371c..41ae942dfa 100644 --- a/packages/blueprints-integration/src/documents/playlistTiming.ts +++ b/packages/blueprints-integration/src/documents/playlistTiming.ts @@ -51,13 +51,13 @@ export interface PlaylistTimingBackTime extends PlaylistTimingBase { } /** - * This mode is inteded for shows with a "floating start", + * This mode is intended for shows with a "floating start", * meaning they will start based on when the show before them on the channel ends. - * In this mode, we will preserve the Duration and automatically calculate the expectedEnd + * In this mode, we will preserve the Duration and automatically calculate the expectedEnd * based on the _actual_ start of the show (playlist.startedPlayback). - * + * * The optional expectedStart property allows setting a start property of the show that will not affect - * timing calculations, only purpose is to drive UI and inform the users about the preliminary plan as + * timing calculations, only purpose is to drive UI and inform the users about the preliminary plan as * planned in the editorial planning tool. */ export interface PlaylistTimingDuration extends PlaylistTimingBase { @@ -71,4 +71,8 @@ export interface PlaylistTimingDuration extends PlaylistTimingBase { expectedDuration: number } -export type RundownPlaylistTiming = PlaylistTimingNone | PlaylistTimingForwardTime | PlaylistTimingBackTime | PlaylistTimingDuration +export type RundownPlaylistTiming = + | PlaylistTimingNone + | PlaylistTimingForwardTime + | PlaylistTimingBackTime + | PlaylistTimingDuration diff --git a/packages/corelib/src/playout/rundownTiming.ts b/packages/corelib/src/playout/rundownTiming.ts index 89254d4edd..f304196fc2 100644 --- a/packages/corelib/src/playout/rundownTiming.ts +++ b/packages/corelib/src/playout/rundownTiming.ts @@ -34,7 +34,7 @@ export namespace PlaylistTiming { export function isPlaylistTimingBackTime(timing: RundownPlaylistTiming): timing is PlaylistTimingBackTime { return timing.type === PlaylistTimingType.BackTime } - + export function isPlaylistDurationTimed(timing: RundownPlaylistTiming): timing is PlaylistTimingDuration { return timing.type === PlaylistTimingType.Duration } @@ -85,12 +85,20 @@ export namespace PlaylistTiming { } } - export function getEstimatedEnd(timing: RundownPlaylistTiming, now: number, remainingPlaylistDuration?: number, startedPlayback?: number): number | undefined { - let frontAnchor = startedPlayback ? now : Math.max(now, PlaylistTiming.getExpectedStart(timing) ?? now) - - return remainingPlaylistDuration !== undefined ? frontAnchor + remainingPlaylistDuration : undefined + export function getEstimatedEnd( + timing: RundownPlaylistTiming, + now: number, + remainingPlaylistDuration?: number, + startedPlayback?: number + ): number | undefined { + if (remainingPlaylistDuration !== undefined) { + const frontAnchor = startedPlayback ? now : Math.max(now, PlaylistTiming.getExpectedStart(timing) ?? now) + + return frontAnchor + remainingPlaylistDuration + } + return undefined } - + export function sortTimings( a: ReadonlyDeep<{ timing: RundownPlaylistTiming }>, b: ReadonlyDeep<{ timing: RundownPlaylistTiming }> diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 5a90703076..10416f08ab 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -16,8 +16,13 @@ export function RundownHeaderExpectedEnd({ const now = timingDurations.currentTime ?? Date.now() const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing, playlist.startedPlayback) - const estEnd = PlaylistTiming.getEstimatedEnd(playlist.timing, now, timingDurations.remainingPlaylistDuration, playlist.startedPlayback) - + const estEnd = PlaylistTiming.getEstimatedEnd( + playlist.timing, + now, + timingDurations.remainingPlaylistDuration, + playlist.startedPlayback + ) + if (expectedEnd === undefined && estEnd === undefined) return null return ( @@ -30,4 +35,4 @@ export function RundownHeaderExpectedEnd({ ) : null}
) -} \ No newline at end of file +} From 383fa82067d452bb4496fbe13789b11fe0a91b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 27 Mar 2026 15:16:04 +0100 Subject: [PATCH 13/13] chore: linting --- packages/live-status-gateway/sample-client/script.js | 8 ++++++-- .../live-status-gateway/src/topics/activePlaylistTopic.ts | 3 ++- packages/webui/src/client/lib/rundownTiming.ts | 7 +++++-- .../client/ui/RundownView/RundownHeader/RundownHeader.tsx | 4 ++-- .../RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- .../RundownHeader/RundownHeaderPlannedStart.tsx | 5 +---- .../RundownHeader/RundownHeaderTimingDisplay.tsx | 6 +++++- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js index 47e9d38d81..06c96ac2d9 100644 --- a/packages/live-status-gateway/sample-client/script.js +++ b/packages/live-status-gateway/sample-client/script.js @@ -267,10 +267,14 @@ function updateTTimers(tTimers) { } else if (timer.state.pauseTime && now >= timer.state.pauseTime) { // Timer has reached its pauseTime - freeze at that moment currentTime = timer.state.zeroTime - timer.state.pauseTime - valueSpan.textContent = formatMillisecondsToTime(currentTime) + ` (pauseTime: ${formatTimestampToTimeOfDay(timer.state.pauseTime)})` + valueSpan.textContent = + formatMillisecondsToTime(currentTime) + + ` (pauseTime: ${formatTimestampToTimeOfDay(timer.state.pauseTime)})` } else { currentTime = timer.state.zeroTime - now - valueSpan.textContent = formatMillisecondsToTime(currentTime) + ` (zeroTime: ${formatTimestampToTimeOfDay(timer.state.zeroTime)})` + valueSpan.textContent = + formatMillisecondsToTime(currentTime) + + ` (zeroTime: ${formatTimestampToTimeOfDay(timer.state.zeroTime)})` } // Update projected time if available diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 5f6043bd70..9cabd80500 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -181,7 +181,8 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket ? this._activePlaylist.timing.expectedStart : undefined, expectedEnd: - this._activePlaylist.timing.type !== PlaylistTimingType.None && this._activePlaylist.timing.type !== PlaylistTimingType.Duration + this._activePlaylist.timing.type !== PlaylistTimingType.None && + this._activePlaylist.timing.type !== PlaylistTimingType.Duration ? this._activePlaylist.timing.expectedEnd : undefined, }, diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index 8b92316ad6..d9368a4ab7 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -847,9 +847,12 @@ export function getPlaylistTimingDiff( } else if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { backAnchor = timingContext.nextRundownAnchor ?? timing.expectedEnd } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { - const backAnchorTimeWithoutBreaks = timingContext.nextRundownAnchor ?? PlaylistTiming.getExpectedEnd(timing, playlist.startedPlayback) ?? currentTime + timing.expectedDuration + const backAnchorTimeWithoutBreaks = + timingContext.nextRundownAnchor ?? + PlaylistTiming.getExpectedEnd(timing, playlist.startedPlayback) ?? + currentTime + timing.expectedDuration backAnchor = timingContext.nextRundownAnchor ?? backAnchorTimeWithoutBreaks - frontAnchor = Math.max(currentTime, playlist.startedPlayback || PlaylistTiming.getExpectedStart(timing) || 0) + frontAnchor = Math.max(currentTime, playlist.startedPlayback || PlaylistTiming.getExpectedStart(timing) || 0) } let diff = PlaylistTiming.isPlaylistTimingNone(timing) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 9713542838..2972c8cfbd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -54,12 +54,12 @@ export function RundownHeader({ const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - + // const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task // same issue in RundownHeaderDuration.tsx - const expectedDuration = playlist.timing.expectedDuration + const expectedDuration = playlist.timing.expectedDuration const hasSimple = !!(expectedStart || expectedDuration || expectedEnd) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index aedb2dde26..d052a131d3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -18,7 +18,7 @@ export function RundownHeaderDurations({ // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task // same issue in RundownHeader.tsx - const expectedDuration = playlist.timing.expectedDuration + const expectedDuration = playlist.timing.expectedDuration // Use remainingPlaylistDuration which includes current part's remaining time const estDuration = timingDurations.remainingPlaylistDuration diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 92dbc88e2c..148e5c2ee6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -16,7 +16,6 @@ export function RundownHeaderPlannedStart({ const timingDurations = useTiming() const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const now = timingDurations.currentTime ?? Date.now() const startsIn = now - (expectedStart ?? 0) @@ -25,9 +24,7 @@ export function RundownHeaderPlannedStart({ {!simplified && expectedStart !== undefined && ( )} - {playlist.startedPlayback !== undefined && ( - - )} + {playlist.startedPlayback !== undefined && } {playlist.startedPlayback === undefined && expectedStart !== undefined && ( {startsIn >= 0 && '+'} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx index 61790953e4..2b9ca4ee74 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -18,7 +18,11 @@ export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDis if (overUnderClock === undefined) return null // Hide diff in untimed mode before first timing take - if (PlaylistTiming.isPlaylistTimingNone(playlist.timing) && playlist.timing.expectedDuration === undefined && !playlist.startedPlayback) { + if ( + PlaylistTiming.isPlaylistTimingNone(playlist.timing) && + playlist.timing.expectedDuration === undefined && + !playlist.startedPlayback + ) { return null }