From c88243f096cf9fbc46b798222b4735df590b43ad Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 16:34:44 +0000 Subject: [PATCH 01/79] wip: data sketch --- .../corelib/src/dataModel/RundownPlaylist.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e2850bc49b..dadf9e6692 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,6 +94,56 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown + +export interface RundownTTimerModeFreeRun { + readonly type: 'freeRun' + /** Starting time (unix timestamp) */ + startTime: number + /** + * Set to a timestamp to pause the timer at that timestamp + * When unpausing, the `startTime` should be adjusted to account for the paused duration + */ + paused: number | null + /** The direction to count */ + // direction: 'up' | 'down' // TODO: does this make sense? +} +export interface RundownTTimerModeCountdown { + readonly type: 'countdown' + /** The target time (unix timestamp) */ + targetTime: number + /** + * Set to a timestamp to pause the timer at that timestamp + * When unpausing, the `targetTime` should be adjusted to account for the paused duration + */ + paused: number | null + /** + * The duration of the countdown in milliseconds + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + stopAtZero: boolean +} + +export interface RundownTTimer { + readonly index: number + + /** A label for the timer */ + label: string + + /** The current mode of the timer, or null if not configured */ + mode: RundownTTimerMode | null + + /* + * Future ideas: + * allowUiControl: boolean + * display: { ... } // some kind of options for how to display in the ui + */ +} + export interface DBRundownPlaylist { _id: RundownPlaylistId /** External ID (source) of the playlist */ @@ -176,6 +226,12 @@ export interface DBRundownPlaylist { trackedAbSessions?: ABSessionInfo[] /** AB playback sessions assigned in the last timeline generation */ assignedAbSessions?: Record + + /** + * T-timers for the Playlist. + * This is a fixed size pool with 3 being chosen as a likely good amount, that can be used for any purpose. + */ + ttimers: [RundownTTimer, RundownTTimer, RundownTTimer] } // Information about a 'selected' PartInstance for the Playlist From d5d74838f3aa98b9279fd64b76403061f2450aa2 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 16:38:01 +0000 Subject: [PATCH 02/79] wip: fix types --- .../src/__mocks__/defaultCollectionObjects.ts | 6 ++++++ packages/job-worker/src/rundownPlaylists.ts | 10 ++++++++++ .../webui/src/__mocks__/defaultCollectionObjects.ts | 5 +++++ .../src/client/lib/__tests__/rundownTiming.test.ts | 6 ++++++ 4 files changed, 27 insertions(+) diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 88869a4da8..ddbc9eda29 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -44,6 +44,12 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: PlaylistTimingType.None, }, rundownIdsInOrder: [], + + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a..d149ecde63 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -236,6 +236,11 @@ export function produceRundownPlaylistInfoFromRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], ...clone(existingPlaylist), @@ -332,6 +337,11 @@ function defaultPlaylistForRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], ...clone(existingPlaylist), diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 7434f499fb..25272c7f4a 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -48,6 +48,11 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: 'none' as any, }, rundownIdsInOrder: [], + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index 8e402449d9..1a1f9d5a35 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -28,6 +28,12 @@ function makeMockPlaylist(): DBRundownPlaylist { type: PlaylistTimingType.None, }, rundownIdsInOrder: [], + + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], }) } From b0930d231ad9da4f664193f513738f8003602114 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 16:53:19 +0000 Subject: [PATCH 03/79] wip: migration --- meteor/server/migration/X_X_X.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 30a74d769e..c43035beae 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -1,7 +1,7 @@ import { addMigrationSteps } from './databaseMigration' import { CURRENT_SYSTEM_VERSION } from './currentSystemVersion' import { MongoInternals } from 'meteor/mongo' -import { Studios } from '../collections' +import { RundownPlaylists, Studios } from '../collections' import { ExpectedPackages } from '../collections' import * as PackagesPreR53 from '@sofie-automation/corelib/dist/dataModel/Old/ExpectedPackagesR52' import { @@ -195,4 +195,28 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, + { + id: 'Add T-timers to RundownPlaylist', + canBeRunAutomatically: true, + validate: async () => { + const playlistCount = await RundownPlaylists.countDocuments({ ttimers: { $exists: false } }) + if (playlistCount > 1) return `There are ${playlistCount} RundownPlaylists without T-timers` + return false + }, + migrate: async () => { + await RundownPlaylists.mutableCollection.updateAsync( + { ttimers: { $exists: false } }, + { + $set: { + ttimers: [ + { index: 0, label: '', mode: null }, + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + ], + }, + }, + { multi: true } + ) + }, + }, ]) From 006d15318705e9c489c0237146c747fffc1240f1 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 16:53:31 +0000 Subject: [PATCH 04/79] wip: blueprint api sketch --- .../src/context/adlibActionContext.ts | 4 +- .../src/context/onSetAsNextContext.ts | 3 +- .../src/context/onTakeContext.ts | 4 +- .../src/context/rundownContext.ts | 6 ++- .../src/context/tTimersContext.ts | 37 +++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 packages/blueprints-integration/src/context/tTimersContext.ts diff --git a/packages/blueprints-integration/src/context/adlibActionContext.ts b/packages/blueprints-integration/src/context/adlibActionContext.ts index 4435d76b41..afca8bcff0 100644 --- a/packages/blueprints-integration/src/context/adlibActionContext.ts +++ b/packages/blueprints-integration/src/context/adlibActionContext.ts @@ -5,6 +5,7 @@ import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import { IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece } from '../index.js' import { IRouteSetMethods } from './routeSetContext.js' +import { ITTimersContext } from './tTimersContext.js' /** Actions */ export interface IDataStoreMethods { @@ -26,7 +27,8 @@ export interface IActionExecutionContext IDataStoreMethods, IPartAndPieceActionContext, IExecuteTSRActionsContext, - IRouteSetMethods { + IRouteSetMethods, + ITTimersContext { /** Fetch the showstyle config for the specified part */ // getNextShowStyleConfig(): Readonly<{ [key: string]: ConfigItemValue }> diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 9e729ce402..ee7b3aa29e 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -12,12 +12,13 @@ import { } from '../index.js' import { BlueprintQuickLookInfo } from './quickLoopInfo.js' import { ReadonlyDeep } from 'type-fest' +import type { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the part currently on air, and 'next' is the partInstance being set as Next * This is similar to `IPartAndPieceActionContext`, but has more limits on what is allowed to be changed. */ -export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext { +export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext, ITTimersContext { /** Information about the current loop, if there is one */ readonly quickLoopInfo: BlueprintQuickLookInfo | null diff --git a/packages/blueprints-integration/src/context/onTakeContext.ts b/packages/blueprints-integration/src/context/onTakeContext.ts index 3918bdd7ee..50606a37ba 100644 --- a/packages/blueprints-integration/src/context/onTakeContext.ts +++ b/packages/blueprints-integration/src/context/onTakeContext.ts @@ -1,6 +1,7 @@ import { IEventContext, IShowStyleUserContext, Time } from '../index.js' import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext } from './executeTsrActionContext.js' +import { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the partInstance we're leaving, and 'next' is the partInstance we're taking @@ -9,7 +10,8 @@ export interface IOnTakeContext extends IPartAndPieceActionContext, IShowStyleUserContext, IEventContext, - IExecuteTSRActionsContext { + IExecuteTSRActionsContext, + ITTimersContext { /** Inform core that a take out of the taken partinstance should be blocked until the specified time */ blockTakeUntil(time: Time | null): Promise /** diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index 402da1fa39..b8bdd9472e 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -13,7 +13,11 @@ export interface IRundownContext extends IShowStyleContext { export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} -export interface IRundownActivationContext extends IRundownContext, IExecuteTSRActionsContext, IDataStoreMethods { +export interface IRundownActivationContext + extends IRundownContext, + IExecuteTSRActionsContext, + IDataStoreMethods, + ITTimersContext { /** Info about the RundownPlaylist state before the Activation / Deactivation event */ readonly previousState: IRundownActivationContextState readonly currentState: IRundownActivationContextState diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts new file mode 100644 index 0000000000..75fc2036c5 --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,37 @@ +export interface ITTimersContext { + /** + * Get a T-timer by its index + * Note: Index is 1-based (1, 2, 3) + * @param index Number of the timer to retrieve + */ + getTimer(index: 1 | 2 | 3): IPlaylistTTimer +} + +export interface IPlaylistTTimer { + readonly index: number + + /** The label of the T-timer */ + readonly label: string + + /** Set the label of the T-timer */ + setLabel(label: string): void + + /** Clear the T-timer back to an uninitialized state */ + clearTimer(): void + + /** + * If the current mode supports being paused, pause the timer + * Note: This only works for the countdown and freerun modes + * @returns True if the timer was paused, false if it could not be paused + */ + pause(): boolean + + /** + * If the timer can be restarted, restart it + * Note: This only works for the countdown mode + * @returns True if the timer was restarted, false if it could not be restarted + */ + restart(): boolean + + // TODO +} From 4a32487de9a2ff8e5bacb57226e32da79c96a300 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 17:08:04 +0000 Subject: [PATCH 05/79] wip: complete sketch --- .../src/context/tTimersContext.ts | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 75fc2036c5..26f4db731e 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -13,25 +13,65 @@ export interface IPlaylistTTimer { /** The label of the T-timer */ readonly label: string + /** + * The current state of the T-timer + * Null if the T-timer is not initialized + */ + readonly state: IPlaylistTTimerState | null + /** Set the label of the T-timer */ setLabel(label: string): void /** Clear the T-timer back to an uninitialized state */ clearTimer(): void + /** + * Start a countdown timer + * @param duration Duration of the countdown in milliseconds + * @param options Options for the countdown + */ + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void + + /** + * Start a free-running timer + */ + startFreeRun(options?: { startPaused?: boolean }): void + /** * If the current mode supports being paused, pause the timer - * Note: This only works for the countdown and freerun modes + * Note: This is supported by the countdown and freerun modes * @returns True if the timer was paused, false if it could not be paused */ pause(): boolean /** * If the timer can be restarted, restart it - * Note: This only works for the countdown mode + * Note: This is supported by the countdown mode * @returns True if the timer was restarted, false if it could not be restarted */ restart(): boolean +} - // TODO +export type IPlaylistTTimerState = IPlaylistTTimerStateCountdown | IPlaylistTTimerStateFreeRun + +export interface IPlaylistTTimerStateCountdown { + /** The mode of the T-timer */ + readonly mode: 'countdown' + /** The current time of the countdown, in milliseconds */ + readonly currentTime: number + /** The total duration of the countdown, in milliseconds */ + readonly duration: number + /** Whether the timer is currently paused */ + readonly paused: boolean + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} +export interface IPlaylistTTimerStateFreeRun { + /** The mode of the T-timer */ + readonly mode: 'freerun' + /** The current time of the freerun, in milliseconds */ + readonly currentTime: number + /** Whether the timer is currently paused */ + readonly paused: boolean } From d219b91d3a508ee316dcfc02b125c22dd034efb6 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 17:21:58 +0000 Subject: [PATCH 06/79] wip: clear all methiod --- .../blueprints-integration/src/context/tTimersContext.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 26f4db731e..fd6b0344d5 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -5,6 +5,11 @@ export interface ITTimersContext { * @param index Number of the timer to retrieve */ getTimer(index: 1 | 2 | 3): IPlaylistTTimer + + /** + * Clear all T-timers + */ + clearAllTimers(): void } export interface IPlaylistTTimer { From 336874c26192ee6631b48d1ec4e0ebd2f244f03e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 13:11:01 +0000 Subject: [PATCH 07/79] wip --- meteor/server/migration/X_X_X.ts | 6 +++--- .../blueprints-integration/src/context/tTimersContext.ts | 9 ++++++++- packages/corelib/src/dataModel/RundownPlaylist.ts | 2 +- .../job-worker/src/__mocks__/defaultCollectionObjects.ts | 2 +- packages/job-worker/src/rundownPlaylists.ts | 4 ++-- packages/webui/src/__mocks__/defaultCollectionObjects.ts | 2 +- .../webui/src/client/lib/__tests__/rundownTiming.test.ts | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index c43035beae..3a5704589a 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -199,16 +199,16 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ id: 'Add T-timers to RundownPlaylist', canBeRunAutomatically: true, validate: async () => { - const playlistCount = await RundownPlaylists.countDocuments({ ttimers: { $exists: false } }) + const playlistCount = await RundownPlaylists.countDocuments({ tTimers: { $exists: false } }) if (playlistCount > 1) return `There are ${playlistCount} RundownPlaylists without T-timers` return false }, migrate: async () => { await RundownPlaylists.mutableCollection.updateAsync( - { ttimers: { $exists: false } }, + { tTimers: { $exists: false } }, { $set: { - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index fd6b0344d5..e017db7d4a 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -50,7 +50,14 @@ export interface IPlaylistTTimer { pause(): boolean /** - * If the timer can be restarted, restart it + * If the current mode supports being paused, resume the timer + * This is the opposite of `pause()` + * @returns True if the timer was resumed, false if it could not be resumed + */ + resume(): boolean + + /** + * If the timer can be restarted, restore it to its initial/restarted state * Note: This is supported by the countdown mode * @returns True if the timer was restarted, false if it could not be restarted */ diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index dadf9e6692..eca17682cd 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -231,7 +231,7 @@ export interface DBRundownPlaylist { * T-timers for the Playlist. * This is a fixed size pool with 3 being chosen as a likely good amount, that can be used for any purpose. */ - ttimers: [RundownTTimer, RundownTTimer, RundownTTimer] + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] } // Information about a 'selected' PartInstance for the Playlist diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index ddbc9eda29..7a2c601e72 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -45,7 +45,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index d149ecde63..902eeb38c1 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -236,7 +236,7 @@ export function produceRundownPlaylistInfoFromRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, @@ -337,7 +337,7 @@ function defaultPlaylistForRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 25272c7f4a..bba6689126 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -48,7 +48,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: 'none' as any, }, rundownIdsInOrder: [], - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index 1a1f9d5a35..afe153eb73 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -29,7 +29,7 @@ function makeMockPlaylist(): DBRundownPlaylist { }, rundownIdsInOrder: [], - ttimers: [ + tTimers: [ { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, From 1ce855ad3080fe5b198bb0a5aea05e1906df18b0 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 13:55:24 +0000 Subject: [PATCH 08/79] wip --- meteor/server/migration/X_X_X.ts | 2 +- .../src/context/rundownContext.ts | 1 + .../src/context/tTimersContext.ts | 6 +- .../corelib/src/dataModel/RundownPlaylist.ts | 4 +- .../blueprints/context/OnSetAsNextContext.ts | 13 +++ .../src/blueprints/context/OnTakeContext.ts | 13 +++ .../context/RundownActivationContext.ts | 13 +++ .../src/blueprints/context/adlibActions.ts | 13 +++ .../context/services/TTimersService.ts | 104 ++++++++++++++++++ .../src/playout/model/PlayoutModel.ts | 7 ++ .../model/implementation/PlayoutModelImpl.ts | 5 + packages/job-worker/src/rundownPlaylists.ts | 4 +- 12 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 packages/job-worker/src/blueprints/context/services/TTimersService.ts diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 3a5704589a..7056e6c1e6 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -209,9 +209,9 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ { $set: { tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], }, }, diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index b8bdd9472e..cf3a30e332 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -4,6 +4,7 @@ import type { IPackageInfoContext } from './packageInfoContext.js' import type { IShowStyleContext } from './showStyleContext.js' import type { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import type { IDataStoreMethods } from './adlibActionContext.js' +import { ITTimersContext } from './tTimersContext.js' export interface IRundownContext extends IShowStyleContext { readonly rundownId: string diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index e017db7d4a..f1228885e6 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -1,10 +1,12 @@ +export type IPlaylistTTimerIndex = 1 | 2 | 3 + export interface ITTimersContext { /** * Get a T-timer by its index * Note: Index is 1-based (1, 2, 3) * @param index Number of the timer to retrieve */ - getTimer(index: 1 | 2 | 3): IPlaylistTTimer + getTimer(index: IPlaylistTTimerIndex): IPlaylistTTimer /** * Clear all T-timers @@ -13,7 +15,7 @@ export interface ITTimersContext { } export interface IPlaylistTTimer { - readonly index: number + readonly index: IPlaylistTTimerIndex /** The label of the T-timer */ readonly label: string diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index eca17682cd..2fb66360f8 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -128,8 +128,10 @@ export interface RundownTTimerModeCountdown { stopAtZero: boolean } +export type RundownTTimerIndex = 1 | 2 | 3 + export interface RundownTTimer { - readonly index: number + readonly index: RundownTTimerIndex /** A label for the timer */ label: string diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index a476c1c593..8c7798362f 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -28,11 +28,16 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { selectNewPartWithOffsets } from '../../playout/moveNextPart.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class OnSetAsNextContext extends ShowStyleUserContext implements IOnSetAsNextContext, IEventContext, IPartAndPieceInstanceActionContext { + readonly #tTimersService: TTimersService + public pendingMoveNextPart: { selectedPart: ReadonlyDeep | null } | undefined = undefined constructor( @@ -45,6 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) + this.#tTimersService = new TTimersService(playoutModel) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -159,4 +165,11 @@ export class OnSetAsNextContext getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index 9d431d9958..def2770fc8 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -27,8 +27,13 @@ import { ActionPartChange, PartAndPieceInstanceActionService } from './services/ import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration/dist/context/quickLoopInfo' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import { TTimersService } from './services/TTimersService.js' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContext, IEventContext { + readonly #tTimersService: TTimersService + public isTakeAborted: boolean public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -52,6 +57,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex ) { super(contextInfo, _context, showStyle, watchedPackages) this.isTakeAborted = false + this.#tTimersService = new TTimersService(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { @@ -162,4 +168,11 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index a1c6849245..a97d6c7dbc 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -13,10 +13,14 @@ import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { RundownEventContext } from './RundownEventContext.js' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { setTimelineDatastoreValue, removeTimelineDatastoreValue } from '../../playout/datastore.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class RundownActivationContext extends RundownEventContext implements IRundownActivationContext { private readonly _playoutModel: PlayoutModel private readonly _context: JobContext + readonly #tTimersService: TTimersService private readonly _previousState: IRundownActivationContextState private readonly _currentState: IRundownActivationContextState @@ -43,6 +47,8 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._playoutModel = options.playoutModel this._previousState = options.previousState this._currentState = options.currentState + + this.#tTimersService = new TTimersService(this._playoutModel) } get previousState(): IRundownActivationContextState { @@ -74,4 +80,11 @@ export class RundownActivationContext extends RundownEventContext implements IRu await removeTimelineDatastoreValue(this._context, key) }) } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 3eaaf728b6..89227bcec0 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -34,6 +34,9 @@ import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration import { setNextPartFromPart } from '../../playout/setNext.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import { TTimersService } from './services/TTimersService.js' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class DatastoreActionExecutionContext extends ShowStyleUserContext @@ -66,6 +69,8 @@ export class DatastoreActionExecutionContext /** Actions */ export class ActionExecutionContext extends ShowStyleUserContext implements IActionExecutionContext, IEventContext { + readonly #tTimersService: TTimersService + /** * Whether the blueprints requested a take to be performed at the end of this action * */ @@ -102,6 +107,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService ) { super(contextInfo, _context, showStyle, watchedPackages) + this.#tTimersService = new TTimersService(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { @@ -257,4 +263,11 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts new file mode 100644 index 0000000000..95e9ceaf97 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,104 @@ +import type { + IPlaylistTTimer, + IPlaylistTTimerState, + ITTimersContext, +} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { assertNever } from '@sofie-automation/corelib/dist/lib' +import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' +import { ReadonlyDeep } from 'type-fest' + +export class TTimersService implements ITTimersContext { + readonly playoutModel: PlayoutModel + + readonly timers: [IPlaylistTTimer, IPlaylistTTimer, IPlaylistTTimer] + + constructor(playoutModel: PlayoutModel) { + this.playoutModel = playoutModel + + this.timers = [ + new PlaylistTTimerImpl(playoutModel, 1), + new PlaylistTTimerImpl(playoutModel, 2), + new PlaylistTTimerImpl(playoutModel, 3), + ] + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) + return this.timers[index - 1] + } + clearAllTimers(): void { + for (const timer of this.timers) { + timer.clearTimer() + } + } +} + +export class PlaylistTTimerImpl implements IPlaylistTTimer { + readonly #playoutModel: PlayoutModel + readonly #index: RundownTTimerIndex + + get #modelTimer(): ReadonlyDeep { + return this.#playoutModel.playlist.tTimers[this.#index - 1] + } + + // get hasChanged(): boolean { + // return this.#hasChanged + // } + + get index(): RundownTTimerIndex { + return this.#modelTimer.index + } + get label(): string { + return this.#modelTimer.label + } + get state(): IPlaylistTTimerState | null { + const rawMode = this.#modelTimer.mode + switch (rawMode?.type) { + case 'countdown': + return null + case 'freeRun': + return null + case undefined: + return null + default: + assertNever(rawMode) + return null + } + } + + constructor(playoutModel: PlayoutModel, index: RundownTTimerIndex) { + this.#playoutModel = playoutModel + this.#index = index + + if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) + } + + setLabel(label: string): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + label: label, + }) + } + clearTimer(): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: null, + }) + } + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { + throw new Error('Method not implemented.') + } + startFreeRun(options?: { startPaused?: boolean }): void { + throw new Error('Method not implemented.') + } + pause(): boolean { + throw new Error('Method not implemented.') + } + resume(): boolean { + throw new Error('Method not implemented.') + } + restart(): boolean { + throw new Error('Method not implemented.') + } +} diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 0dff06ff91..439d58b895 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -17,6 +17,7 @@ import { DBRundownPlaylist, QuickLoopMarker, RundownHoldState, + RundownTTimer, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' import { StudioPlayoutModelBase, StudioPlayoutModelBaseReadonly } from '../../studio/model/StudioPlayoutModel.js' @@ -374,6 +375,12 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void + /** + * Update a T-timer + * @param timer Timer properties + */ + updateTTimer(timer: RundownTTimer): void + calculatePartTimings( fromPartInstance: PlayoutPartInstanceModel | null, toPartInstance: PlayoutPartInstanceModel, diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 52253f1a2f..2497c589eb 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -16,6 +16,7 @@ import { DBRundownPlaylist, QuickLoopMarker, RundownHoldState, + RundownTTimer, SelectedPartInstance, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' @@ -877,6 +878,10 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + updateTTimer(timer: RundownTTimer): void { + // TODO + } + #lastMonotonicNowInPlayout = getCurrentTime() getNowInPlayout(): number { const nowOffsetLatency = this.getNowOffsetLatency() ?? 0 diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 902eeb38c1..eb61a94b06 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -237,9 +237,9 @@ export function produceRundownPlaylistInfoFromRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], ...clone(existingPlaylist), @@ -338,9 +338,9 @@ function defaultPlaylistForRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], ...clone(existingPlaylist), From 7395d85df96f2cdaeb782b06058d0d776ee668e1 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 14:27:48 +0000 Subject: [PATCH 09/79] wip: first draft complete? --- .../src/context/tTimersContext.ts | 2 +- .../corelib/src/dataModel/RundownPlaylist.ts | 16 ++- .../context/services/TTimersService.ts | 78 +++++++++--- .../model/implementation/PlayoutModelImpl.ts | 6 +- packages/job-worker/src/playout/tTimers.ts | 119 ++++++++++++++++++ .../src/__mocks__/defaultCollectionObjects.ts | 2 +- .../lib/__tests__/rundownTiming.test.ts | 2 +- 7 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 packages/job-worker/src/playout/tTimers.ts diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index f1228885e6..ee4d86afc4 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -83,7 +83,7 @@ export interface IPlaylistTTimerStateCountdown { } export interface IPlaylistTTimerStateFreeRun { /** The mode of the T-timer */ - readonly mode: 'freerun' + readonly mode: 'freeRun' /** The current time of the freerun, in milliseconds */ readonly currentTime: number /** Whether the timer is currently paused */ diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 2fb66360f8..bcdf828aa9 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -98,25 +98,31 @@ export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCoun export interface RundownTTimerModeFreeRun { readonly type: 'freeRun' - /** Starting time (unix timestamp) */ + /** + * Starting time (unix timestamp) + * This may not be the original start time, if the timer has been paused/resumed + */ startTime: number /** * Set to a timestamp to pause the timer at that timestamp * When unpausing, the `startTime` should be adjusted to account for the paused duration */ - paused: number | null + pauseTime: number | null /** The direction to count */ // direction: 'up' | 'down' // TODO: does this make sense? } export interface RundownTTimerModeCountdown { readonly type: 'countdown' - /** The target time (unix timestamp) */ - targetTime: number + /** + * Starting time (unix timestamp) + * This may not be the original start time, if the timer has been paused/resumed + */ + startTime: number /** * Set to a timestamp to pause the timer at that timestamp * When unpausing, the `targetTime` should be adjusted to account for the paused duration */ - paused: number | null + pauseTime: number | null /** * The duration of the countdown in milliseconds */ diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 95e9ceaf97..bc44b8919e 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -1,17 +1,25 @@ import type { IPlaylistTTimer, IPlaylistTTimerState, - ITTimersContext, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { assertNever } from '@sofie-automation/corelib/dist/lib' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' +import { + calculateTTimerCurrentTime, + createCountdownTTimer, + createFreeRunTTimer, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, +} from '../../../playout/tTimers.js' -export class TTimersService implements ITTimersContext { +export class TTimersService { readonly playoutModel: PlayoutModel - readonly timers: [IPlaylistTTimer, IPlaylistTTimer, IPlaylistTTimer] + readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] constructor(playoutModel: PlayoutModel) { this.playoutModel = playoutModel @@ -24,7 +32,7 @@ export class TTimersService implements ITTimersContext { } getTimer(index: RundownTTimerIndex): IPlaylistTTimer { - if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) + validateTTimerIndex(index) return this.timers[index - 1] } clearAllTimers(): void { @@ -42,10 +50,6 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { return this.#playoutModel.playlist.tTimers[this.#index - 1] } - // get hasChanged(): boolean { - // return this.#hasChanged - // } - get index(): RundownTTimerIndex { return this.#modelTimer.index } @@ -56,9 +60,19 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { const rawMode = this.#modelTimer.mode switch (rawMode?.type) { case 'countdown': - return null + return { + mode: 'countdown', + currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + duration: rawMode.duration, + paused: !rawMode.pauseTime, + stopAtZero: rawMode.stopAtZero, + } case 'freeRun': - return null + return { + mode: 'freeRun', + currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + paused: !rawMode.pauseTime, + } case undefined: return null default: @@ -71,7 +85,7 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#playoutModel = playoutModel this.#index = index - if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) + validateTTimerIndex(index) } setLabel(label: string): void { @@ -87,18 +101,50 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { }) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { - throw new Error('Method not implemented.') + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createCountdownTTimer(duration, { + stopAtZero: options?.stopAtZero ?? true, + startPaused: options?.startPaused ?? false, + }), + }) } startFreeRun(options?: { startPaused?: boolean }): void { - throw new Error('Method not implemented.') + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createFreeRunTTimer({ + startPaused: options?.startPaused ?? false, + }), + }) } pause(): boolean { - throw new Error('Method not implemented.') + const newTimer = pauseTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true } resume(): boolean { - throw new Error('Method not implemented.') + const newTimer = resumeTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true } restart(): boolean { - throw new Error('Method not implemented.') + const newTimer = restartTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true } } diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 2497c589eb..a593fad9c6 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -72,6 +72,7 @@ import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout import { NotificationsModelHelper } from '../../../notifications/NotificationsModelHelper.js' import { getExpectedLatency } from '@sofie-automation/corelib/dist/studio/playout' import { ExpectedPackage } from '@sofie-automation/blueprints-integration' +import { validateTTimerIndex } from '../../tTimers.js' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -879,7 +880,10 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou } updateTTimer(timer: RundownTTimer): void { - // TODO + validateTTimerIndex(timer.index) + + this.playlistImpl.tTimers[timer.index - 1] = timer + this.#playlistHasChanged = true } #lastMonotonicNowInPlayout = getCurrentTime() diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts new file mode 100644 index 0000000000..7038db3675 --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,119 @@ +import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { getCurrentTime } from '../lib/index.js' +import type { ReadonlyDeep } from 'type-fest' + +export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { + if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) +} + +/** + * Returns an updated T-timer in the paused state (if supported) + * @param timer Timer to update + * @returns If the timer supports pausing, the timer in paused state, otherwise null + */ +export function pauseTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { + if (timer?.type === 'countdown' || timer?.type === 'freeRun') { + return { + ...timer, + pauseTime: getCurrentTime(), + } + } else { + return null + } +} + +/** + * Returns an updated T-timer in the resumed state (if supported) + * @param timer Timer to update + * @returns If the timer supports pausing, the timer in resumed state, otherwise null + */ +export function resumeTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { + if (timer?.type === 'countdown' || timer?.type === 'freeRun') { + if (timer.pauseTime === null) { + // Already running + return timer + } + + const pausedOffset = timer.startTime - timer.pauseTime + const newStartTime = getCurrentTime() + pausedOffset + + return { + ...timer, + startTime: newStartTime, + pauseTime: null, + } + } else { + return null + } +} + +/** + * Returns an updated T-timer, after restarting (if supported) + * @param timer Timer to update + * @returns If the timer supports restarting, the restarted timer, otherwise null + */ +export function restartTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { + if (timer?.type === 'countdown') { + return { + ...timer, + startTime: getCurrentTime(), + pauseTime: timer.pauseTime ? getCurrentTime() : null, + } + } else { + return null + } +} + +/** + * Create a new countdown T-timer + * @param index Timer index + * @param duration Duration in milliseconds + * @param options Options for the countdown + * @returns The created T-timer + */ +export function createCountdownTTimer( + duration: number, + options: { + stopAtZero: boolean + startPaused: boolean + } +): ReadonlyDeep { + if (duration <= 0) throw new Error('Duration must be greater than zero') + + const now = getCurrentTime() + return { + type: 'countdown', + startTime: now, + pauseTime: options.startPaused ? now : null, + duration, + stopAtZero: !!options.stopAtZero, + } +} + +/** + * Create a new free-running T-timer + * @param index Timer index + * @param options Options for the free-run + * @returns The created T-timer + */ +export function createFreeRunTTimer(options: { startPaused: boolean }): ReadonlyDeep { + const now = getCurrentTime() + return { + type: 'freeRun', + startTime: now, + pauseTime: options.startPaused ? now : null, + } +} + +/** + * Calculate the current time of a T-timer + * @param startTime The start time of the timer (unix timestamp) + * @param pauseTime The pause time of the timer (unix timestamp) or null if not paused + */ +export function calculateTTimerCurrentTime(startTime: number, pauseTime: number | null): number { + if (pauseTime) { + return pauseTime - startTime + } else { + return getCurrentTime() - startTime + } +} diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index bba6689126..161bbec448 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -49,9 +49,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], } } diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index afe153eb73..f57f33d4ed 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -30,9 +30,9 @@ function makeMockPlaylist(): DBRundownPlaylist { rundownIdsInOrder: [], tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], }) } From bf6fc3d5be5b15103ef8328f9537df704cf69bd6 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 15:34:56 +0000 Subject: [PATCH 10/79] wip: tests --- .../src/__mocks__/defaultCollectionObjects.ts | 2 +- .../context/services/TTimersService.ts | 4 +- .../services/__tests__/TTimersService.test.ts | 522 ++++++++++++++++++ .../__tests__/externalMessageQueue.test.ts | 10 + .../syncChangesToPartInstance.test.ts | 5 + .../src/ingest/__tests__/updateNext.test.ts | 5 + .../__snapshots__/mosIngest.test.ts.snap | 255 +++++++++ .../__snapshots__/playout.test.ts.snap | 17 + .../src/playout/__tests__/tTimers.test.ts | 351 ++++++++++++ packages/job-worker/src/playout/tTimers.ts | 7 +- .../src/topics/__tests__/utils.ts | 1 + 11 files changed, 1175 insertions(+), 4 deletions(-) create mode 100644 packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts create mode 100644 packages/job-worker/src/playout/__tests__/tTimers.test.ts diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 7a2c601e72..8d705cc1b7 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -46,9 +46,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rundownIdsInOrder: [], tTimers: [ - { index: 0, label: '', mode: null }, { index: 1, label: '', mode: null }, { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, ], } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index bc44b8919e..3a45741390 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -64,14 +64,14 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { mode: 'countdown', currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), duration: rawMode.duration, - paused: !rawMode.pauseTime, + paused: !!rawMode.pauseTime, stopAtZero: rawMode.stopAtZero, } case 'freeRun': return { mode: 'freeRun', currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), - paused: !rawMode.pauseTime, + paused: !!rawMode.pauseTime, } case undefined: return null diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts new file mode 100644 index 0000000000..dba52e91d7 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,522 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { useFakeCurrentTime, useRealCurrentTime } from '../../../../__mocks__/time.js' +import { TTimersService, PlaylistTTimerImpl } from '../TTimersService.js' +import type { PlayoutModel } from '../../../../playout/model/PlayoutModel.js' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { mock, MockProxy } from 'jest-mock-extended' +import type { ReadonlyDeep } from 'type-fest' + +function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { + const mockPlayoutModel = mock() + const mockPlaylist = { + tTimers, + } as unknown as ReadonlyDeep + + Object.defineProperty(mockPlayoutModel, 'playlist', { + get: () => mockPlaylist, + configurable: true, + }) + + return mockPlayoutModel +} + +function createEmptyTTimers(): [RundownTTimer, RundownTTimer, RundownTTimer] { + return [ + { index: 1, label: 'Timer 1', mode: null }, + { index: 2, label: 'Timer 2', mode: null }, + { index: 3, label: 'Timer 3', mode: null }, + ] +} + +describe('TTimersService', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('constructor', () => { + it('should create three timer instances', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + const service = new TTimersService(mockPlayoutModel) + + expect(service.timers).toHaveLength(3) + expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[1]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[2]).toBeInstanceOf(PlaylistTTimerImpl) + }) + }) + + describe('getTimer', () => { + it('should return the correct timer for index 1', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(1) + + expect(timer).toBe(service.timers[0]) + }) + + it('should return the correct timer for index 2', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(2) + + expect(timer).toBe(service.timers[1]) + }) + + it('should return the correct timer for index 3', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(3) + + expect(timer).toBe(service.timers[2]) + }) + + it('should throw for invalid index', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') + expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') + }) + }) + + describe('clearAllTimers', () => { + it('should call clearTimer on all timers', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[1].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const service = new TTimersService(mockPlayoutModel) + + service.clearAllTimers() + + // updateTTimer should have been called 3 times (once for each timer) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledTimes(3) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, mode: null }) + ) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 2, mode: null }) + ) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 3, mode: null }) + ) + }) + }) +}) + +describe('PlaylistTTimerImpl', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('getters', () => { + it('should return the correct index', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + + expect(timer.index).toBe(2) + }) + + it('should return the correct label', () => { + const tTimers = createEmptyTTimers() + tTimers[1].label = 'Custom Label' + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + + expect(timer.label).toBe('Custom Label') + }) + + it('should return null state when no mode is set', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toBeNull() + }) + + it('should return running freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 5000, // 10000 - 5000 + paused: false, // pauseTime is null = running + }) + }) + + it('should return paused freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 3000, // 8000 - 5000 + paused: true, // pauseTime is set = paused + }) + }) + + it('should return running countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 5000, // 10000 - 5000 + duration: 60000, + paused: false, // pauseTime is null = running + stopAtZero: true, + }) + }) + + it('should return paused countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: 7000, + duration: 60000, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 2000, // 7000 - 5000 + duration: 60000, + paused: true, // pauseTime is set = paused + stopAtZero: false, + }) + }) + }) + + describe('setLabel', () => { + it('should update the label', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.setLabel('New Label') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'New Label', + mode: null, + }) + }) + }) + + describe('clearTimer', () => { + it('should clear the timer mode', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.clearTimer() + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + }) + }) + }) + + describe('startCountdown', () => { + it('should start a running countdown with default options', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startCountdown(60000) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should start a paused countdown', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: 10000, + duration: 30000, + stopAtZero: false, + }, + }) + }) + }) + + describe('startFreeRun', () => { + it('should start a running free-run timer', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startFreeRun() + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 10000, + pauseTime: null, + }, + }) + }) + + it('should start a paused free-run timer', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startFreeRun({ startPaused: true }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 10000, + pauseTime: 10000, + }, + }) + }) + }) + + describe('pause', () => { + it('should pause a running freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 5000, + pauseTime: 10000, + }, + }) + }) + + it('should pause a running countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 5000, + pauseTime: 10000, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('resume', () => { + it('should resume a paused freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 7000, // adjusted for pause duration + pauseTime: null, + }, + }) + }) + + it('should return true but not change a running timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + // Returns true because timer supports resume, but it's already running + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('restart', () => { + it('should restart a countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, // reset to now + pauseTime: null, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should restart a paused countdown timer (stays paused)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: 8000, + duration: 60000, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: 10000, // also reset to now (paused at start) + duration: 60000, + stopAtZero: false, + }, + }) + }) + + it('should return false for freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('constructor validation', () => { + it('should throw for invalid index', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + expect(() => new PlaylistTTimerImpl(mockPlayoutModel, 0 as RundownTTimerIndex)).toThrow( + 'T-timer index out of range: 0' + ) + expect(() => new PlaylistTTimerImpl(mockPlayoutModel, 4 as RundownTTimerIndex)).toThrow( + 'T-timer index out of range: 4' + ) + }) + }) +}) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 7631c647c5..a39d82f7cc 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -56,6 +56,11 @@ describe('Test external message queue static methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) await context.mockCollections.Rundowns.insertOne({ _id: protectString('rundown_1'), @@ -201,6 +206,11 @@ describe('Test sending messages to mocked endpoints', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) const rundown = (await context.mockCollections.Rundowns.findOne(rundownId)) as DBRundown diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index b663ad3501..47ddfed664 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -315,6 +315,11 @@ describe('SyncChangesToPartInstancesWorker', () => { modified: 0, timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } const segmentModel = new PlayoutSegmentModelImpl(segment, [part0]) diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index b92cbe7766..91df4cc24e 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -34,6 +34,11 @@ async function createMockRO(context: MockJobContext): Promise { }, rundownIdsInOrder: [rundownId], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) await context.mockCollections.Rundowns.insertOne({ diff --git a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap index 8c1b68d443..45148a92f6 100644 --- a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap +++ b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap @@ -15,6 +15,23 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -307,6 +324,23 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -591,6 +625,23 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -896,6 +947,23 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1191,6 +1259,23 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1484,6 +1569,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1745,6 +1847,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2051,6 +2170,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2365,6 +2501,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2662,6 +2815,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2959,6 +3129,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3255,6 +3442,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3544,6 +3748,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3865,6 +4086,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -4162,6 +4400,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap index d99635086b..8017111a4f 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap @@ -77,6 +77,23 @@ exports[`Playout API Basic rundown control 4`] = ` "resetTime": 0, "rundownIdsInOrder": [], "studioId": "mockStudio0", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts new file mode 100644 index 0000000000..6e3b395857 --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,351 @@ +import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { + validateTTimerIndex, + pauseTTimer, + resumeTTimer, + restartTTimer, + createCountdownTTimer, + createFreeRunTTimer, + calculateTTimerCurrentTime, +} from '../tTimers.js' +import type { RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +describe('tTimers utils', () => { + beforeEach(() => { + useFakeCurrentTime(10000) // Set a fixed time for tests + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('validateTTimerIndex', () => { + it('should accept valid indices 1, 2, 3', () => { + expect(() => validateTTimerIndex(1)).not.toThrow() + expect(() => validateTTimerIndex(2)).not.toThrow() + expect(() => validateTTimerIndex(3)).not.toThrow() + }) + + it('should reject index 0', () => { + expect(() => validateTTimerIndex(0)).toThrow('T-timer index out of range: 0') + }) + + it('should reject index 4', () => { + expect(() => validateTTimerIndex(4)).toThrow('T-timer index out of range: 4') + }) + + it('should reject negative indices', () => { + expect(() => validateTTimerIndex(-1)).toThrow('T-timer index out of range: -1') + }) + + it('should reject NaN', () => { + expect(() => validateTTimerIndex(NaN)).toThrow('T-timer index out of range: NaN') + }) + }) + + describe('pauseTTimer', () => { + it('should pause a running countdown timer', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + + const result = pauseTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 5000, + pauseTime: 10000, // getCurrentTime() + duration: 60000, + stopAtZero: true, + }) + }) + + it('should pause a running freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, + } + + const result = pauseTTimer(timer) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 5000, + pauseTime: 10000, + }) + }) + + it('should return unchanged countdown timer if already paused', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: 7000, // already paused + duration: 60000, + stopAtZero: true, + } + + const result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + it('should return unchanged freeRun timer if already paused', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: 7000, // already paused + } + + const result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + it('should return null for null timer', () => { + expect(pauseTTimer(null)).toBeNull() + }) + }) + + describe('resumeTTimer', () => { + it('should resume a paused countdown timer', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: 8000, // paused 3 seconds after start + duration: 60000, + stopAtZero: true, + } + + const result = resumeTTimer(timer) + + // pausedOffset = 5000 - 8000 = -3000 + // newStartTime = 10000 + (-3000) = 7000 + expect(result).toEqual({ + type: 'countdown', + startTime: 7000, // 3 seconds before now + pauseTime: null, + duration: 60000, + stopAtZero: true, + }) + }) + + it('should resume a paused freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 2000, + pauseTime: 6000, // paused 4 seconds after start + } + + const result = resumeTTimer(timer) + + // pausedOffset = 2000 - 6000 = -4000 + // newStartTime = 10000 + (-4000) = 6000 + expect(result).toEqual({ + type: 'freeRun', + startTime: 6000, // 4 seconds before now + pauseTime: null, + }) + }) + + it('should return countdown timer unchanged if already running', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, // already running + duration: 60000, + stopAtZero: true, + } + + const result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + it('should return freeRun timer unchanged if already running', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, // already running + } + + const result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + it('should return null for null timer', () => { + expect(resumeTTimer(null)).toBeNull() + }) + }) + + describe('restartTTimer', () => { + it('should restart a running countdown timer', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, // now + pauseTime: null, + duration: 60000, + stopAtZero: true, + }) + }) + + it('should restart a paused countdown timer (stays paused)', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: 8000, + duration: 60000, + stopAtZero: false, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, // now + pauseTime: 10000, // also now (paused at start) + duration: 60000, + stopAtZero: false, + }) + }) + + it('should return null for freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, + } + + expect(restartTTimer(timer)).toBeNull() + }) + + it('should return null for null timer', () => { + expect(restartTTimer(null)).toBeNull() + }) + }) + + describe('createCountdownTTimer', () => { + it('should create a running countdown timer', () => { + const result = createCountdownTTimer(60000, { + stopAtZero: true, + startPaused: false, + }) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + }) + }) + + it('should create a paused countdown timer', () => { + const result = createCountdownTTimer(30000, { + stopAtZero: false, + startPaused: true, + }) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, + pauseTime: 10000, + duration: 30000, + stopAtZero: false, + }) + }) + + it('should throw for zero duration', () => { + expect(() => + createCountdownTTimer(0, { + stopAtZero: true, + startPaused: false, + }) + ).toThrow('Duration must be greater than zero') + }) + + it('should throw for negative duration', () => { + expect(() => + createCountdownTTimer(-1000, { + stopAtZero: true, + startPaused: false, + }) + ).toThrow('Duration must be greater than zero') + }) + }) + + describe('createFreeRunTTimer', () => { + it('should create a running freeRun timer', () => { + const result = createFreeRunTTimer({ startPaused: false }) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 10000, + pauseTime: null, + }) + }) + + it('should create a paused freeRun timer', () => { + const result = createFreeRunTTimer({ startPaused: true }) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 10000, + pauseTime: 10000, + }) + }) + }) + + describe('calculateTTimerCurrentTime', () => { + it('should calculate time for a running timer', () => { + // Timer started at 5000, current time is 10000 + const result = calculateTTimerCurrentTime(5000, null) + + expect(result).toBe(5000) // 10000 - 5000 + }) + + it('should calculate time for a paused timer', () => { + // Timer started at 5000, paused at 8000 + const result = calculateTTimerCurrentTime(5000, 8000) + + expect(result).toBe(3000) // 8000 - 5000 + }) + + it('should handle timer that just started', () => { + const result = calculateTTimerCurrentTime(10000, null) + + expect(result).toBe(0) + }) + + it('should handle timer paused immediately', () => { + const result = calculateTTimerCurrentTime(10000, 10000) + + expect(result).toBe(0) + }) + + it('should update as time progresses', () => { + const startTime = 5000 + + expect(calculateTTimerCurrentTime(startTime, null)).toBe(5000) + + adjustFakeTime(2000) // Now at 12000 + + expect(calculateTTimerCurrentTime(startTime, null)).toBe(7000) + }) + }) +}) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 7038db3675..7bf8d70223 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -13,6 +13,11 @@ export function validateTTimerIndex(index: number): asserts index is RundownTTim */ export function pauseTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { if (timer?.type === 'countdown' || timer?.type === 'freeRun') { + if (timer.pauseTime) { + // Already paused + return timer + } + return { ...timer, pauseTime: getCurrentTime(), @@ -29,7 +34,7 @@ export function pauseTTimer(timer: ReadonlyDeep | null): Read */ export function resumeTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { if (timer?.type === 'countdown' || timer?.type === 'freeRun') { - if (timer.pauseTime === null) { + if (!timer.pauseTime) { // Already running return timer } diff --git a/packages/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 576b1cb743..23b70507c1 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -34,6 +34,7 @@ export function makeTestPlaylist(id?: string): DBRundownPlaylist { studioId: protectString('STUDIO_1'), timing: { type: PlaylistTimingType.None }, publicData: { a: 'b' }, + tTimers: [] as any, } } From 47e38b4ddd6b12f40b06438bf5f0fee9e755bc63 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 15:49:42 +0000 Subject: [PATCH 11/79] lint --- meteor/__mocks__/defaultCollectionObjects.ts | 5 +++++ meteor/server/__tests__/cronjobs.test.ts | 1 + meteor/server/api/__tests__/externalMessageQueue.test.ts | 1 + meteor/server/api/__tests__/peripheralDevice.test.ts | 1 + 4 files changed, 8 insertions(+) diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index fa8a8934ed..faec0a06be 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -52,6 +52,11 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: 'none' as any, }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index c61e36bdcb..9133eb4439 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -618,6 +618,7 @@ describe('cronjobs', () => { type: PlaylistTimingType.None, }, activationId: protectString(''), + tTimers: [] as any, }) return { diff --git a/meteor/server/api/__tests__/externalMessageQueue.test.ts b/meteor/server/api/__tests__/externalMessageQueue.test.ts index 801220a8f8..1b5fb53f93 100644 --- a/meteor/server/api/__tests__/externalMessageQueue.test.ts +++ b/meteor/server/api/__tests__/externalMessageQueue.test.ts @@ -41,6 +41,7 @@ describe('Test external message queue static methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [] as any, }) await Rundowns.mutableCollection.insertAsync({ _id: protectString('rundown_1'), diff --git a/meteor/server/api/__tests__/peripheralDevice.test.ts b/meteor/server/api/__tests__/peripheralDevice.test.ts index 3c819cf20a..594c44049c 100644 --- a/meteor/server/api/__tests__/peripheralDevice.test.ts +++ b/meteor/server/api/__tests__/peripheralDevice.test.ts @@ -78,6 +78,7 @@ describe('test peripheralDevice general API methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [rundownID], + tTimers: [] as any, }) await Rundowns.mutableCollection.insertAsync({ _id: rundownID, From baa8947db013914380ae9e8fc6ec44063e6f652c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 16:32:24 +0000 Subject: [PATCH 12/79] time of day timer mode --- .../src/context/tTimersContext.ts | 32 ++- .../corelib/src/dataModel/RundownPlaylist.ts | 21 +- packages/job-worker/package.json | 1 + .../context/services/TTimersService.ts | 18 ++ .../services/__tests__/TTimersService.test.ts | 212 +++++++++++++++ .../src/playout/__tests__/tTimers.test.ts | 252 +++++++++++++++++- packages/job-worker/src/playout/tTimers.ts | 48 ++++ packages/yarn.lock | 8 + 8 files changed, 587 insertions(+), 5 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index ee4d86afc4..8747f450a2 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -39,6 +39,13 @@ export interface IPlaylistTTimer { */ startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void + /** + * Start a timeOfDay timer, counting towards the target time + * This will throw if it is unable to parse the target time + * @param targetTime The target time, as a string (e.g. "14:30", "2023-12-31T23:59:59Z") or a timestamp number + */ + startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void + /** * Start a free-running timer */ @@ -60,13 +67,16 @@ export interface IPlaylistTTimer { /** * If the timer can be restarted, restore it to its initial/restarted state - * Note: This is supported by the countdown mode + * Note: This is supported by the countdown and timeOfDay modes * @returns True if the timer was restarted, false if it could not be restarted */ restart(): boolean } -export type IPlaylistTTimerState = IPlaylistTTimerStateCountdown | IPlaylistTTimerStateFreeRun +export type IPlaylistTTimerState = + | IPlaylistTTimerStateCountdown + | IPlaylistTTimerStateFreeRun + | IPlaylistTTimerStateTimeOfDay export interface IPlaylistTTimerStateCountdown { /** The mode of the T-timer */ @@ -89,3 +99,21 @@ export interface IPlaylistTTimerStateFreeRun { /** Whether the timer is currently paused */ readonly paused: boolean } + +export interface IPlaylistTTimerStateTimeOfDay { + /** The mode of the T-timer */ + readonly mode: 'timeOfDay' + /** The current remaining time of the timer, in milliseconds */ + readonly currentTime: number + /** The target timestamp of the timer, in milliseconds */ + readonly targetTime: number + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index bcdf828aa9..0ea7a83fe8 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,7 +94,7 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } -export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay export interface RundownTTimerModeFreeRun { readonly type: 'freeRun' @@ -131,7 +131,24 @@ export interface RundownTTimerModeCountdown { /** * If the countdown should stop at zero, or continue into negative values */ - stopAtZero: boolean + readonly stopAtZero: boolean +} +export interface RundownTTimerModeTimeOfDay { + readonly type: 'timeOfDay' + + /** The target timestamp of the timer, in milliseconds */ + targetTime: number + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean } export type RundownTTimerIndex = 1 | 2 | 3 diff --git a/packages/job-worker/package.json b/packages/job-worker/package.json index 21ed27ecfc..356f8d57a8 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -45,6 +45,7 @@ "@sofie-automation/corelib": "1.53.0-in-development", "@sofie-automation/shared-lib": "1.53.0-in-development", "amqplib": "^0.10.5", + "chrono-node": "^2.9.0", "deepmerge": "^4.3.1", "elastic-apm-node": "^4.11.0", "mongodb": "^6.12.0", diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 3a45741390..b8ef3c7e21 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -10,11 +10,13 @@ import { calculateTTimerCurrentTime, createCountdownTTimer, createFreeRunTTimer, + createTimeOfDayTTimer, pauseTTimer, restartTTimer, resumeTTimer, validateTTimerIndex, } from '../../../playout/tTimers.js' +import { getCurrentTime } from '../../../lib/time.js' export class TTimersService { readonly playoutModel: PlayoutModel @@ -73,6 +75,14 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), paused: !!rawMode.pauseTime, } + case 'timeOfDay': + return { + mode: 'timeOfDay', + currentTime: rawMode.targetTime - getCurrentTime(), + targetTime: rawMode.targetTime, + targetRaw: rawMode.targetRaw, + stopAtZero: rawMode.stopAtZero, + } case undefined: return null default: @@ -109,6 +119,14 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { }), }) } + startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createTimeOfDayTTimer(targetTime, { + stopAtZero: options?.stopAtZero ?? true, + }), + }) + } startFreeRun(options?: { startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index dba52e91d7..7943a89592 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -216,6 +216,47 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, }) }) + + it('should return timeOfDay state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, // 10 seconds in the future + targetRaw: '15:30', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: 10000, // targetTime - getCurrentTime() = 20000 - 10000 + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + }) + }) + + it('should return timeOfDay state with numeric targetRaw', () => { + const tTimers = createEmptyTTimers() + const targetTimestamp = 1737331200000 + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: targetTimestamp - 10000, // targetTime - getCurrentTime() + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: false, + }) + }) }) describe('setLabel', () => { @@ -331,6 +372,100 @@ describe('PlaylistTTimerImpl', () => { }) }) + describe('startTimeOfDay', () => { + it('should start a timeOfDay timer with time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('15:30') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + stopAtZero: true, + }, + }) + }) + + it('should start a timeOfDay timer with numeric timestamp', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const targetTimestamp = 1737331200000 + + timer.startTimeOfDay(targetTimestamp) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: true, + }, + }) + }) + + it('should start a timeOfDay timer with stopAtZero false', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('18:00', { stopAtZero: false }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetRaw: '18:00', + stopAtZero: false, + }), + }) + }) + + it('should start a timeOfDay timer with 12-hour format', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('5:30pm') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '5:30pm', + stopAtZero: true, + }), + }) + }) + + it('should throw for invalid time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') + }) + + it('should throw for empty time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') + }) + }) + describe('pause', () => { it('should pause a running freeRun timer', () => { const tTimers = createEmptyTTimers() @@ -384,6 +519,23 @@ describe('PlaylistTTimerImpl', () => { expect(result).toBe(false) expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() }) + + it('should return false for timeOfDay timer (does not support pause)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) }) describe('resume', () => { @@ -430,6 +582,23 @@ describe('PlaylistTTimerImpl', () => { expect(result).toBe(false) expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() }) + + it('should return false for timeOfDay timer (does not support resume)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) }) describe('restart', () => { @@ -495,6 +664,49 @@ describe('PlaylistTTimerImpl', () => { expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() }) + it('should restart a timeOfDay timer with valid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 5000, // old target time + targetRaw: '15:30', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + stopAtZero: true, + }, + }) + }) + + it('should return false for timeOfDay timer with invalid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 5000, + targetRaw: 'invalid-time-string', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() const mockPlayoutModel = createMockPlayoutModel(tTimers) diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts index 6e3b395857..144baca1a5 100644 --- a/packages/job-worker/src/playout/__tests__/tTimers.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' import { validateTTimerIndex, @@ -7,8 +8,13 @@ import { createCountdownTTimer, createFreeRunTTimer, calculateTTimerCurrentTime, + calculateNextTimeOfDayTarget, + createTimeOfDayTTimer, } from '../tTimers.js' -import type { RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { + RundownTTimerMode, + RundownTTimerModeTimeOfDay, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('tTimers utils', () => { beforeEach(() => { @@ -348,4 +354,248 @@ describe('tTimers utils', () => { expect(calculateTTimerCurrentTime(startTime, null)).toBe(7000) }) }) + + describe('calculateNextTimeOfDayTarget', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return number input unchanged (unix timestamp)', () => { + const timestamp = 1737331200000 // Some future timestamp + expect(calculateNextTimeOfDayTarget(timestamp)).toBe(timestamp) + }) + + it('should return null for null/undefined/empty input', () => { + expect(calculateNextTimeOfDayTarget('' as string)).toBeNull() + expect(calculateNextTimeOfDayTarget(' ')).toBeNull() + }) + + // 24-hour time formats + it('should parse 24-hour time HH:mm', () => { + const result = calculateNextTimeOfDayTarget('13:34') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T13:34:00.000Z') + }) + + it('should parse 24-hour time H:mm (single digit hour)', () => { + const result = calculateNextTimeOfDayTarget('9:05') + expect(result).not.toBeNull() + // 9:05 is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T09:05:00.000Z') + }) + + it('should parse 24-hour time with seconds HH:mm:ss', () => { + const result = calculateNextTimeOfDayTarget('14:30:45') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T14:30:45.000Z') + }) + + // 12-hour time formats + it('should parse 12-hour time with pm', () => { + const result = calculateNextTimeOfDayTarget('5:13pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T17:13:00.000Z') + }) + + it('should parse 12-hour time with PM (uppercase)', () => { + const result = calculateNextTimeOfDayTarget('5:13PM') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T17:13:00.000Z') + }) + + it('should parse 12-hour time with am', () => { + const result = calculateNextTimeOfDayTarget('9:30am') + expect(result).not.toBeNull() + // 9:30am is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T09:30:00.000Z') + }) + + it('should parse 12-hour time with space before am/pm', () => { + const result = calculateNextTimeOfDayTarget('3:45 pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:45:00.000Z') + }) + + it('should parse 12-hour time with seconds', () => { + const result = calculateNextTimeOfDayTarget('11:30:15pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T23:30:15.000Z') + }) + + // Date + time formats + it('should parse date with time (slash separator)', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 15:43') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + it('should parse date with time and seconds', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 15:43:30') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:30.000Z') + }) + + it('should parse date with 12-hour time', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 3:43pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + // ISO 8601 format + it('should parse ISO 8601 format', () => { + const result = calculateNextTimeOfDayTarget('2026-01-19T15:43:00') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + it('should parse ISO 8601 with timezone', () => { + const result = calculateNextTimeOfDayTarget('2026-01-19T15:43:00+01:00') + expect(result).not.toBeNull() + // +01:00 means the time is 1 hour ahead of UTC, so 15:43 +01:00 = 14:43 UTC + expect(new Date(result!).toISOString()).toBe('2026-01-19T14:43:00.000Z') + }) + + // Natural language formats (chrono-node strength) + it('should parse natural language date', () => { + const result = calculateNextTimeOfDayTarget('January 19, 2026 at 3:30pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:30:00.000Z') + }) + + it('should parse "noon"', () => { + const result = calculateNextTimeOfDayTarget('noon') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T12:00:00.000Z') + }) + + it('should parse "midnight"', () => { + const result = calculateNextTimeOfDayTarget('midnight') + expect(result).not.toBeNull() + // Midnight is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T00:00:00.000Z') + }) + + // Edge cases + it('should return null for invalid time string', () => { + expect(calculateNextTimeOfDayTarget('not a time')).toBeNull() + }) + + it('should return null for gibberish', () => { + expect(calculateNextTimeOfDayTarget('asdfghjkl')).toBeNull() + }) + }) + + describe('createTimeOfDayTTimer', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should create a timeOfDay timer with valid time string', () => { + const result = createTimeOfDayTTimer('15:30', { stopAtZero: true }) + + expect(result).toEqual({ + type: 'timeOfDay', + stopAtZero: true, + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + }) + }) + + it('should create a timeOfDay timer with numeric timestamp', () => { + const timestamp = 1737331200000 + const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) + + expect(result).toEqual({ + type: 'timeOfDay', + targetTime: timestamp, + targetRaw: timestamp, + stopAtZero: false, + }) + }) + + it('should throw for invalid time string', () => { + expect(() => createTimeOfDayTTimer('invalid', { stopAtZero: true })).toThrow( + 'Unable to parse target time for timeOfDay T-timer' + ) + }) + + it('should throw for empty string', () => { + expect(() => createTimeOfDayTTimer('', { stopAtZero: true })).toThrow( + 'Unable to parse target time for timeOfDay T-timer' + ) + }) + }) + + describe('restartTTimer with timeOfDay', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should restart a timeOfDay timer with valid targetRaw', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: '15:30', + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + ...timer, + targetTime: expect.any(Number), // new target time + }) + expect((result as RundownTTimerModeTimeOfDay).targetTime).toBeGreaterThan(timer.targetTime) + }) + + it('should return null for timeOfDay timer with invalid targetRaw', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: 'invalid', + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + + it('should return null for timeOfDay timer with unix timestamp', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: 1737300000000, + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + }) }) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 7bf8d70223..5477491d71 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,6 +1,7 @@ import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' +import * as chrono from 'chrono-node' export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) @@ -64,6 +65,15 @@ export function restartTTimer(timer: ReadonlyDeep | null): Re startTime: getCurrentTime(), pauseTime: timer.pauseTime ? getCurrentTime() : null, } + } else if (timer?.type === 'timeOfDay') { + const nextTime = calculateNextTimeOfDayTarget(timer.targetRaw) + // If we can't calculate the next time, we can't restart + if (nextTime === null || nextTime === timer.targetTime) return null + + return { + ...timer, + targetTime: nextTime, + } } else { return null } @@ -95,6 +105,23 @@ export function createCountdownTTimer( } } +export function createTimeOfDayTTimer( + targetTime: string | number, + options: { + stopAtZero: boolean + } +): ReadonlyDeep { + const nextTime = calculateNextTimeOfDayTarget(targetTime) + if (nextTime === null) throw new Error('Unable to parse target time for timeOfDay T-timer') + + return { + type: 'timeOfDay', + targetTime: nextTime, + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + } +} + /** * Create a new free-running T-timer * @param index Timer index @@ -122,3 +149,24 @@ export function calculateTTimerCurrentTime(startTime: number, pauseTime: number return getCurrentTime() - startTime } } + +/** + * Calculate the next target time for a timeOfDay T-timer + * @param targetTime The target time, as a string or timestamp number + * @returns The next target timestamp in milliseconds, or null if it could not be calculated + */ +export function calculateNextTimeOfDayTarget(targetTime: string | number): number | null { + if (typeof targetTime === 'number') { + // This should be a unix timestamp + return targetTime + } + + // Verify we have a string worth parsing + if (typeof targetTime !== 'string' || !targetTime) return null + + const parsed = chrono.parseDate(targetTime, undefined, { + // Always look ahead for the next occurrence + forwardDate: true, + }) + return parsed ? parsed.getTime() : null +} diff --git a/packages/yarn.lock b/packages/yarn.lock index 34c5ff0f28..e80c9a5adc 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -6998,6 +6998,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" jest: "npm:^29.7.0" @@ -11347,6 +11348,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"chrono-node@npm:^2.9.0": + version: 2.9.0 + resolution: "chrono-node@npm:2.9.0" + checksum: 10/a30bbaa67f9a127e711db6e694ee4c89292d8f533dbfdc3d7cb34f479728e02e377f682e75ad84dd4b6a16016c248a5e85fb453943b96f93f5993f5ccddc6d08 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.8.0 resolution: "ci-info@npm:3.8.0" From 91502126f22518c5bcd16c0aa15d686405227389 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 17:02:29 +0000 Subject: [PATCH 13/79] lockfile --- meteor/yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index b5bebd1474..b9708443a2 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1208,6 +1208,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" mongodb: "npm:^6.12.0" @@ -2899,6 +2900,13 @@ __metadata: languageName: node linkType: hard +"chrono-node@npm:^2.9.0": + version: 2.9.0 + resolution: "chrono-node@npm:2.9.0" + checksum: 10/a30bbaa67f9a127e711db6e694ee4c89292d8f533dbfdc3d7cb34f479728e02e377f682e75ad84dd4b6a16016c248a5e85fb453943b96f93f5993f5ccddc6d08 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.8.0 resolution: "ci-info@npm:3.8.0" From 7a3ceca5dfeeb29ff675d5c3a1eab915214a3805 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:44:17 +0100 Subject: [PATCH 14/79] SOFIE-261 | add UI for t-timers (WIP) --- .../webui/src/client/styles/rundownView.scss | 80 +++++++++-- .../RundownHeader/RundownHeaderTimers.tsx | 124 ++++++++++++++++++ .../RundownHeader/TimingDisplay.tsx | 2 + 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index 32ee778f21..31ca322601 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -209,8 +209,13 @@ body.no-overflow { bottom: 0; right: 0; - background: - linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), + background: linear-gradient( + -45deg, + $color-status-fatal 33%, + transparent 33%, + transparent 66%, + $color-status-fatal 66% + ), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%); @@ -266,7 +271,15 @@ body.no-overflow { .timing__header__left { text-align: left; + display: flex; + } + + .timing__header__center { + display: flex; + justify-content: center; + align-items: center; } + .timing__header__right { display: grid; grid-template-columns: auto auto; @@ -1100,8 +1113,7 @@ svg.icon { } .segment-timeline__part { .segment-timeline__part__invalid-cover { - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 4px, @@ -1383,8 +1395,7 @@ svg.icon { left: 2px; right: 2px; z-index: 3; - background: - repeating-linear-gradient( + background: repeating-linear-gradient( 45deg, var(--invalid-reason-color-opaque) 0, var(--invalid-reason-color-opaque) 5px, @@ -1566,8 +1577,7 @@ svg.icon { right: 1px; z-index: 10; pointer-events: all; - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, @@ -3573,3 +3583,57 @@ svg.icon { } @import 'rundownOverview'; + +.rundown-header .timing__header_t-timers { + display: flex; + flex-direction: column; + justify-content: center; + margin-right: 1em; + padding-left: 1em; + text-align: right; + align-self: center; + width: fit-content; + + .timing__header_t-timers__timer { + display: flex; + gap: 0.5em; + justify-content: space-between; + align-items: baseline; + + .timing__header_t-timers__timer__label { + font-size: 0.7em; + color: #b8b8b8; + text-transform: uppercase; + } + + .timing__header_t-timers__timer__value { + font-family: + 'Roboto', + Helvetica Neue, + Arial, + sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: $general-clock; + font-size: 1.1em; + } + + .timing__header_t-timers__timer__sign { + margin-right: 0.2em; + font-weight: 700; + color: #fff; + } + + .timing__header_t-timers__timer__part { + color: white; + &.timing__header_t-timers__timer__part--dimmed { + color: #888; // Dimmed color for "00" + font-weight: 400; + } + } + .timing__header_t-timers__timer__separator { + margin: 0 0.05em; + color: #888; + } + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx new file mode 100644 index 0000000000..4aafecf659 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' +import classNames from 'classnames' + +interface IProps { + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] +} + +export const RundownHeaderTimers: React.FC = ({ tTimers }) => { + useTiming() + + // TODO: Remove this mock data once verified + const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { + const now = Date.now() + return [ + { + index: 1, + label: 'Timer 1', + mode: { type: 'freeRun', startTime: now - 60000 }, + }, + { + index: 2, + label: 'Timer 2', + mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, + }, + { + index: 3, + label: 'Timer 3', + mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, + }, + ] + }, []) + + tTimers = mockTimers + + const hasActiveTimers = tTimers.some((t) => t.mode) + + if (!hasActiveTimers) return null + + return ( +
+ {tTimers.map((timer) => ( + + ))} +
+ ) +} + +interface ISingleTimerProps { + timer: RundownTTimer +} + +function SingleTimer({ timer }: ISingleTimerProps) { + if (!timer.mode) return null + + const now = Date.now() + + const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + + const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) + const parts = timeStr.split(':') + + const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + + return ( +
+ {timer.label} +
+ {timerSign} + {parts.map((p, i) => ( + + + {p} + + {i < parts.length - 1 && :} + + ))} +
+
+ ) +} + +function calculateDiff( + timer: RundownTTimer, + now: number +): { + diff: number + isNegative: boolean + isFreeRun: boolean +} { + if (timer.mode!.type === 'freeRun') { + const startTime = timer.mode!.startTime + const diff = now - startTime + return { diff, isNegative: false, isFreeRun: true } + } else if (timer.mode!.type === 'countdown') { + const endTime = timer.mode!.startTime + timer.mode!.duration + let diff = endTime - now + + if (timer.mode!.stopAtZero && diff < 0) { + diff = 0 + } + + return { diff, isNegative: diff >= 0, isFreeRun: false } + } + return { diff: 0, isNegative: false, isFreeRun: false } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 53c9134642..8e930daaa4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -12,6 +12,7 @@ import { PlaylistStartTiming } from '../RundownTiming/PlaylistStartTiming' import { RundownName } from '../RundownTiming/RundownName' import { TimeOfDay } from '../RundownTiming/TimeOfDay' import { useTiming } from '../RundownTiming/withTiming' +import { RundownHeaderTimers } from './RundownHeaderTimers' interface ITimingDisplayProps { rundownPlaylist: DBRundownPlaylist @@ -50,6 +51,7 @@ export function TimingDisplay({
+
From 884cb664d5717162d12ca9dd29231e2559821317 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:13:59 +0100 Subject: [PATCH 15/79] SOFIE-261 | change alignment of t-timers in rundown screen --- .../webui/src/client/styles/rundownView.scss | 27 ++++++++++++------- .../RundownHeader/RundownHeaderTimers.tsx | 24 ----------------- .../RundownHeader/TimingDisplay.tsx | 2 +- 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index 31ca322601..02cdd1c49b 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -275,6 +275,7 @@ body.no-overflow { } .timing__header__center { + position: relative; display: flex; justify-content: center; align-items: center; @@ -3585,25 +3586,29 @@ svg.icon { @import 'rundownOverview'; .rundown-header .timing__header_t-timers { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-38%); display: flex; flex-direction: column; justify-content: center; + align-items: flex-end; margin-right: 1em; - padding-left: 1em; - text-align: right; - align-self: center; - width: fit-content; .timing__header_t-timers__timer { display: flex; gap: 0.5em; justify-content: space-between; align-items: baseline; + white-space: nowrap; + line-height: 1.3; .timing__header_t-timers__timer__label { font-size: 0.7em; color: #b8b8b8; text-transform: uppercase; + white-space: nowrap; } .timing__header_t-timers__timer__value { @@ -3614,20 +3619,24 @@ svg.icon { sans-serif; font-variant-numeric: tabular-nums; font-weight: 500; - color: $general-clock; + color: #fff; font-size: 1.1em; } .timing__header_t-timers__timer__sign { - margin-right: 0.2em; - font-weight: 700; + display: inline-block; + width: 0.6em; + text-align: center; + font-weight: 500; + font-size: 0.9em; color: #fff; + margin-right: 0.3em; } .timing__header_t-timers__timer__part { - color: white; + color: #fff; &.timing__header_t-timers__timer__part--dimmed { - color: #888; // Dimmed color for "00" + color: #888; font-weight: 400; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 4aafecf659..925d4ddff9 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -11,30 +11,6 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - // TODO: Remove this mock data once verified - const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { - const now = Date.now() - return [ - { - index: 1, - label: 'Timer 1', - mode: { type: 'freeRun', startTime: now - 60000 }, - }, - { - index: 2, - label: 'Timer 2', - mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, - }, - { - index: 3, - label: 'Timer 3', - mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, - }, - ] - }, []) - - tTimers = mockTimers - const hasActiveTimers = tTimers.some((t) => t.mode) if (!hasActiveTimers) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 8e930daaa4..0ade467075 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -51,9 +51,9 @@ export function TimingDisplay({
-
+
From 989242191b3e0053693668671523284fac5102f8 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 17:28:22 +0000 Subject: [PATCH 16/79] Refactor UI for new timer style --- .../RundownHeader/RundownHeaderTimers.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 925d4ddff9..185cef40f5 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -33,13 +33,13 @@ function SingleTimer({ timer }: ISingleTimerProps) { const now = Date.now() - const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + const isRunning = timer.state !== null && !timer.state.paused - const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const diff = calculateDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) const parts = timeStr.split(':') - const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + const timerSign = diff >= 0 ? '+' : '-' return (
{timer.label} @@ -74,27 +74,23 @@ function SingleTimer({ timer }: ISingleTimerProps) { ) } -function calculateDiff( - timer: RundownTTimer, - now: number -): { - diff: number - isNegative: boolean - isFreeRun: boolean -} { - if (timer.mode!.type === 'freeRun') { - const startTime = timer.mode!.startTime - const diff = now - startTime - return { diff, isNegative: false, isFreeRun: true } - } else if (timer.mode!.type === 'countdown') { - const endTime = timer.mode!.startTime + timer.mode!.duration - let diff = endTime - now - - if (timer.mode!.stopAtZero && diff < 0) { - diff = 0 - } - - return { diff, isNegative: diff >= 0, isFreeRun: false } +function calculateDiff(timer: RundownTTimer, now: number): number { + if (!timer.state) { + return 0 } - return { diff: 0, isNegative: false, isFreeRun: false } + + // Get current time: either frozen duration or calculated from zeroTime + const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentTime + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentTime < 0) { + return 0 + } + + return currentTime } From a5bf059d65896c92ccf9e53c545897672c12ebb6 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 11 Feb 2026 03:23:26 +0100 Subject: [PATCH 17/79] SOFIE-261 | (WIP) add estimates over/under to t-timers UI in director screen --- .../corelib/src/dataModel/RundownPlaylist.ts | 17 ++++ packages/webui/src/client/lib/tTimerUtils.ts | 83 +++++++++++++++++++ .../src/client/styles/countdown/director.scss | 70 ++++++++++++++++ .../client/styles/countdown/presenter.scss | 69 ++++++++++++++- .../webui/src/client/styles/rundownView.scss | 16 ++++ .../client/ui/ClockView/DirectorScreen.tsx | 9 ++ .../client/ui/ClockView/PresenterScreen.tsx | 6 ++ .../src/client/ui/ClockView/TTimerDisplay.tsx | 55 ++++++++++++ .../RundownHeader/RundownHeaderTimers.tsx | 41 ++++----- 9 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 packages/webui/src/client/lib/tTimerUtils.ts create mode 100644 packages/webui/src/client/ui/ClockView/TTimerDisplay.tsx diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 93c4bb769c..2629f9a0b2 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -165,6 +165,23 @@ export interface RundownTTimer { */ state: TimerState | null + /** The estimated time when we expect to reach the anchor part, for calculating over/under diff. + * + * Based on scheduled durations of remaining parts and segments up to the anchor. + * Running means we are progressing towards the anchor (estimate moves with real time). + * Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed). + * + * Calculated automatically when anchorPartId is set, or can be set manually by a blueprint. + */ + estimateState?: TimerState + + /** The target Part that this timer is counting towards (the "timing anchor"). + * + * When set, the server calculates estimateState based on when we expect to reach this part. + * If not set, estimateState is not calculated automatically but can still be set manually by a blueprint. + */ + anchorPartId?: PartId + /* * Future ideas: * allowUiControl: boolean diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts new file mode 100644 index 0000000000..08ec4f19e2 --- /dev/null +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -0,0 +1,83 @@ +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +/** + * Calculate the display diff for a T-Timer. + * For countdown/timeOfDay: positive = time remaining, negative = overrun. + * For freeRun: positive = elapsed time. + */ +export function calculateTTimerDiff(timer: RundownTTimer, now: number): number { + if (!timer.state) { + return 0 + } + + // Get current time: either frozen duration or calculated from zeroTime + const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentTime + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentTime < 0) { + return 0 + } + + return currentTime +} + +/** + * Calculate the over/under difference between the timer's current value + * and its estimate. + * + * Positive = over (behind schedule, will reach anchor after timer hits zero) + * Negative = under (ahead of schedule, will reach anchor before timer hits zero) + * + * Returns undefined if no estimate is available. + */ +export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): number | undefined { + if (!timer.state || !timer.estimateState) { + return undefined + } + + const duration = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + const estimateDuration = timer.estimateState.paused + ? timer.estimateState.duration + : timer.estimateState.zeroTime - now + + return duration - estimateDuration +} + +// TODO: remove this mock +let mockTimer: RundownTTimer | undefined + +export function getDefaultTTimer(_tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): RundownTTimer | undefined { + // FORCE MOCK: + /* + const active = tTimers.find((t) => t.mode) + if (active) return active + */ + + if (!mockTimer) { + const now = Date.now() + mockTimer = { + index: 0, + label: 'MOCK TIMER', + mode: { + type: 'countdown', + }, + state: { + zeroTime: now + 60 * 60 * 1000, // 1 hour + duration: 0, + paused: false, + }, + estimateState: { + zeroTime: now + 65 * 60 * 1000, // 65 mins -> 5 mins over + duration: 0, + paused: false, + }, + } as any + } + + return mockTimer +} diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 7cc5dd8813..fe9f95eac0 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -428,5 +428,75 @@ $hold-status-color: $liveline-timecode-color; .clocks-counter-heavy { font-weight: 600; } + + .director-screen__body__t-timer { + position: absolute; + bottom: 0; + right: 0; + text-align: right; + font-size: 5vh; + z-index: 10; + line-height: 1; + + .t-timer-display { + display: flex; + align-items: stretch; + justify-content: flex-end; + font-weight: 500; + background: #333; + border-radius: 0; + overflow: hidden; + + &__label { + display: flex; + align-items: center; + color: #fff; + padding-left: 0.4em; + padding-right: 0.2em; + font-size: 1em; + text-transform: uppercase; + letter-spacing: 0.05em; + font-stretch: condensed; + } + + &__value { + display: flex; + align-items: center; + color: #fff; + font-variant-numeric: tabular-nums; + padding: 0 0.2em; + font-size: 1em; + + .t-timer-display__part { + &--dimmed { + color: #aaa; + } + } + } + + &__over-under { + display: flex; + align-items: center; + justify-content: center; + margin: 0 0 0 0.2em; + font-size: 1em; + font-variant-numeric: tabular-nums; + padding: 0 0.4em; + line-height: 1.1; + min-width: 3.5em; + border-radius: 1em; + + &--over { + background-color: $general-late-color; + color: #fff; + } + + &--under { + background-color: #ffe900; + color: #000; + } + } + } + } } } diff --git a/packages/webui/src/client/styles/countdown/presenter.scss b/packages/webui/src/client/styles/countdown/presenter.scss index 0f2a939f43..df9a20d66a 100644 --- a/packages/webui/src/client/styles/countdown/presenter.scss +++ b/packages/webui/src/client/styles/countdown/presenter.scss @@ -163,7 +163,7 @@ $hold-status-color: $liveline-timecode-color; .presenter-screen__rundown-status-bar { display: grid; - grid-template-columns: auto fit-content(5em); + grid-template-columns: auto fit-content(20em) fit-content(5em); grid-template-rows: fit-content(1em); font-size: 6em; color: #888; @@ -176,6 +176,73 @@ $hold-status-color: $liveline-timecode-color; line-height: 1.44em; } + .presenter-screen__rundown-status-bar__t-timer { + margin-right: 1em; + font-size: 0.8em; + align-self: center; + justify-self: end; + + .t-timer-display { + display: flex; + align-items: stretch; + justify-content: flex-end; + font-weight: 500; + background: #333; + border-radius: 0; + overflow: hidden; + + &__label { + display: flex; + align-items: center; + color: #fff; + padding-left: 0.4em; + padding-right: 0.2em; + font-size: 1em; + text-transform: uppercase; + letter-spacing: 0.05em; + font-stretch: condensed; + } + + &__value { + display: flex; + align-items: center; + color: #fff; + font-variant-numeric: tabular-nums; + padding: 0 0.2em; + font-size: 1em; + + .t-timer-display__part { + &--dimmed { + color: #aaa; + } + } + } + + &__over-under { + display: flex; + align-items: center; + justify-content: center; + margin: 0 0 0 0.2em; + font-size: 1em; + font-variant-numeric: tabular-nums; + padding: 0 0.4em; + line-height: 1.1; + min-width: 3.5em; + border-radius: 1em; + + &--over { + background-color: $general-late-color; + color: #fff; + } + + &--under { + background-color: #ffe900; + color: #000; + } + } + } + } + .presenter-screen__rundown-status-bar__countdown { white-space: nowrap; diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index ef27214343..1fbb31352b 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -3644,5 +3644,21 @@ svg.icon { margin: 0 0.05em; color: #888; } + + .timing__header_t-timers__timer__over-under { + font-size: 0.75em; + font-weight: 400; + font-variant-numeric: tabular-nums; + margin-left: 0.5em; + white-space: nowrap; + + &.timing__header_t-timers__timer__over-under--over { + color: $general-late-color; + } + + &.timing__header_t-timers__timer__over-under--under { + color: #0f0; + } + } } } diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 98b36e7f32..3781aaefda 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -51,6 +51,8 @@ import { AdjustLabelFit } from '../util/AdjustLabelFit.js' import { AutoNextStatus } from '../RundownView/RundownTiming/AutoNextStatus.js' import { useTranslation } from 'react-i18next' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { TTimerDisplay } from './TTimerDisplay.js' +import { getDefaultTTimer } from '../../lib/tTimerUtils.js' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance.js' interface SegmentUi extends DBSegment { @@ -550,6 +552,8 @@ function DirectorScreenRender({ } } + const activeTTimer = getDefaultTTimer(playlist.tTimers) + return (
@@ -754,6 +758,11 @@ function DirectorScreenRender({ ) : null}
+ {!!activeTTimer && ( +
+ +
+ )}
) diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 68e6581789..92f7cd88e6 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -48,6 +48,8 @@ import { useSetDocumentClass, useSetDocumentDarkTheme } from '../util/useSetDocu import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist.js' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil.js' import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { TTimerDisplay } from './TTimerDisplay.js' +import { getDefaultTTimer } from '../../lib/tTimerUtils.js' interface SegmentUi extends DBSegment { items: Array @@ -482,6 +484,7 @@ function PresenterScreenContentDefaultLayout({ const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 + const activeTTimer = getDefaultTTimer(playlist.tTimers) return (
@@ -587,6 +590,9 @@ function PresenterScreenContentDefaultLayout({
{playlist ? playlist.name : 'UNKNOWN'}
+
+ {!!activeTTimer && } +
= 0, diff --git a/packages/webui/src/client/ui/ClockView/TTimerDisplay.tsx b/packages/webui/src/client/ui/ClockView/TTimerDisplay.tsx new file mode 100644 index 0000000000..ec0ef952a0 --- /dev/null +++ b/packages/webui/src/client/ui/ClockView/TTimerDisplay.tsx @@ -0,0 +1,55 @@ +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownUtils } from '../../lib/rundown' +import { calculateTTimerDiff, calculateTTimerOverUnder } from '../../lib/tTimerUtils' +import { useTiming } from '../RundownView/RundownTiming/withTiming' +import classNames from 'classnames' + +interface TTimerDisplayProps { + timer: RundownTTimer +} + +export function TTimerDisplay({ timer }: Readonly): JSX.Element | null { + useTiming() + + if (!timer.mode) return null + + const now = Date.now() + + const diff = calculateTTimerDiff(timer, now) + const overUnder = calculateTTimerOverUnder(timer, now) + + const timerStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) + const timerParts = timerStr.split(':') + const timerSign = diff >= 0 ? '' : '-' + + return ( +
+ {timer.label} + + {timerSign} + {timerParts.map((p, i) => ( + + {p} + {i < timerParts.length - 1 && :} + + ))} + + {overUnder !== undefined && ( + 0, + 't-timer-display__over-under--under': overUnder <= 0, + })} + > + {overUnder > 0 ? '+' : '\u2013'} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + + )} +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 185cef40f5..f2c2a39f03 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -2,6 +2,7 @@ import React from 'react' import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' +import { calculateTTimerDiff, calculateTTimerOverUnder } from '../../../lib/tTimerUtils' import classNames from 'classnames' interface IProps { @@ -33,14 +34,16 @@ function SingleTimer({ timer }: ISingleTimerProps) { const now = Date.now() - const isRunning = timer.state !== null && !timer.state.paused + const isRunning = !!timer.state && !timer.state.paused - const diff = calculateDiff(timer, now) + const diff = calculateTTimerDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) const parts = timeStr.split(':') const timerSign = diff >= 0 ? '+' : '-' + const overUnder = calculateTTimerOverUnder(timer, now) + return (
{timer.label} @@ -70,27 +73,17 @@ function SingleTimer({ timer }: ISingleTimerProps) { ))}
+ {!!overUnder && ( + 0, + 'timing__header_t-timers__timer__over-under--under': overUnder < 0, + })} + > + {overUnder >= 0 ? '+' : '-'} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + + )}
) } - -function calculateDiff(timer: RundownTTimer, now: number): number { - if (!timer.state) { - return 0 - } - - // Get current time: either frozen duration or calculated from zeroTime - const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now - - // Free run counts up, so negate to get positive elapsed time - if (timer.mode?.type === 'freeRun') { - return -currentTime - } - - // Apply stopAtZero if configured - if (timer.mode?.stopAtZero && currentTime < 0) { - return 0 - } - - return currentTime -} From 15ad2fdc365d71e67ac9be67ffff61f2246bc0b6 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:44:17 +0100 Subject: [PATCH 18/79] SOFIE-261 | add UI for t-timers (WIP) --- .../webui/src/client/styles/rundownView.scss | 80 +++++++++-- .../RundownHeader/RundownHeaderTimers.tsx | 124 ++++++++++++++++++ .../RundownHeader/TimingDisplay.tsx | 2 + 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index b647eabfa6..9e036f1ddf 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -209,8 +209,13 @@ body.no-overflow { bottom: 0; right: 0; - background: - linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), + background: linear-gradient( + -45deg, + $color-status-fatal 33%, + transparent 33%, + transparent 66%, + $color-status-fatal 66% + ), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%); @@ -266,7 +271,15 @@ body.no-overflow { .timing__header__left { text-align: left; + display: flex; + } + + .timing__header__center { + display: flex; + justify-content: center; + align-items: center; } + .timing__header__right { display: grid; grid-template-columns: auto auto; @@ -1100,8 +1113,7 @@ svg.icon { } .segment-timeline__part { .segment-timeline__part__invalid-cover { - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 4px, @@ -1383,8 +1395,7 @@ svg.icon { left: 2px; right: 2px; z-index: 3; - background: - repeating-linear-gradient( + background: repeating-linear-gradient( 45deg, var(--invalid-reason-color-opaque) 0, var(--invalid-reason-color-opaque) 5px, @@ -1566,8 +1577,7 @@ svg.icon { right: 1px; z-index: 10; pointer-events: all; - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, @@ -3573,3 +3583,57 @@ svg.icon { } @import 'rundownOverview'; + +.rundown-header .timing__header_t-timers { + display: flex; + flex-direction: column; + justify-content: center; + margin-right: 1em; + padding-left: 1em; + text-align: right; + align-self: center; + width: fit-content; + + .timing__header_t-timers__timer { + display: flex; + gap: 0.5em; + justify-content: space-between; + align-items: baseline; + + .timing__header_t-timers__timer__label { + font-size: 0.7em; + color: #b8b8b8; + text-transform: uppercase; + } + + .timing__header_t-timers__timer__value { + font-family: + 'Roboto', + Helvetica Neue, + Arial, + sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: $general-clock; + font-size: 1.1em; + } + + .timing__header_t-timers__timer__sign { + margin-right: 0.2em; + font-weight: 700; + color: #fff; + } + + .timing__header_t-timers__timer__part { + color: white; + &.timing__header_t-timers__timer__part--dimmed { + color: #888; // Dimmed color for "00" + font-weight: 400; + } + } + .timing__header_t-timers__timer__separator { + margin: 0 0.05em; + color: #888; + } + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx new file mode 100644 index 0000000000..4aafecf659 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' +import classNames from 'classnames' + +interface IProps { + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] +} + +export const RundownHeaderTimers: React.FC = ({ tTimers }) => { + useTiming() + + // TODO: Remove this mock data once verified + const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { + const now = Date.now() + return [ + { + index: 1, + label: 'Timer 1', + mode: { type: 'freeRun', startTime: now - 60000 }, + }, + { + index: 2, + label: 'Timer 2', + mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, + }, + { + index: 3, + label: 'Timer 3', + mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, + }, + ] + }, []) + + tTimers = mockTimers + + const hasActiveTimers = tTimers.some((t) => t.mode) + + if (!hasActiveTimers) return null + + return ( +
+ {tTimers.map((timer) => ( + + ))} +
+ ) +} + +interface ISingleTimerProps { + timer: RundownTTimer +} + +function SingleTimer({ timer }: ISingleTimerProps) { + if (!timer.mode) return null + + const now = Date.now() + + const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + + const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) + const parts = timeStr.split(':') + + const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + + return ( +
+ {timer.label} +
+ {timerSign} + {parts.map((p, i) => ( + + + {p} + + {i < parts.length - 1 && :} + + ))} +
+
+ ) +} + +function calculateDiff( + timer: RundownTTimer, + now: number +): { + diff: number + isNegative: boolean + isFreeRun: boolean +} { + if (timer.mode!.type === 'freeRun') { + const startTime = timer.mode!.startTime + const diff = now - startTime + return { diff, isNegative: false, isFreeRun: true } + } else if (timer.mode!.type === 'countdown') { + const endTime = timer.mode!.startTime + timer.mode!.duration + let diff = endTime - now + + if (timer.mode!.stopAtZero && diff < 0) { + diff = 0 + } + + return { diff, isNegative: diff >= 0, isFreeRun: false } + } + return { diff: 0, isNegative: false, isFreeRun: false } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 53c9134642..8e930daaa4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -12,6 +12,7 @@ import { PlaylistStartTiming } from '../RundownTiming/PlaylistStartTiming' import { RundownName } from '../RundownTiming/RundownName' import { TimeOfDay } from '../RundownTiming/TimeOfDay' import { useTiming } from '../RundownTiming/withTiming' +import { RundownHeaderTimers } from './RundownHeaderTimers' interface ITimingDisplayProps { rundownPlaylist: DBRundownPlaylist @@ -50,6 +51,7 @@ export function TimingDisplay({
+
From 95dc71f916209c4f6a3a8288146ee1d43bda0b03 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:13:59 +0100 Subject: [PATCH 19/79] SOFIE-261 | change alignment of t-timers in rundown screen --- .../webui/src/client/styles/rundownView.scss | 27 ++++++++++++------- .../RundownHeader/RundownHeaderTimers.tsx | 24 ----------------- .../RundownHeader/TimingDisplay.tsx | 2 +- 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index 9e036f1ddf..ef27214343 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -275,6 +275,7 @@ body.no-overflow { } .timing__header__center { + position: relative; display: flex; justify-content: center; align-items: center; @@ -3585,25 +3586,29 @@ svg.icon { @import 'rundownOverview'; .rundown-header .timing__header_t-timers { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-38%); display: flex; flex-direction: column; justify-content: center; + align-items: flex-end; margin-right: 1em; - padding-left: 1em; - text-align: right; - align-self: center; - width: fit-content; .timing__header_t-timers__timer { display: flex; gap: 0.5em; justify-content: space-between; align-items: baseline; + white-space: nowrap; + line-height: 1.3; .timing__header_t-timers__timer__label { font-size: 0.7em; color: #b8b8b8; text-transform: uppercase; + white-space: nowrap; } .timing__header_t-timers__timer__value { @@ -3614,20 +3619,24 @@ svg.icon { sans-serif; font-variant-numeric: tabular-nums; font-weight: 500; - color: $general-clock; + color: #fff; font-size: 1.1em; } .timing__header_t-timers__timer__sign { - margin-right: 0.2em; - font-weight: 700; + display: inline-block; + width: 0.6em; + text-align: center; + font-weight: 500; + font-size: 0.9em; color: #fff; + margin-right: 0.3em; } .timing__header_t-timers__timer__part { - color: white; + color: #fff; &.timing__header_t-timers__timer__part--dimmed { - color: #888; // Dimmed color for "00" + color: #888; font-weight: 400; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 4aafecf659..925d4ddff9 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -11,30 +11,6 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - // TODO: Remove this mock data once verified - const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { - const now = Date.now() - return [ - { - index: 1, - label: 'Timer 1', - mode: { type: 'freeRun', startTime: now - 60000 }, - }, - { - index: 2, - label: 'Timer 2', - mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, - }, - { - index: 3, - label: 'Timer 3', - mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, - }, - ] - }, []) - - tTimers = mockTimers - const hasActiveTimers = tTimers.some((t) => t.mode) if (!hasActiveTimers) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 8e930daaa4..0ade467075 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -51,9 +51,9 @@ export function TimingDisplay({
-
+
From 079b70ba28bf443efa0e164eb855aa7c50c4ea9c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 17:28:22 +0000 Subject: [PATCH 20/79] Refactor UI for new timer style --- .../RundownHeader/RundownHeaderTimers.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 925d4ddff9..185cef40f5 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -33,13 +33,13 @@ function SingleTimer({ timer }: ISingleTimerProps) { const now = Date.now() - const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + const isRunning = timer.state !== null && !timer.state.paused - const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const diff = calculateDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) const parts = timeStr.split(':') - const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + const timerSign = diff >= 0 ? '+' : '-' return (
{timer.label} @@ -74,27 +74,23 @@ function SingleTimer({ timer }: ISingleTimerProps) { ) } -function calculateDiff( - timer: RundownTTimer, - now: number -): { - diff: number - isNegative: boolean - isFreeRun: boolean -} { - if (timer.mode!.type === 'freeRun') { - const startTime = timer.mode!.startTime - const diff = now - startTime - return { diff, isNegative: false, isFreeRun: true } - } else if (timer.mode!.type === 'countdown') { - const endTime = timer.mode!.startTime + timer.mode!.duration - let diff = endTime - now - - if (timer.mode!.stopAtZero && diff < 0) { - diff = 0 - } - - return { diff, isNegative: diff >= 0, isFreeRun: false } +function calculateDiff(timer: RundownTTimer, now: number): number { + if (!timer.state) { + return 0 } - return { diff: 0, isNegative: false, isFreeRun: false } + + // Get current time: either frozen duration or calculated from zeroTime + const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentTime + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentTime < 0) { + return 0 + } + + return currentTime } From e9fc4c4fc0ce1dc7132b5c535ff23f9a4c74c2ac Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:26:28 +0100 Subject: [PATCH 21/79] Apply code review suggestions --- .../RundownHeader/RundownHeaderTimers.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 185cef40f5..d5de3a5942 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -3,6 +3,7 @@ import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownP import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' import classNames from 'classnames' +import { getCurrentTime } from '../../../lib/systemTime' interface IProps { tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] @@ -11,13 +12,13 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - const hasActiveTimers = tTimers.some((t) => t.mode) + const activeTimers = tTimers.filter((t) => t.mode) - if (!hasActiveTimers) return null + if (activeTimers.length == 0) return null return (
- {tTimers.map((timer) => ( + {activeTimers.map((timer) => ( ))}
@@ -29,11 +30,9 @@ interface ISingleTimerProps { } function SingleTimer({ timer }: ISingleTimerProps) { - if (!timer.mode) return null + const now = getCurrentTime() - const now = Date.now() - - const isRunning = timer.state !== null && !timer.state.paused + const isRunning = !!timer.state && !timer.state.paused const diff = calculateDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) @@ -41,6 +40,8 @@ function SingleTimer({ timer }: ISingleTimerProps) { const timerSign = diff >= 0 ? '+' : '-' + const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning + return (
{timer.label} @@ -61,7 +62,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { {p} From b45e406e46173a2919479396b29cd520c34d69f5 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:12:53 +0100 Subject: [PATCH 22/79] WIP - new topbar --- .../webui/src/client/styles/_colorScheme.scss | 6 + .../src/client/styles/defaultColors.scss | 6 + .../src/client/styles/notifications.scss | 2 +- .../webui/src/client/styles/rundownView.scss | 22 +- .../webui/src/client/ui/RundownList/util.ts | 2 +- packages/webui/src/client/ui/RundownView.tsx | 3 +- .../RundownHeader/RundownContextMenu.tsx | 179 +++++++++ .../RundownHeader/RundownHeader.scss | 341 ++++++++++++++++ .../RundownHeader/RundownHeader.tsx | 370 +++++++++--------- .../RundownHeader/RundownHeaderTimers.tsx | 8 +- .../RundownHeader_old/RundownHeaderTimers.tsx | 97 +++++ .../RundownHeader_old/RundownHeader_old.tsx | 235 +++++++++++ .../RundownReloadResponse.ts | 0 .../TimingDisplay.tsx | 0 .../useRundownPlaylistOperations.tsx | 0 .../client/ui/RundownView/RundownNotifier.tsx | 2 +- .../RundownView/RundownRightHandControls.tsx | 2 +- .../RundownViewContextProviders.tsx | 2 +- .../ui/SegmentList/SegmentListHeader.tsx | 2 +- 19 files changed, 1067 insertions(+), 212 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx rename packages/webui/src/client/ui/RundownView/{RundownHeader => RundownHeader_old}/RundownReloadResponse.ts (100%) rename packages/webui/src/client/ui/RundownView/{RundownHeader => RundownHeader_old}/TimingDisplay.tsx (100%) rename packages/webui/src/client/ui/RundownView/{RundownHeader => RundownHeader_old}/useRundownPlaylistOperations.tsx (100%) diff --git a/packages/webui/src/client/styles/_colorScheme.scss b/packages/webui/src/client/styles/_colorScheme.scss index 0a50e271f1..eea945a755 100644 --- a/packages/webui/src/client/styles/_colorScheme.scss +++ b/packages/webui/src/client/styles/_colorScheme.scss @@ -37,3 +37,9 @@ $ui-button-primary--translucent: var(--ui-button-primary--translucent); $ui-dark-color: var(--ui-dark-color); $ui-dark-color-brighter: var(--ui-dark-color-brighter); + +$color-interactive-highlight: var(--color-interactive-highlight); + +$color-header-inactive: var(--color-header-inactive); +$color-header-rehearsal: var(--color-header-rehearsal); +$color-header-on-air: var(--color-header-on-air); diff --git a/packages/webui/src/client/styles/defaultColors.scss b/packages/webui/src/client/styles/defaultColors.scss index ef618d611d..41be88ec00 100644 --- a/packages/webui/src/client/styles/defaultColors.scss +++ b/packages/webui/src/client/styles/defaultColors.scss @@ -41,5 +41,11 @@ --ui-dark-color: #252627; --ui-dark-color-brighter: #5f6164; + --color-interactive-highlight: #40b8fa99; + + --color-header-inactive: rgb(38, 137, 186); + --color-header-rehearsal: #666600; + --color-header-on-air: #000000; + --segment-timeline-background-color: #{$segment-timeline-background-color}; } diff --git a/packages/webui/src/client/styles/notifications.scss b/packages/webui/src/client/styles/notifications.scss index 7b86c04aa5..c3bd5a439a 100644 --- a/packages/webui/src/client/styles/notifications.scss +++ b/packages/webui/src/client/styles/notifications.scss @@ -490,7 +490,7 @@ .rundown-view { &.notification-center-open { padding-right: 25vw !important; - > .rundown-header .rundown-overview { + > .rundown-header_OLD .rundown-overview { padding-right: calc(25vw + 1.5em) !important; } } diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index ef27214343..f7c8db52fd 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -142,11 +142,11 @@ $break-width: 35rem; } } - .rundown-header .notification-pop-ups { + .rundown-header_OLD .notification-pop-ups { top: 65px; } - > .rundown-header .rundown-overview { + > .rundown-header_OLD .rundown-overview { transition: 0s padding-right 0.5s; } @@ -154,7 +154,7 @@ $break-width: 35rem; padding-right: $notification-center-width; transition: 0s padding-right 1s; - > .rundown-header .rundown-overview { + > .rundown-header_OLD .rundown-overview { padding-right: calc(#{$notification-center-width} + 1.5em); transition: 0s padding-right 1s; } @@ -164,7 +164,7 @@ $break-width: 35rem; padding-right: $properties-panel-width; transition: 0s padding-right 1s; - > .rundown-header .rundown-overview { + > .rundown-header_OLD .rundown-overview { padding-right: calc(#{$properties-panel-width} + 1.5em); transition: 0s padding-right 1s; } @@ -245,7 +245,7 @@ body.no-overflow { } } -.rundown-header { +.rundown-header_OLD { padding: 0; .header-row { @@ -488,17 +488,17 @@ body.no-overflow { cursor: default; } -.rundown-header.not-active .first-row { +.rundown-header_OLD.not-active .first-row { background-color: rgb(38, 137, 186); } -.rundown-header.not-active .first-row .timing-clock, -.rundown-header.not-active .first-row .timing-clock-label { +.rundown-header_OLD.not-active .first-row .timing-clock, +.rundown-header_OLD.not-active .first-row .timing-clock-label { color: #fff !important; } -// .rundown-header.active .first-row { +// .rundown-header_OLD.active .first-row { // background-color: #600 // } -.rundown-header.active.rehearsal .first-row { +.rundown-header_OLD.active.rehearsal .first-row { background-color: #660; } @@ -3585,7 +3585,7 @@ svg.icon { @import 'rundownOverview'; -.rundown-header .timing__header_t-timers { +.rundown-header_OLD .timing__header_t-timers { position: absolute; right: 100%; top: 50%; diff --git a/packages/webui/src/client/ui/RundownList/util.ts b/packages/webui/src/client/ui/RundownList/util.ts index cec30f2fd8..7a492e91f3 100644 --- a/packages/webui/src/client/ui/RundownList/util.ts +++ b/packages/webui/src/client/ui/RundownList/util.ts @@ -4,7 +4,7 @@ import { doModalDialog } from '../../lib/ModalDialog.js' import { doUserAction, UserAction } from '../../lib/clientUserAction.js' import { MeteorCall } from '../../lib/meteorApi.js' import { TFunction } from 'i18next' -import { handleRundownReloadResponse } from '../RundownView/RundownHeader/RundownReloadResponse.js' +import { handleRundownReloadResponse } from '../RundownView/RundownHeader_old/RundownReloadResponse.js' import { RundownId, RundownLayoutId, diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index d795959a66..1d89f6b00c 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -291,7 +291,7 @@ export function RundownView(props: Readonly): JSX.Element { return (
): JSX.Element { + const { t } = useTranslation() + const userPermissions = useContext(UserPermissionsContext) + const operations = useRundownPlaylistOperations() + + const canClearQuickLoop = + !!studio.settings.enableQuickLoop && + !RundownResolver.isLoopLocked(playlist) && + RundownResolver.isAnyLoopMarkerDefined(playlist) + + const rundownTimesInfo = checkRundownTimes(playlist.timing) + + // --- Event bus listeners for playlist operations --- + const eventActivate = useCallback( + (e: ActivateRundownPlaylistEvent) => { + if (e.rehearsal) { + operations.activateRehearsal(e.context) + } else { + operations.activate(e.context) + } + }, + [operations] + ) + const eventDeactivate = useCallback( + (e: DeactivateRundownPlaylistEvent) => operations.deactivate(e.context), + [operations] + ) + const eventResync = useCallback((e: IEventContext) => operations.reloadRundownPlaylist(e.context), [operations]) + const eventTake = useCallback((e: IEventContext) => operations.take(e.context), [operations]) + const eventResetRundownPlaylist = useCallback((e: IEventContext) => operations.resetRundown(e.context), [operations]) + const eventCreateSnapshot = useCallback((e: IEventContext) => operations.takeRundownSnapshot(e.context), [operations]) + + useRundownViewEventBusListener(RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, eventActivate) + useRundownViewEventBusListener(RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, eventDeactivate) + useRundownViewEventBusListener(RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, eventResync) + useRundownViewEventBusListener(RundownViewEvents.TAKE, eventTake) + useRundownViewEventBusListener(RundownViewEvents.RESET_RUNDOWN_PLAYLIST, eventResetRundownPlaylist) + useRundownViewEventBusListener(RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, eventCreateSnapshot) + + useEffect(() => { + reloadRundownPlaylistClick.set(operations.reloadRundownPlaylist) + }, [operations.reloadRundownPlaylist]) + + return ( + + +
{playlist && playlist.name}
+ {userPermissions.studio ? ( + + {!(playlist.activationId && playlist.rehearsal) ? ( + !rundownTimesInfo.shouldHaveStarted && !playlist.activationId ? ( + + {t('Prepare Studio and Activate (Rehearsal)')} + + ) : ( + {t('Activate (Rehearsal)')} + ) + ) : ( + {t('Activate (On-Air)')} + )} + {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( + {t('Activate (On-Air)')} + )} + {playlist.activationId ? {t('Deactivate')} : null} + {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( + {t('AdLib Testing')} + ) : null} + {playlist.activationId ? {t('Take')} : null} + {studio.settings.allowHold && playlist.activationId ? ( + {t('Hold')} + ) : null} + {playlist.activationId && canClearQuickLoop ? ( + {t('Clear QuickLoop')} + ) : null} + {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( + {t('Reset Rundown')} + ) : null} + + {t('Reload {{nrcsName}} Data', { + nrcsName: getRundownNrcsName(firstRundown), + })} + + {t('Store Snapshot')} + + ) : ( + + {t('No actions available')} + + )} +
+
+ ) +} + +interface RundownContextMenuTriggerProps { + children: React.ReactNode +} + +export function RundownHeaderContextMenuTrigger({ children }: Readonly): JSX.Element { + return ( + + {children} + + ) +} + +/** + * A hamburger button that opens the context menu on left-click. + */ +export function RundownHamburgerButton(): JSX.Element { + const { t } = useTranslation() + const buttonRef = useRef(null) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + // Dispatch a custom contextmenu event + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + const event = new MouseEvent('contextmenu', { + view: globalThis as unknown as Window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.bottom + 5, + button: 2, + buttons: 2, + }) + buttonRef.current.dispatchEvent(event) + } + }, []) + + return ( + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss new file mode 100644 index 0000000000..a37d650965 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -0,0 +1,341 @@ +@import '../../../styles/colorScheme'; + +.rundown-header { + height: 64px; + min-height: 64px; + padding: 0; + width: 100%; + border-bottom: 1px solid #333; + transition: background-color 0.5s; + font-family: 'Roboto Flex', sans-serif; + + .rundown-header__trigger { + height: 100%; + width: 100%; + display: block; + } + + // State-based background colors + &.not-active { + background: $color-header-inactive; + } + + &.active { + background: $color-header-on-air; + + .rundown-header__segment-remaining, + .rundown-header__onair-remaining, + .rundown-header__expected-end { + color: #fff; + } + + .timing__header_t-timers__timer__label { + color: rgba(255, 255, 255, 0.9); + } + + .timing-clock { + &.time-now { + color: #fff; + } + } + + &.rehearsal { + background: $color-header-rehearsal; + } + } + + .rundown-header__content { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + background: transparent; + } + + .rundown-header__left { + display: flex; + align-items: center; + flex: 1; + } + + .rundown-header__right { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; + gap: 1em; + } + + .rundown-header__center { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + + .timing-clock { + color: #40b8fa99; + font-size: 1.4em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + transition: color 0.2s; + + &.time-now { + font-size: 1.25em; + font-style: italic; + font-weight: 300; + } + } + + .rundown-header__timing-display { + display: flex; + align-items: center; + margin-right: 1.5em; + margin-left: 2em; + + .rundown-header__diff { + display: flex; + align-items: center; + gap: 0.4em; + font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; + font-variant-numeric: tabular-nums; + white-space: nowrap; + + .rundown-header__diff__label { + font-size: 0.85em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #888; + } + + .rundown-header__diff__chip { + font-size: 1.1em; + font-weight: 500; + padding: 0.15em 0.75em; + border-radius: 999px; + font-variant-numeric: tabular-nums; + } + + &.rundown-header__diff--under { + .rundown-header__diff__chip { + background-color: #c8a800; + color: #000; + } + } + + &.rundown-header__diff--over { + .rundown-header__diff__chip { + background-color: #b00; + color: #fff; + } + } + } + } + + .timing__header_t-timers { + position: absolute; + left: 28%; /* Position exactly between the 15% left edge content and the 50% center clock */ + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; /* Center vertically against the entire header height */ + align-items: flex-end; + + .timing__header_t-timers__timer { + display: flex; + gap: 0.5em; + justify-content: space-between; + align-items: baseline; + white-space: nowrap; + line-height: 1.25; + + .timing__header_t-timers__timer__label { + font-size: 0.7em; + color: #b8b8b8; + text-transform: uppercase; + white-space: nowrap; + } + + .timing__header_t-timers__timer__value { + font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: #fff; + font-size: 1.4em; + } + + .timing__header_t-timers__timer__sign { + display: inline-block; + width: 0.6em; + text-align: center; + font-weight: 500; + font-size: 1.1em; + color: #fff; + margin-right: 0.3em; + } + + .timing__header_t-timers__timer__part { + color: #fff; + &.timing__header_t-timers__timer__part--dimmed { + color: #888; + font-weight: 400; + } + } + .timing__header_t-timers__timer__separator { + margin: 0 0.05em; + color: #888; + } + + &:only-child { + /* For single timers, lift it vertically by exactly half its height to match the SegBudget top row height */ + transform: translateY(-65%); + } + } + } + + &:hover { + .timing-clock { + color: #40b8fa; + } + } + } + + .rundown-header__right { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; + padding-right: 1rem; + } + + .rundown-header__hamburger-btn { + background: none; + border: none; + color: #40b8fa99; + cursor: pointer; + padding: 0 1em; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 1.2em; + transition: color 0.2s; + } + + .rundown-header__timers { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 0.1em; + } + + .rundown-header__segment-remaining, + .rundown-header__onair-remaining { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8em; + color: rgba(255, 255, 255, 0.6); + transition: color 0.2s; + + &__label { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + opacity: 0; + transition: opacity 0.2s; + } + + &__value { + font-size: 1.4em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + + .overtime { + color: $general-late-color; + } + } + } + + // Stacked Plan. End / Est. End in right section + .rundown-header__endtimes { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.1em; + min-width: 9em; + } + + .rundown-header__expected-end { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.6em; + color: rgba(255, 255, 255, 0.6); + transition: color 0.2s; + + &__label { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + } + + &__value { + font-size: 1.4em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + } + } + + .rundown-header__onair-remaining__label { + background-color: $general-live-color; + color: #fff; + padding: 0.1em 0.6em 0.1em 0.3em; + border-radius: 2px 999px 999px 2px; + font-weight: bold; + opacity: 1 !important; + + .freeze-frame-icon { + margin-left: 0.3em; + vertical-align: middle; + height: 0.9em; + width: auto; + } + } + + .rundown-header__close-btn { + display: flex; + align-items: center; + cursor: pointer; + color: #40b8fa; + opacity: 0; + transition: opacity 0.2s; + } + + &:hover { + .rundown-header__hamburger-btn, + .rundown-header__center .timing-clock { + color: #40b8fa; + } + + .rundown-header__segment-remaining, + .rundown-header__onair-remaining, + .rundown-header__expected-end { + color: white; + } + + .rundown-header__segment-remaining__label, + .rundown-header__onair-remaining__label { + opacity: 1; + } + + .rundown-header__close-btn { + opacity: 1; + } + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 818fc88874..30e5de651a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -1,40 +1,30 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import * as CoreIcon from '@nrk/core-icons/jsx' import ClassNames from 'classnames' -import Escape from '../../../lib/Escape' -import Tooltip from 'rc-tooltip' import { NavLink } from 'react-router-dom' +import * as CoreIcon from '@nrk/core-icons/jsx' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Rundown, getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { ContextMenu, MenuItem, ContextMenuTrigger } from '@jstarpl/react-contextmenu' -import { PieceUi } from '../../SegmentTimeline/SegmentTimelineContainer' -import { RundownSystemStatus } from '../RundownSystemStatus' -import { getHelpMode } from '../../../lib/localStorage' -import { reloadRundownPlaylistClick } from '../RundownNotifier' -import { useRundownViewEventBusListener } from '../../../lib/lib' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { contextMenuHoldToDisplayTime } from '../../../lib/lib' -import { - ActivateRundownPlaylistEvent, - DeactivateRundownPlaylistEvent, - IEventContext, - RundownViewEvents, -} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' -import { RundownLayoutsAPI } from '../../../lib/rundownLayouts' +import { VTContent } from '@sofie-automation/blueprints-integration' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' -import { BucketAdLibItem } from '../../Shelf/RundownViewBuckets' -import { IAdLibListItem } from '../../Shelf/AdLibListItem' -import { ShelfDashboardLayout } from '../../Shelf/ShelfDashboardLayout' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' -import { UserPermissionsContext } from '../../UserPermissions' -import * as RundownResolver from '../../../lib/RundownResolver' import Navbar from 'react-bootstrap/Navbar' -import { WarningDisplay } from '../WarningDisplay' -import { TimingDisplay } from './TimingDisplay' -import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations' +import Moment from 'react-moment' +import { RundownContextMenu, RundownHeaderContextMenuTrigger, RundownHamburgerButton } from './RundownContextMenu' +import { useTranslation } from 'react-i18next' +import { TimeOfDay } from '../RundownTiming/TimeOfDay' +import { CurrentPartOrSegmentRemaining } from '../RundownTiming/CurrentPartOrSegmentRemaining' +import { RundownHeaderTimers } from './RundownHeaderTimers' +import { FreezeFrameIcon } from '../../../lib/ui/icons/freezeFrame' +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { PieceInstances, PartInstances } from '../../../collections/index' +import { useTiming, TimingTickResolution, TimingDataResolution } from '../RundownTiming/withTiming' +import { getPlaylistTimingDiff } from '../../../lib/rundownTiming' +import { RundownUtils } from '../../../lib/rundown' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import './RundownHeader.scss' interface IRundownHeaderProps { playlist: DBRundownPlaylist @@ -44,121 +34,18 @@ interface IRundownHeaderProps { studio: UIStudio rundownIds: RundownId[] firstRundown: Rundown | undefined + rundownCount: number onActivate?: (isRehearsal: boolean) => void inActiveRundownView?: boolean layout: RundownLayoutRundownHeader | undefined } -export function RundownHeader({ - playlist, - showStyleBase, - showStyleVariant, - currentRundown, - studio, - rundownIds, - firstRundown, - inActiveRundownView, - layout, -}: IRundownHeaderProps): JSX.Element { +export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() - const userPermissions = useContext(UserPermissionsContext) - - const [selectedPiece, setSelectedPiece] = useState(undefined) - const [shouldQueueAdlibs, setShouldQueueAdlibs] = useState(false) - - const operations = useRundownPlaylistOperations() - - const eventActivate = useCallback( - (e: ActivateRundownPlaylistEvent) => { - if (e.rehearsal) { - operations.activateRehearsal(e.context) - } else { - operations.activate(e.context) - } - }, - [operations] - ) - const eventDeactivate = useCallback( - (e: DeactivateRundownPlaylistEvent) => operations.deactivate(e.context), - [operations] - ) - const eventResync = useCallback((e: IEventContext) => operations.reloadRundownPlaylist(e.context), [operations]) - const eventTake = useCallback((e: IEventContext) => operations.take(e.context), [operations]) - const eventResetRundownPlaylist = useCallback((e: IEventContext) => operations.resetRundown(e.context), [operations]) - const eventCreateSnapshot = useCallback((e: IEventContext) => operations.takeRundownSnapshot(e.context), [operations]) - - useRundownViewEventBusListener(RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, eventActivate) - useRundownViewEventBusListener(RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, eventDeactivate) - useRundownViewEventBusListener(RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, eventResync) - useRundownViewEventBusListener(RundownViewEvents.TAKE, eventTake) - useRundownViewEventBusListener(RundownViewEvents.RESET_RUNDOWN_PLAYLIST, eventResetRundownPlaylist) - useRundownViewEventBusListener(RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, eventCreateSnapshot) - - useEffect(() => { - reloadRundownPlaylistClick.set(operations.reloadRundownPlaylist) - }, [operations.reloadRundownPlaylist]) - - const canClearQuickLoop = - !!studio.settings.enableQuickLoop && - !RundownResolver.isLoopLocked(playlist) && - RundownResolver.isAnyLoopMarkerDefined(playlist) - - const rundownTimesInfo = checkRundownTimes(playlist.timing) - - useEffect(() => { - console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) - }, [playlist.tTimers]) - return ( <> - - -
{playlist && playlist.name}
- {userPermissions.studio ? ( - - {!(playlist.activationId && playlist.rehearsal) ? ( - !rundownTimesInfo.shouldHaveStarted && !playlist.activationId ? ( - - {t('Prepare Studio and Activate (Rehearsal)')} - - ) : ( - {t('Activate (Rehearsal)')} - ) - ) : ( - {t('Activate (On-Air)')} - )} - {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( - {t('Activate (On-Air)')} - )} - {playlist.activationId ? {t('Deactivate')} : null} - {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( - {t('AdLib Testing')} - ) : null} - {playlist.activationId ? {t('Take')} : null} - {studio.settings.allowHold && playlist.activationId ? ( - {t('Hold')} - ) : null} - {playlist.activationId && canClearQuickLoop ? ( - {t('Clear QuickLoop')} - ) : null} - {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( - {t('Reset Rundown')} - ) : null} - - {t('Reload {{nrcsName}} Data', { - nrcsName: getRundownNrcsName(firstRundown), - })} - - {t('Store Snapshot')} - - ) : ( - - {t('No actions available')} - - )} -
-
+ - - - noResetOnActivate ? operations.activateRundown(e) : operations.resetAndActivateRundown(e) - } - /> -
-
-
- -
- -
+ +
+
+ + {playlist.currentPartInfo && ( +
+ + {t('Seg. Budg.')} + + + + + + {t('On Air')} + + + + + +
+ )} +
+ +
+ + +
- {layout && RundownLayoutsAPI.isDashboardLayout(layout) ? ( - - ) : ( - <> - - - - )} -
-
- - - -
+ +
+ + + +
- + ) } + +interface IRundownHeaderTimingDisplayProps { + playlist: DBRundownPlaylist +} + +function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDisplayProps): JSX.Element | null { + const timingDurations = useTiming() + + const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(overUnderClock), false, false, true, true, true) + const isUnder = overUnderClock <= 0 + + return ( +
+ + {isUnder ? 'Under' : 'Over'} + + {isUnder ? '−' : '+'} + {timeStr} + + +
+ ) +} + +function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { + const { t } = useTranslation() + const timingDurations = useTiming() + + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + + const now = timingDurations.currentTime ?? Date.now() + const estEnd = + expectedStart != null && timingDurations.remainingPlaylistDuration != null + ? now + timingDurations.remainingPlaylistDuration + : null + + if (!expectedEnd && !expectedDuration && !estEnd) return null + + return ( +
+ {expectedEnd ? ( + + {t('Plan. End')} + + + + + ) : null} + {estEnd ? ( + + {t('Est. End')} + + + + + ) : null} +
+ ) +} + +function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }) { + const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) + + const freezeFrameIcon = useTracker( + () => { + const partInstance = PartInstances.findOne(partInstanceId) + if (!partInstance) return null + + // We use the exact display duration from the timing context just like VTSourceRenderer does. + // Fallback to static displayDuration or expectedDuration if timing context is unavailable. + const partDisplayDuration = + (timingDurations.partDisplayDurations && timingDurations.partDisplayDurations[partInstanceId as any]) ?? + partInstance.part.displayDuration ?? + partInstance.part.expectedDuration ?? + 0 + + const partDuration = timingDurations.partDurations + ? timingDurations.partDurations[partInstanceId as any] + : partDisplayDuration + + const pieceInstances = PieceInstances.find({ partInstanceId }).fetch() + + for (const pieceInstance of pieceInstances) { + const piece = pieceInstance.piece + if (piece.virtual) continue + + const content = piece.content as VTContent | undefined + if (!content || content.loop || content.sourceDuration === undefined) { + continue + } + + const seek = content.seek || 0 + const renderedInPoint = typeof piece.enable.start === 'number' ? piece.enable.start : 0 + const pieceDuration = content.sourceDuration - seek + + const isAutoNext = partInstance.part.autoNext + + if ( + (isAutoNext && renderedInPoint + pieceDuration < partDuration) || + (!isAutoNext && Math.abs(renderedInPoint + pieceDuration - partDisplayDuration) > 500) + ) { + return + } + } + return null + }, + [partInstanceId, timingDurations.partDisplayDurations, timingDurations.partDurations], + null + ) + + return freezeFrameIcon +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index d5de3a5942..0b7a5aec4f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -12,9 +12,11 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - const activeTimers = tTimers.filter((t) => t.mode) + if (!tTimers?.length) { + return null + } - if (activeTimers.length == 0) return null + const activeTimers = tTimers.filter((t) => t.mode) return (
@@ -76,7 +78,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { } function calculateDiff(timer: RundownTTimer, now: number): number { - if (!timer.state) { + if (!timer.state || timer.state.paused === undefined) { return 0 } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx new file mode 100644 index 0000000000..132963d696 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' +import classNames from 'classnames' +import { getCurrentTime } from '../../../lib/systemTime' + +interface IProps { + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] +} + +export const RundownHeaderTimers: React.FC = ({ tTimers }) => { + useTiming() + + const activeTimers = tTimers.filter((t) => t.mode) + + if (activeTimers.length == 0) return null + + return ( +
+ {activeTimers.map((timer) => ( + + ))} +
+ ) +} + +interface ISingleTimerProps { + timer: RundownTTimer +} + +function SingleTimer({ timer }: ISingleTimerProps) { + const now = getCurrentTime() + + const isRunning = !!timer.state && !timer.state.paused + + const diff = calculateDiff(timer, now) + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) + const parts = timeStr.split(':') + + const timerSign = diff >= 0 ? '+' : '-' + + const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning + + return ( +
+ {timer.label} +
+ {timerSign} + {parts.map((p, i) => ( + + + {p} + + {i < parts.length - 1 && :} + + ))} +
+
+ ) +} + +function calculateDiff(timer: RundownTTimer, now: number): number { + if (!timer.state || timer.state.paused === undefined) { + return 0 + } + + // Get current time: either frozen duration or calculated from zeroTime + const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentTime + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentTime < 0) { + return 0 + } + + return currentTime +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx new file mode 100644 index 0000000000..e235cb792f --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx @@ -0,0 +1,235 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as CoreIcon from '@nrk/core-icons/jsx' +import ClassNames from 'classnames' +import Escape from '../../../lib/Escape' +import Tooltip from 'rc-tooltip' +import { NavLink } from 'react-router-dom' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { Rundown, getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { ContextMenu, MenuItem, ContextMenuTrigger } from '@jstarpl/react-contextmenu' +import { PieceUi } from '../../SegmentTimeline/SegmentTimelineContainer' +import { RundownSystemStatus } from '../RundownSystemStatus' +import { getHelpMode } from '../../../lib/localStorage' +import { reloadRundownPlaylistClick } from '../RundownNotifier' +import { useRundownViewEventBusListener } from '../../../lib/lib' +import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' +import { contextMenuHoldToDisplayTime } from '../../../lib/lib' +import { + ActivateRundownPlaylistEvent, + DeactivateRundownPlaylistEvent, + IEventContext, + RundownViewEvents, +} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' +import { RundownLayoutsAPI } from '../../../lib/rundownLayouts' +import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' +import { BucketAdLibItem } from '../../Shelf/RundownViewBuckets' +import { IAdLibListItem } from '../../Shelf/AdLibListItem' +import { ShelfDashboardLayout } from '../../Shelf/ShelfDashboardLayout' +import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' +import { UserPermissionsContext } from '../../UserPermissions' +import * as RundownResolver from '../../../lib/RundownResolver' +import Navbar from 'react-bootstrap/Navbar' +import { WarningDisplay } from '../WarningDisplay' +import { TimingDisplay } from './TimingDisplay' +import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations' + +interface IRundownHeaderProps { + playlist: DBRundownPlaylist + showStyleBase: UIShowStyleBase + showStyleVariant: DBShowStyleVariant + currentRundown: Rundown | undefined + studio: UIStudio + rundownIds: RundownId[] + firstRundown: Rundown | undefined + onActivate?: (isRehearsal: boolean) => void + inActiveRundownView?: boolean + layout: RundownLayoutRundownHeader | undefined +} + +export function RundownHeader_old({ + playlist, + showStyleBase, + showStyleVariant, + currentRundown, + studio, + rundownIds, + firstRundown, + inActiveRundownView, + layout, +}: IRundownHeaderProps): JSX.Element { + const { t } = useTranslation() + + const userPermissions = useContext(UserPermissionsContext) + + const [selectedPiece, setSelectedPiece] = useState(undefined) + const [shouldQueueAdlibs, setShouldQueueAdlibs] = useState(false) + + const operations = useRundownPlaylistOperations() + + const eventActivate = useCallback( + (e: ActivateRundownPlaylistEvent) => { + if (e.rehearsal) { + operations.activateRehearsal(e.context) + } else { + operations.activate(e.context) + } + }, + [operations] + ) + const eventDeactivate = useCallback( + (e: DeactivateRundownPlaylistEvent) => operations.deactivate(e.context), + [operations] + ) + const eventResync = useCallback((e: IEventContext) => operations.reloadRundownPlaylist(e.context), [operations]) + const eventTake = useCallback((e: IEventContext) => operations.take(e.context), [operations]) + const eventResetRundownPlaylist = useCallback((e: IEventContext) => operations.resetRundown(e.context), [operations]) + const eventCreateSnapshot = useCallback((e: IEventContext) => operations.takeRundownSnapshot(e.context), [operations]) + + useRundownViewEventBusListener(RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, eventActivate) + useRundownViewEventBusListener(RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, eventDeactivate) + useRundownViewEventBusListener(RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, eventResync) + useRundownViewEventBusListener(RundownViewEvents.TAKE, eventTake) + useRundownViewEventBusListener(RundownViewEvents.RESET_RUNDOWN_PLAYLIST, eventResetRundownPlaylist) + useRundownViewEventBusListener(RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, eventCreateSnapshot) + + useEffect(() => { + reloadRundownPlaylistClick.set(operations.reloadRundownPlaylist) + }, [operations.reloadRundownPlaylist]) + + const canClearQuickLoop = + !!studio.settings.enableQuickLoop && + !RundownResolver.isLoopLocked(playlist) && + RundownResolver.isAnyLoopMarkerDefined(playlist) + + const rundownTimesInfo = checkRundownTimes(playlist.timing) + + useEffect(() => { + console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) + }, [playlist.tTimers]) + + return ( + <> + + +
{playlist && playlist.name}
+ {userPermissions.studio ? ( + + {!(playlist.activationId && playlist.rehearsal) ? ( + !rundownTimesInfo.shouldHaveStarted && !playlist.activationId ? ( + + {t('Prepare Studio and Activate (Rehearsal)')} + + ) : ( + {t('Activate (Rehearsal)')} + ) + ) : ( + {t('Activate (On-Air)')} + )} + {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( + {t('Activate (On-Air)')} + )} + {playlist.activationId ? {t('Deactivate')} : null} + {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( + {t('AdLib Testing')} + ) : null} + {playlist.activationId ? {t('Take')} : null} + {studio.settings.allowHold && playlist.activationId ? ( + {t('Hold')} + ) : null} + {playlist.activationId && canClearQuickLoop ? ( + {t('Clear QuickLoop')} + ) : null} + {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( + {t('Reset Rundown')} + ) : null} + + {t('Reload {{nrcsName}} Data', { + nrcsName: getRundownNrcsName(firstRundown), + })} + + {t('Store Snapshot')} + + ) : ( + + {t('No actions available')} + + )} +
+
+ + + + noResetOnActivate ? operations.activateRundown(e) : operations.resetAndActivateRundown(e) + } + /> +
+
+
+ +
+ +
+
+ {layout && RundownLayoutsAPI.isDashboardLayout(layout) ? ( + + ) : ( + <> + + + + )} +
+
+ + + +
+
+
+ + + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts similarity index 100% rename from packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts rename to packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx similarity index 100% rename from packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx rename to packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx similarity index 100% rename from packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx rename to packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index ba7328a21e..1cd767f05f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -25,7 +25,7 @@ import { doUserAction, UserAction } from '../../lib/clientUserAction.js' import { i18nTranslator as t } from '../i18n.js' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PeripheralDevicesAPI } from '../../lib/clientAPI.js' -import { handleRundownReloadResponse } from '../RundownView/RundownHeader/RundownReloadResponse.js' +import { handleRundownReloadResponse } from '../RundownView/RundownHeader_old/RundownReloadResponse.js' import { MeteorCall } from '../../lib/meteorApi.js' import { UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { isTranslatableMessage, translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' diff --git a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx index 775d55c326..203c8dd81a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx @@ -141,7 +141,7 @@ export function RundownRightHandControls(props: Readonly): JSX.Element { )}
diff --git a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx index a5eb33429e..552f54afeb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx @@ -1,7 +1,7 @@ import React from 'react' import { RundownTimingProvider } from './RundownTiming/RundownTimingProvider' import StudioContext from './StudioContext' -import { RundownPlaylistOperationsContextProvider } from './RundownHeader/useRundownPlaylistOperations' +import { RundownPlaylistOperationsContextProvider } from './RundownHeader_old/useRundownPlaylistOperations' import { PreviewPopUpContextProvider } from '../PreviewPopUp/PreviewPopUpContext' import { SelectedElementProvider } from './SelectedElementsContext' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' diff --git a/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx b/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx index f8e65fbe38..7a791cbe5d 100644 --- a/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx +++ b/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx @@ -197,7 +197,7 @@ export function SegmentListHeader({ 'time-of-day-countdowns': useTimeOfDayCountdowns, - 'no-rundown-header': hideRundownHeader, + 'no-rundown-header_OLD': hideRundownHeader, })} > {contents} From 0c62b532434b3e3febc3e81a0dd1c832104605b9 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:14:58 +0100 Subject: [PATCH 23/79] New top bar UI - WIP --- .../client/ui/ClockView/CameraScreen/Part.tsx | 2 +- .../client/ui/ClockView/DirectorScreen.tsx | 2 +- .../client/ui/ClockView/PresenterScreen.tsx | 2 +- .../RundownView/RundownHeader/Countdown.scss | 24 +++ .../RundownView/RundownHeader/Countdown.tsx | 22 +++ .../CurrentPartOrSegmentRemaining.tsx | 139 +++++++++++++++++ .../RundownHeader/HeaderFreezeFrameIcon.tsx | 59 +++++++ .../RundownHeader/RundownHeader.scss | 82 ++-------- .../RundownHeader/RundownHeader.tsx | 137 ++-------------- .../RundownHeader/RundownHeaderDurations.tsx | 33 ++++ .../RundownHeaderExpectedEnd.tsx | 29 ++++ .../RundownHeaderPlannedStart.tsx | 17 ++ .../RundownHeader/RundownHeaderTimers.tsx | 35 ++--- .../RundownHeaderTimingDisplay.tsx | 30 ++++ .../RundownHeader_old/TimingDisplay.tsx | 2 +- .../CurrentPartOrSegmentRemaining.tsx | 147 ------------------ .../src/client/ui/SegmentList/LinePart.tsx | 2 +- .../src/client/ui/SegmentList/OnAirLine.tsx | 2 +- .../ui/SegmentStoryboard/StoryboardPart.tsx | 2 +- .../ui/SegmentTimeline/SegmentTimeline.tsx | 2 +- .../src/client/ui/Shelf/PartTimingPanel.tsx | 2 +- 21 files changed, 402 insertions(+), 370 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownTiming/CurrentPartOrSegmentRemaining.tsx diff --git a/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx index 3ea9e4a83a..5bfa710d78 100644 --- a/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx @@ -7,7 +7,7 @@ import { PieceExtended } from '../../../lib/RundownResolver.js' import { getAllowSpeaking, getAllowVibrating } from '../../../lib/localStorage.js' import { getPartInstanceTimingValue } from '../../../lib/rundownTiming.js' import { AutoNextStatus } from '../../RundownView/RundownTiming/AutoNextStatus.js' -import { CurrentPartOrSegmentRemaining } from '../../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { PartCountdown } from '../../RundownView/RundownTiming/PartCountdown.js' import { PartDisplayDuration } from '../../RundownView/RundownTiming/PartDuration.js' import { TimingDataResolution, TimingTickResolution, useTiming } from '../../RundownView/RundownTiming/withTiming.js' diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 98b36e7f32..2dc92b4be7 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -40,7 +40,7 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSetDocumentClass } from '../util/useSetDocumentClass.js' import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist.js' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { OverUnderClockComponent, PlannedEndComponent, diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 68e6581789..30797b3cc0 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -47,7 +47,7 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSetDocumentClass, useSetDocumentDarkTheme } from '../util/useSetDocumentClass.js' import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist.js' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' interface SegmentUi extends DBSegment { items: Array diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss new file mode 100644 index 0000000000..16e73ba385 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -0,0 +1,24 @@ +@import '../../../styles/colorScheme'; + +.countdown { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.6em; + color: rgba(255, 255, 255, 0.6); + transition: color 0.2s; + + &__label { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; + } + + &__value { + font-size: 1.4em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx new file mode 100644 index 0000000000..487f0a4367 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import Moment from 'react-moment' +import classNames from 'classnames' +import './Countdown.scss' + +interface IProps { + label: string + time?: number + className?: string + children?: React.ReactNode +} + +export function Countdown({ label, time, className, children }: IProps): JSX.Element { + return ( + + {label} + + {time !== undefined ? : children} + + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx new file mode 100644 index 0000000000..905e32b768 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useRef } from 'react' +import ClassNames from 'classnames' +import { TimingDataResolution, TimingTickResolution, useTiming } from '../RundownTiming/withTiming.js' +import { RundownUtils } from '../../../lib/rundown.js' +import { SpeechSynthesiser } from '../../../lib/speechSynthesis.js' +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +const SPEAK_ADVANCE = 500 + +interface IPartRemainingProps { + currentPartInstanceId: PartInstanceId | null + hideOnZero?: boolean + className?: string + heavyClassName?: string + speaking?: boolean + vibrating?: boolean + /** Use the segment budget instead of the part duration if available */ + preferSegmentTime?: boolean +} + +// global variable for remembering last uttered displayTime +let prevDisplayTime: number | undefined = undefined + +function speak(displayTime: number) { + let text = '' // Say nothing + + switch (displayTime) { + case -1: + text = 'One' + break + case -2: + text = 'Two' + break + case -3: + text = 'Three' + break + case -4: + text = 'Four' + break + case -5: + text = 'Five' + break + case -6: + text = 'Six' + break + case -7: + text = 'Seven' + break + case -8: + text = 'Eight' + break + case -9: + text = 'Nine' + break + case -10: + text = 'Ten' + break + } + + if (text) { + SpeechSynthesiser.speak(text, 'countdown') + } +} + +function vibrate(displayTime: number) { + if ('vibrate' in navigator) { + switch (displayTime) { + case 0: + navigator.vibrate([500]) + break + case -1: + case -2: + case -3: + navigator.vibrate([250]) + break + } + } +} + +export const CurrentPartOrSegmentRemaining: React.FC = (props) => { + const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) + const prevPartInstanceId = useRef(null) + + useEffect(() => { + if (props.currentPartInstanceId !== prevPartInstanceId.current) { + prevDisplayTime = undefined + prevPartInstanceId.current = props.currentPartInstanceId + } + + if (!timingDurations?.currentTime) return + if (timingDurations.currentPartInstanceId !== props.currentPartInstanceId) return + + let displayTime = (timingDurations.remainingTimeOnCurrentPart || 0) * -1 + + if (displayTime !== 0) { + displayTime += SPEAK_ADVANCE + displayTime = Math.floor(displayTime / 1000) + } + + if (prevDisplayTime !== displayTime) { + if (props.speaking) { + speak(displayTime) + } + + if (props.vibrating) { + vibrate(displayTime) + } + + prevDisplayTime = displayTime + } + }, [ + props.currentPartInstanceId, + timingDurations?.currentTime, + timingDurations?.currentPartInstanceId, + timingDurations?.remainingTimeOnCurrentPart, + props.speaking, + props.vibrating, + ]) + + if (!timingDurations?.currentTime) return null + if (timingDurations.currentPartInstanceId !== props.currentPartInstanceId) return null + + let displayTimecode = timingDurations.remainingTimeOnCurrentPart + if (props.preferSegmentTime) { + displayTimecode = timingDurations.remainingBudgetOnCurrentSegment ?? displayTimecode + } + + if (displayTimecode === undefined) return null + displayTimecode *= -1 + + return ( + 0 ? props.heavyClassName : undefined)} + role="timer" + > + {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx new file mode 100644 index 0000000000..18318ac74f --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx @@ -0,0 +1,59 @@ +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { FreezeFrameIcon } from '../../../lib/ui/icons/freezeFrame' +import { useTiming, TimingTickResolution, TimingDataResolution } from '../RundownTiming/withTiming' +import { useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { PartInstances, PieceInstances } from '../../../collections' +import { VTContent } from '@sofie-automation/blueprints-integration' + +export function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }) { + const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) + + const freezeFrameIcon = useTracker( + () => { + const partInstance = PartInstances.findOne(partInstanceId) + if (!partInstance) return null + + // We use the exact display duration from the timing context just like VTSourceRenderer does. + // Fallback to static displayDuration or expectedDuration if timing context is unavailable. + const partDisplayDuration = + (timingDurations.partDisplayDurations && timingDurations.partDisplayDurations[partInstanceId as any]) ?? + partInstance.part.displayDuration ?? + partInstance.part.expectedDuration ?? + 0 + + const partDuration = timingDurations.partDurations + ? timingDurations.partDurations[partInstanceId as any] + : partDisplayDuration + + const pieceInstances = PieceInstances.find({ partInstanceId }).fetch() + + for (const pieceInstance of pieceInstances) { + const piece = pieceInstance.piece + if (piece.virtual) continue + + const content = piece.content as VTContent | undefined + if (!content || content.loop || content.sourceDuration === undefined) { + continue + } + + const seek = content.seek || 0 + const renderedInPoint = typeof piece.enable.start === 'number' ? piece.enable.start : 0 + const pieceDuration = content.sourceDuration - seek + + const isAutoNext = partInstance.part.autoNext + + if ( + (isAutoNext && renderedInPoint + pieceDuration < partDuration) || + (!isAutoNext && Math.abs(renderedInPoint + pieceDuration - partDisplayDuration) > 500) + ) { + return + } + } + return null + }, + [partInstanceId, timingDurations.partDisplayDurations, timingDurations.partDurations], + null + ) + + return freezeFrameIcon +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index a37d650965..f44c5ba570 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -25,7 +25,7 @@ .rundown-header__segment-remaining, .rundown-header__onair-remaining, - .rundown-header__expected-end { + .countdown { color: #fff; } @@ -33,12 +33,6 @@ color: rgba(255, 255, 255, 0.9); } - .timing-clock { - &.time-now { - color: #fff; - } - } - &.rehearsal { background: $color-header-rehearsal; } @@ -74,14 +68,14 @@ flex: 1; .timing-clock { - color: #40b8fa99; + color: #40b8fa; font-size: 1.4em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; transition: color 0.2s; &.time-now { - font-size: 1.25em; + font-size: 1.6em; font-style: italic; font-weight: 300; } @@ -142,28 +136,20 @@ flex-direction: column; justify-content: center; /* Center vertically against the entire header height */ align-items: flex-end; + font-size: 0.75em; .timing__header_t-timers__timer { - display: flex; - gap: 0.5em; - justify-content: space-between; - align-items: baseline; white-space: nowrap; line-height: 1.25; - .timing__header_t-timers__timer__label { - font-size: 0.7em; + .countdown__label { color: #b8b8b8; - text-transform: uppercase; - white-space: nowrap; } - .timing__header_t-timers__timer__value { + .countdown__value { font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; - font-variant-numeric: tabular-nums; font-weight: 500; color: #fff; - font-size: 1.4em; } .timing__header_t-timers__timer__sign { @@ -187,27 +173,8 @@ margin: 0 0.05em; color: #888; } - - &:only-child { - /* For single timers, lift it vertically by exactly half its height to match the SegBudget top row height */ - transform: translateY(-65%); - } } } - - &:hover { - .timing-clock { - color: #40b8fa; - } - } - } - - .rundown-header__right { - display: flex; - align-items: center; - justify-content: flex-end; - flex: 1; - padding-right: 1rem; } .rundown-header__hamburger-btn { @@ -261,35 +228,15 @@ } } - // Stacked Plan. End / Est. End in right section + // Stacked Plan. Start / Plan. End / Est. End in right section .rundown-header__endtimes { display: flex; flex-direction: column; - justify-content: center; - gap: 0.1em; - min-width: 9em; - } - - .rundown-header__expected-end { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.6em; - color: rgba(255, 255, 255, 0.6); - transition: color 0.2s; - - &__label { - font-size: 0.7em; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - } - - &__value { - font-size: 1.4em; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; - } + justify-content: flex-start; + gap: 0.15em; + min-width: 7em; + font-size: 0.75em; + padding-top: 0.8em; // Align with the top row of the T-Timers and Seg Budget } .rundown-header__onair-remaining__label { @@ -318,14 +265,13 @@ } &:hover { - .rundown-header__hamburger-btn, - .rundown-header__center .timing-clock { + .rundown-header__hamburger-btn { color: #40b8fa; } .rundown-header__segment-remaining, .rundown-header__onair-remaining, - .rundown-header__expected-end { + .countdown { color: white; } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 30e5de651a..4ce0c9c1a4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -4,26 +4,22 @@ import * as CoreIcon from '@nrk/core-icons/jsx' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { VTContent } from '@sofie-automation/blueprints-integration' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' import Navbar from 'react-bootstrap/Navbar' -import Moment from 'react-moment' import { RundownContextMenu, RundownHeaderContextMenuTrigger, RundownHamburgerButton } from './RundownContextMenu' import { useTranslation } from 'react-i18next' import { TimeOfDay } from '../RundownTiming/TimeOfDay' -import { CurrentPartOrSegmentRemaining } from '../RundownTiming/CurrentPartOrSegmentRemaining' +import { CurrentPartOrSegmentRemaining } from '../RundownHeader/CurrentPartOrSegmentRemaining' import { RundownHeaderTimers } from './RundownHeaderTimers' -import { FreezeFrameIcon } from '../../../lib/ui/icons/freezeFrame' -import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' -import { PieceInstances, PartInstances } from '../../../collections/index' -import { useTiming, TimingTickResolution, TimingDataResolution } from '../RundownTiming/withTiming' -import { getPlaylistTimingDiff } from '../../../lib/rundownTiming' -import { RundownUtils } from '../../../lib/rundown' -import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' + +import { RundownHeaderTimingDisplay } from './RundownHeaderTimingDisplay' +import { RundownHeaderPlannedStart } from './RundownHeaderPlannedStart' +import { RundownHeaderDurations } from './RundownHeaderDurations' +import { RundownHeaderExpectedEnd } from './RundownHeaderExpectedEnd' +import { HeaderFreezeFrameIcon } from './HeaderFreezeFrameIcon' import './RundownHeader.scss' interface IRundownHeaderProps { @@ -93,6 +89,8 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader
+ + @@ -104,120 +102,3 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader ) } - -interface IRundownHeaderTimingDisplayProps { - playlist: DBRundownPlaylist -} - -function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDisplayProps): JSX.Element | null { - const timingDurations = useTiming() - - const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 - const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(overUnderClock), false, false, true, true, true) - const isUnder = overUnderClock <= 0 - - return ( -
- - {isUnder ? 'Under' : 'Over'} - - {isUnder ? '−' : '+'} - {timeStr} - - -
- ) -} - -function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { - const { t } = useTranslation() - const timingDurations = useTiming() - - const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) - - const now = timingDurations.currentTime ?? Date.now() - const estEnd = - expectedStart != null && timingDurations.remainingPlaylistDuration != null - ? now + timingDurations.remainingPlaylistDuration - : null - - if (!expectedEnd && !expectedDuration && !estEnd) return null - - return ( -
- {expectedEnd ? ( - - {t('Plan. End')} - - - - - ) : null} - {estEnd ? ( - - {t('Est. End')} - - - - - ) : null} -
- ) -} - -function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }) { - const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) - - const freezeFrameIcon = useTracker( - () => { - const partInstance = PartInstances.findOne(partInstanceId) - if (!partInstance) return null - - // We use the exact display duration from the timing context just like VTSourceRenderer does. - // Fallback to static displayDuration or expectedDuration if timing context is unavailable. - const partDisplayDuration = - (timingDurations.partDisplayDurations && timingDurations.partDisplayDurations[partInstanceId as any]) ?? - partInstance.part.displayDuration ?? - partInstance.part.expectedDuration ?? - 0 - - const partDuration = timingDurations.partDurations - ? timingDurations.partDurations[partInstanceId as any] - : partDisplayDuration - - const pieceInstances = PieceInstances.find({ partInstanceId }).fetch() - - for (const pieceInstance of pieceInstances) { - const piece = pieceInstance.piece - if (piece.virtual) continue - - const content = piece.content as VTContent | undefined - if (!content || content.loop || content.sourceDuration === undefined) { - continue - } - - const seek = content.seek || 0 - const renderedInPoint = typeof piece.enable.start === 'number' ? piece.enable.start : 0 - const pieceDuration = content.sourceDuration - seek - - const isAutoNext = partInstance.part.autoNext - - if ( - (isAutoNext && renderedInPoint + pieceDuration < partDuration) || - (!isAutoNext && Math.abs(renderedInPoint + pieceDuration - partDisplayDuration) > 500) - ) { - return - } - } - return null - }, - [partInstanceId, timingDurations.partDisplayDurations, timingDurations.partDurations], - null - ) - - return freezeFrameIcon -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx new file mode 100644 index 0000000000..8c98979360 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -0,0 +1,33 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { useTranslation } from 'react-i18next' +import { Countdown } from './Countdown' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' + +export function RundownHeaderDurations({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { + const { t } = useTranslation() + const timingDurations = useTiming() + + const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + const planned = + expectedDuration != null ? RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true) : null + + const remainingMs = timingDurations.remainingPlaylistDuration + const startedMs = playlist.startedPlayback + const estDuration = + remainingMs != null && startedMs != null + ? (timingDurations.currentTime ?? Date.now()) - startedMs + remainingMs + : null + const estimated = + estDuration != null ? RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true) : null + + if (!planned && !estimated) return null + + return ( +
+ {planned ? {planned} : null} + {estimated ? {estimated} : null} +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx new file mode 100644 index 0000000000..c78f232685 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -0,0 +1,29 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { useTranslation } from 'react-i18next' +import { Countdown } from './Countdown' +import { useTiming } from '../RundownTiming/withTiming' + +export function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { + const { t } = useTranslation() + const timingDurations = useTiming() + + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + + const now = timingDurations.currentTime ?? Date.now() + const estEnd = + expectedStart != null && timingDurations.remainingPlaylistDuration != null + ? now + timingDurations.remainingPlaylistDuration + : null + + if (!expectedEnd && !expectedDuration && !estEnd) return null + + return ( +
+ {expectedEnd ? : null} + {estEnd ? : null} +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx new file mode 100644 index 0000000000..7bc995f23e --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -0,0 +1,17 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { useTranslation } from 'react-i18next' +import { Countdown } from './Countdown' + +export function RundownHeaderPlannedStart({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { + const { t } = useTranslation() + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + + if (expectedStart == null) return null + + return ( +
+ +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 0b7a5aec4f..9e265990f4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -4,6 +4,7 @@ import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' import classNames from 'classnames' import { getCurrentTime } from '../../../lib/systemTime' +import { Countdown } from './Countdown' interface IProps { tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] @@ -45,7 +46,8 @@ function SingleTimer({ timer }: ISingleTimerProps) { const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning return ( -
- {timer.label} -
- {timerSign} - {parts.map((p, i) => ( - - - {p} - - {i < parts.length - 1 && :} - - ))} -
-
+ {timerSign} + {parts.map((p, i) => ( + + + {p} + + {i < parts.length - 1 && :} + + ))} + ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx new file mode 100644 index 0000000000..b6f1c971c1 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -0,0 +1,30 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { getPlaylistTimingDiff } from '../../../lib/rundownTiming' +import { RundownUtils } from '../../../lib/rundown' + +export interface IRundownHeaderTimingDisplayProps { + playlist: DBRundownPlaylist +} + +export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDisplayProps): JSX.Element | null { + const timingDurations = useTiming() + + const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(overUnderClock), false, false, true, true, true) + const isUnder = overUnderClock <= 0 + + return ( +
+ + {isUnder ? 'Under' : 'Over'} + + {isUnder ? '−' : '+'} + {timeStr} + + +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx index 0ade467075..809c544fff 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx @@ -5,7 +5,7 @@ import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/co import { useTranslation } from 'react-i18next' import * as RundownResolver from '../../../lib/RundownResolver' import { AutoNextStatus } from '../RundownTiming/AutoNextStatus' -import { CurrentPartOrSegmentRemaining } from '../RundownTiming/CurrentPartOrSegmentRemaining' +import { CurrentPartOrSegmentRemaining } from '../RundownHeader/CurrentPartOrSegmentRemaining' import { NextBreakTiming } from '../RundownTiming/NextBreakTiming' import { PlaylistEndTiming } from '../RundownTiming/PlaylistEndTiming' import { PlaylistStartTiming } from '../RundownTiming/PlaylistStartTiming' diff --git a/packages/webui/src/client/ui/RundownView/RundownTiming/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownTiming/CurrentPartOrSegmentRemaining.tsx deleted file mode 100644 index 1322e9bb32..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownTiming/CurrentPartOrSegmentRemaining.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import * as React from 'react' -import ClassNames from 'classnames' -import { TimingDataResolution, TimingTickResolution, withTiming, WithTiming } from './withTiming.js' -import { RundownUtils } from '../../../lib/rundown.js' -import { SpeechSynthesiser } from '../../../lib/speechSynthesis.js' -import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' - -const SPEAK_ADVANCE = 500 - -interface IPartRemainingProps { - currentPartInstanceId: PartInstanceId | null - hideOnZero?: boolean - className?: string - heavyClassName?: string - speaking?: boolean - vibrating?: boolean - /** Use the segment budget instead of the part duration if available */ - preferSegmentTime?: boolean -} - -// global variable for remembering last uttered displayTime -let prevDisplayTime: number | undefined = undefined - -/** - * A presentational component that will render a countdown to the end of the current part or segment, - * depending on the value of segmentTiming.countdownType - * - * @class CurrentPartOrSegmentRemaining - * @extends React.Component> - */ -export const CurrentPartOrSegmentRemaining = withTiming({ - tickResolution: TimingTickResolution.Synced, - dataResolution: TimingDataResolution.Synced, -})( - class CurrentPartOrSegmentRemaining extends React.Component> { - render(): JSX.Element | null { - if (!this.props.timingDurations || !this.props.timingDurations.currentTime) return null - if (this.props.timingDurations.currentPartInstanceId !== this.props.currentPartInstanceId) return null - let displayTimecode = this.props.timingDurations.remainingTimeOnCurrentPart - if (this.props.preferSegmentTime) - displayTimecode = this.props.timingDurations.remainingBudgetOnCurrentSegment ?? displayTimecode - if (displayTimecode === undefined) return null - displayTimecode *= -1 - return ( - 0 ? this.props.heavyClassName : undefined - )} - role="timer" - > - {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} - - ) - } - - speak(displayTime: number) { - let text = '' // Say nothing - - switch (displayTime) { - case -1: - text = 'One' - break - case -2: - text = 'Two' - break - case -3: - text = 'Three' - break - case -4: - text = 'Four' - break - case -5: - text = 'Five' - break - case -6: - text = 'Six' - break - case -7: - text = 'Seven' - break - case -8: - text = 'Eight' - break - case -9: - text = 'Nine' - break - case -10: - text = 'Ten' - break - } - // if (displayTime === 0 && prevDisplayTime !== undefined) { - // text = 'Zero' - // } - - if (text) { - SpeechSynthesiser.speak(text, 'countdown') - } - } - - vibrate(displayTime: number) { - if ('vibrate' in navigator) { - switch (displayTime) { - case 0: - navigator.vibrate([500]) - break - case -1: - case -2: - case -3: - navigator.vibrate([250]) - break - } - } - } - - act() { - // Note that the displayTime is negative when counting down to 0. - let displayTime = (this.props.timingDurations.remainingTimeOnCurrentPart || 0) * -1 - - if (displayTime === 0) { - // do nothing - } else { - displayTime += SPEAK_ADVANCE - displayTime = Math.floor(displayTime / 1000) - } - - if (prevDisplayTime !== displayTime) { - if (this.props.speaking) { - this.speak(displayTime) - } - - if (this.props.vibrating) { - this.vibrate(displayTime) - } - - prevDisplayTime = displayTime - } - } - - componentDidUpdate(prevProps: WithTiming) { - if (this.props.currentPartInstanceId !== prevProps.currentPartInstanceId) { - prevDisplayTime = undefined - } - this.act() - } - } -) diff --git a/packages/webui/src/client/ui/SegmentList/LinePart.tsx b/packages/webui/src/client/ui/SegmentList/LinePart.tsx index 0c89801e0d..014618f726 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePart.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePart.tsx @@ -7,7 +7,7 @@ import { contextMenuHoldToDisplayTime } from '../../lib/lib.js' import { RundownUtils } from '../../lib/rundown.js' import { getElementDocumentOffset } from '../../utils/positions.js' import { IContextMenuContext } from '../RundownView.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { PieceUi, SegmentUi } from '../SegmentContainer/withResolvedSegment.js' import { SegmentTimelinePartElementId } from '../SegmentTimeline/Parts/SegmentTimelinePart.js' import { LinePartIdentifier } from './LinePartIdentifier.js' diff --git a/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx b/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx index cbbd9b86c0..f353f6edbb 100644 --- a/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx +++ b/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx @@ -4,7 +4,7 @@ import { SIMULATED_PLAYBACK_HARD_MARGIN } from '../SegmentTimeline/Constants.js' import { PartInstanceLimited } from '../../lib/RundownResolver.js' import { useTranslation } from 'react-i18next' import { getAllowSpeaking, getAllowVibrating } from '../../lib/localStorage.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { AutoNextStatus } from '../RundownView/RundownTiming/AutoNextStatus.js' import classNames from 'classnames' diff --git a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx index 3a3b4202a8..32a94dc407 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx @@ -11,7 +11,7 @@ import { getElementDocumentOffset } from '../../utils/positions.js' import { IContextMenuContext } from '../RundownView.js' import { literal } from '@sofie-automation/corelib/dist/lib' import { SegmentTimelinePartElementId } from '../SegmentTimeline/Parts/SegmentTimelinePart.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { getAllowSpeaking, getAllowVibrating } from '../../lib/localStorage.js' import { HighlightEvent, RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' import { Meteor } from 'meteor/meteor' diff --git a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx index 5c419819d5..df60feeeba 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -12,7 +12,7 @@ import { SegmentTimelineZoomControls } from './SegmentTimelineZoomControls.js' import { SegmentDuration } from '../RundownView/RundownTiming/SegmentDuration.js' import { PartCountdown } from '../RundownView/RundownTiming/PartCountdown.js' import { RundownTiming } from '../RundownView/RundownTiming/RundownTiming.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { RundownUtils } from '../../lib/rundown.js' import { Translated } from '../../lib/ReactMeteorData/ReactMeteorData.js' diff --git a/packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx b/packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx index 3288f3b2e2..cd1987a723 100644 --- a/packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx @@ -8,7 +8,7 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { dashboardElementStyle } from './DashboardPanel.js' import { RundownLayoutsAPI } from '../../lib/rundownLayouts.js' import { getAllowSpeaking, getAllowVibrating } from '../../lib/localStorage.js' -import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining.js' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' import { CurrentPartElapsed } from '../RundownView/RundownTiming/CurrentPartElapsed.js' import { getIsFilterActive } from '../../lib/rundownLayouts.js' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' From f8cac9a94648c0e056a8546cf159f17035e722ce Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:53:25 +0100 Subject: [PATCH 24/79] top-bar ui: some style changes, implement est. end properly' --- .../RundownView/RundownHeader/Countdown.tsx | 4 +-- .../CurrentPartOrSegmentRemaining.tsx | 9 +++-- .../RundownHeader/RundownHeader.scss | 19 ++--------- .../RundownHeader/RundownHeader.tsx | 24 ++++++-------- .../RundownHeaderExpectedEnd.tsx | 33 +++++++++++++++---- 5 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx index 487f0a4367..7e79aaab31 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames' import './Countdown.scss' interface IProps { - label: string + label?: string time?: number className?: string children?: React.ReactNode @@ -13,7 +13,7 @@ interface IProps { export function Countdown({ label, time, className, children }: IProps): JSX.Element { return ( - {label} + {label && {label}} {time !== undefined ? : children} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx index 905e32b768..e2a6143637 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -5,10 +5,13 @@ import { RundownUtils } from '../../../lib/rundown.js' import { SpeechSynthesiser } from '../../../lib/speechSynthesis.js' import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Countdown } from './Countdown.js' + const SPEAK_ADVANCE = 500 interface IPartRemainingProps { currentPartInstanceId: PartInstanceId | null + label?: string hideOnZero?: boolean className?: string heavyClassName?: string @@ -129,11 +132,11 @@ export const CurrentPartOrSegmentRemaining: React.FC = (pro displayTimecode *= -1 return ( - 0 ? props.heavyClassName : undefined)} - role="timer" > {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} - + ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index f44c5ba570..612018c8cc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -136,7 +136,6 @@ flex-direction: column; justify-content: center; /* Center vertically against the entire header height */ align-items: flex-end; - font-size: 0.75em; .timing__header_t-timers__timer { white-space: nowrap; @@ -217,14 +216,8 @@ transition: opacity 0.2s; } - &__value { - font-size: 1.4em; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; - - .overtime { - color: $general-late-color; - } + .overtime { + color: $general-late-color; } } @@ -235,8 +228,6 @@ justify-content: flex-start; gap: 0.15em; min-width: 7em; - font-size: 0.75em; - padding-top: 0.8em; // Align with the top row of the T-Timers and Seg Budget } .rundown-header__onair-remaining__label { @@ -269,12 +260,6 @@ color: #40b8fa; } - .rundown-header__segment-remaining, - .rundown-header__onair-remaining, - .countdown { - color: white; - } - .rundown-header__segment-remaining__label, .rundown-header__onair-remaining__label { opacity: 1; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 4ce0c9c1a4..345fc0d4ab 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -60,23 +60,19 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader
{t('Seg. Budg.')} - - - + {t('On Air')} - - - - + +
)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index c78f232685..4bdc83b378 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -1,5 +1,6 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' @@ -8,17 +9,35 @@ export function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlay const { t } = useTranslation() const timingDurations = useTiming() - const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) const now = timingDurations.currentTime ?? Date.now() - const estEnd = - expectedStart != null && timingDurations.remainingPlaylistDuration != null - ? now + timingDurations.remainingPlaylistDuration - : null - if (!expectedEnd && !expectedDuration && !estEnd) return null + // Calculate Est. End by summing partExpectedDurations for all parts after the current one. + // Both partStartsAt and partExpectedDurations use PartInstanceId keys, so they match. + let estEnd: number | null = null + const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId + const partStartsAt = timingDurations.partStartsAt + const partExpectedDurations = timingDurations.partExpectedDurations + + if (currentPartInstanceId && partStartsAt && partExpectedDurations) { + const currentKey = unprotectString(currentPartInstanceId) + const currentStartsAt = partStartsAt[currentKey] + + if (currentStartsAt != null) { + let remainingDuration = 0 + for (const [partId, startsAt] of Object.entries(partStartsAt)) { + if (startsAt > currentStartsAt) { + remainingDuration += partExpectedDurations[partId] ?? 0 + } + } + if (remainingDuration > 0) { + estEnd = now + remainingDuration + } + } + } + + if (!expectedEnd && !estEnd) return null return (
From 174eed5d32f252622bfb1033cb6781eaed58001f Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 26 Feb 2026 11:53:36 +0100 Subject: [PATCH 25/79] chore: Styling walltime clock with correct font properties. --- .../ui/RundownView/RundownHeader/RundownHeader.scss | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 612018c8cc..eaffe9bd88 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -7,7 +7,9 @@ width: 100%; border-bottom: 1px solid #333; transition: background-color 0.5s; - font-family: 'Roboto Flex', sans-serif; + font-family: 'Roboto Flex', 'Roboto', sans-serif; + font-feature-settings: 'liga' 0, 'tnum'; + font-variant-numeric: tabular-nums; .rundown-header__trigger { height: 100%; @@ -70,14 +72,13 @@ .timing-clock { color: #40b8fa; font-size: 1.4em; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; + + letter-spacing: 0.0 em; transition: color 0.2s; &.time-now { - font-size: 1.6em; - font-style: italic; - font-weight: 300; + font-size: 1.8em; + font-variation-settings: 'wdth' 70, 'wght' 400, 'slnt' -5, 'GRAD' 0, 'opsz' 44, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; } } From e92803ecbda24502ae5db79342e30f502ccd512c Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 26 Feb 2026 13:15:55 +0100 Subject: [PATCH 26/79] chore: Styling of Over/Under labels and ON AIR label. --- .../RundownHeader/RundownHeader.scss | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index eaffe9bd88..28ef20814f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -92,16 +92,15 @@ display: flex; align-items: center; gap: 0.4em; - font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; font-variant-numeric: tabular-nums; white-space: nowrap; .rundown-header__diff__label { - font-size: 0.85em; - font-weight: 700; + font-size: 0.7em; + font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; text-transform: uppercase; - letter-spacing: 0.06em; - color: #888; + letter-spacing: 0.01em; + color: #666666; } .rundown-header__diff__chip { @@ -143,11 +142,9 @@ line-height: 1.25; .countdown__label { - color: #b8b8b8; } .countdown__value { - font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; font-weight: 500; color: #fff; } @@ -209,9 +206,9 @@ transition: color 0.2s; &__label { - font-size: 0.7em; - font-weight: 600; - letter-spacing: 0.1em; + font-size: 0.8em; + font-variation-settings: 'wdth' 80, 'wght' 600, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + letter-spacing: 0.05em; text-transform: uppercase; opacity: 0; transition: opacity 0.2s; @@ -233,8 +230,8 @@ .rundown-header__onair-remaining__label { background-color: $general-live-color; - color: #fff; - padding: 0.1em 0.6em 0.1em 0.3em; + color: #ffffff; + padding: 0.03em 0.45em 0.02em 0.2em; border-radius: 2px 999px 999px 2px; font-weight: bold; opacity: 1 !important; From 9656727c8090a6b51ec1678b92ad4e178dde83ea Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 10:45:38 +0100 Subject: [PATCH 27/79] chore: Common label style for header labels that react to hover. Fixed the hover state of labels. Show timer labels are still font-size 600 for some weird reason, needs to be fixed later. --- .../RundownView/RundownHeader/Countdown.scss | 3 +- .../RundownHeader/RundownHeader.scss | 42 ++++++++++++------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 16e73ba385..b46ccad9ab 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -1,4 +1,5 @@ @import '../../../styles/colorScheme'; +@import './RundownHeader.scss'; .countdown { display: flex; @@ -9,10 +10,10 @@ transition: color 0.2s; &__label { + @extend .rundown-header__hoverable-label; font-size: 0.7em; font-weight: 600; letter-spacing: 0.1em; - text-transform: uppercase; white-space: nowrap; } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 28ef20814f..474cf839f0 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -96,16 +96,16 @@ white-space: nowrap; .rundown-header__diff__label { + @extend .rundown-header__hoverable-label; font-size: 0.7em; font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; - text-transform: uppercase; - letter-spacing: 0.01em; - color: #666666; + color: #fff; + opacity: 0.6; } .rundown-header__diff__chip { font-size: 1.1em; - font-weight: 500; + //font-weight: 500; padding: 0.15em 0.75em; border-radius: 999px; font-variant-numeric: tabular-nums; @@ -142,10 +142,12 @@ line-height: 1.25; .countdown__label { + @extend .rundown-header__hoverable-label; + font-size: 0.65em; } .countdown__value { - font-weight: 500; + //font-weight: 500; color: #fff; } @@ -153,7 +155,7 @@ display: inline-block; width: 0.6em; text-align: center; - font-weight: 500; + //font-weight: 500; font-size: 1.1em; color: #fff; margin-right: 0.3em; @@ -196,6 +198,16 @@ gap: 0.1em; } + // Common label style for header labels that react to hover + .rundown-header__hoverable-label { + font-size: 0.75em; + font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + letter-spacing: 0.01em; + text-transform: uppercase; + opacity: 0.6; + transition: opacity 0.2s; + } + .rundown-header__segment-remaining, .rundown-header__onair-remaining { display: flex; @@ -206,12 +218,7 @@ transition: color 0.2s; &__label { - font-size: 0.8em; - font-variation-settings: 'wdth' 80, 'wght' 600, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0; - transition: opacity 0.2s; + @extend .rundown-header__hoverable-label; } .overtime { @@ -233,7 +240,11 @@ color: #ffffff; padding: 0.03em 0.45em 0.02em 0.2em; border-radius: 2px 999px 999px 2px; - font-weight: bold; + // Label font styling override meant to match the ON AIR label on the On Air line + font-size: 0.8em; + letter-spacing: 0.05em; + font-variation-settings: 'wdth' 80, 'wght' 700, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + opacity: 1 !important; .freeze-frame-icon { @@ -258,7 +269,10 @@ color: #40b8fa; } - .rundown-header__segment-remaining__label, + .rundown-header__hoverable-label { + opacity: 1; + } + .rundown-header__onair-remaining__label { opacity: 1; } From 9e73ab78cec653859a2599fdcf2ec1bcf6b8b868 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 12:34:39 +0100 Subject: [PATCH 28/79] chore: Updated styling on the Over/Under (previously known as "Diff") counter and adjusted the close icon placement. --- .../RundownView/RundownHeader/RundownHeader.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 474cf839f0..1fc71523d3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -85,7 +85,7 @@ .rundown-header__timing-display { display: flex; align-items: center; - margin-right: 1.5em; + margin-right: 0.5em; margin-left: 2em; .rundown-header__diff { @@ -99,28 +99,27 @@ @extend .rundown-header__hoverable-label; font-size: 0.7em; font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; - color: #fff; opacity: 0.6; } .rundown-header__diff__chip { - font-size: 1.1em; - //font-weight: 500; - padding: 0.15em 0.75em; + font-size: 1.2em; + padding: 0.0em 0.3em; border-radius: 999px; - font-variant-numeric: tabular-nums; + font-variation-settings: 'wdth' 25, 'wght' 600, 'slnt' 0, 'GRAD' 0, 'opsz' 20, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + letter-spacing: -0.02em; } &.rundown-header__diff--under { .rundown-header__diff__chip { - background-color: #c8a800; + background-color: #ff0;//$general-fast-color; color: #000; } } &.rundown-header__diff--over { .rundown-header__diff__chip { - background-color: #b00; + background-color: $general-late-color; color: #fff; } } @@ -258,6 +257,7 @@ .rundown-header__close-btn { display: flex; align-items: center; + margin-right: 0.75em; cursor: pointer; color: #40b8fa; opacity: 0; From 7322b8e0f7ace27f3b8afc13a8f0d7026a9af9ea Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 12:41:00 +0100 Subject: [PATCH 29/79] chore: Removed extra styling of the labels of the Show Counters group. --- .../src/client/ui/RundownView/RundownHeader/Countdown.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index b46ccad9ab..1ad7a17eaa 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -6,14 +6,11 @@ align-items: baseline; justify-content: space-between; gap: 0.6em; - color: rgba(255, 255, 255, 0.6); + //color: rgba(255, 255, 255, 0.6); transition: color 0.2s; &__label { @extend .rundown-header__hoverable-label; - font-size: 0.7em; - font-weight: 600; - letter-spacing: 0.1em; white-space: nowrap; } From 6deba1531421851210f824ea50ffef9005de5b60 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:30:58 +0100 Subject: [PATCH 30/79] add simplified mode to top bar --- .../RundownView/RundownHeader/Countdown.scss | 1 + .../RundownHeader/RundownHeader.scss | 92 +++++++++++++++++-- .../RundownHeader/RundownHeader.tsx | 10 +- .../RundownHeader/RundownHeaderDurations.tsx | 36 ++++++-- .../RundownHeaderExpectedEnd.tsx | 42 ++++----- .../RundownHeaderPlannedStart.tsx | 23 ++++- .../RundownHeader/remainingDuration.ts | 26 ++++++ 7 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 1ad7a17eaa..4a0819ba62 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -15,6 +15,7 @@ } &__value { + margin-left: auto; font-size: 1.4em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 1fc71523d3..c00eafc7cc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -8,7 +8,9 @@ border-bottom: 1px solid #333; transition: background-color 0.5s; font-family: 'Roboto Flex', 'Roboto', sans-serif; - font-feature-settings: 'liga' 0, 'tnum'; + font-feature-settings: + 'liga' 0, + 'tnum'; font-variant-numeric: tabular-nums; .rundown-header__trigger { @@ -73,12 +75,25 @@ color: #40b8fa; font-size: 1.4em; - letter-spacing: 0.0 em; + letter-spacing: 0 em; transition: color 0.2s; &.time-now { font-size: 1.8em; - font-variation-settings: 'wdth' 70, 'wght' 400, 'slnt' -5, 'GRAD' 0, 'opsz' 44, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + font-variation-settings: + 'wdth' 70, + 'wght' 400, + 'slnt' -5, + 'GRAD' 0, + 'opsz' 44, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } } @@ -98,21 +113,47 @@ .rundown-header__diff__label { @extend .rundown-header__hoverable-label; font-size: 0.7em; - font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; opacity: 0.6; } .rundown-header__diff__chip { font-size: 1.2em; - padding: 0.0em 0.3em; + padding: 0em 0.3em; border-radius: 999px; - font-variation-settings: 'wdth' 25, 'wght' 600, 'slnt' 0, 'GRAD' 0, 'opsz' 20, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + font-variation-settings: + 'wdth' 25, + 'wght' 600, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 20, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; letter-spacing: -0.02em; } &.rundown-header__diff--under { .rundown-header__diff__chip { - background-color: #ff0;//$general-fast-color; + background-color: #ff0; //$general-fast-color; color: #000; } } @@ -200,7 +241,20 @@ // Common label style for header labels that react to hover .rundown-header__hoverable-label { font-size: 0.75em; - font-variation-settings: 'wdth' 25, 'wght' 500, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; letter-spacing: 0.01em; text-transform: uppercase; opacity: 0.6; @@ -234,6 +288,13 @@ min-width: 7em; } + .rundown-header__timing-group { + display: flex; + align-items: center; + gap: 1em; + cursor: pointer; + } + .rundown-header__onair-remaining__label { background-color: $general-live-color; color: #ffffff; @@ -242,7 +303,20 @@ // Label font styling override meant to match the ON AIR label on the On Air line font-size: 0.8em; letter-spacing: 0.05em; - font-variation-settings: 'wdth' 80, 'wght' 700, 'slnt' 0, 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTFI' 738, 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; + font-variation-settings: + 'wdth' 80, + 'wght' 700, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; opacity: 1 !important; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 345fc0d4ab..321de6c504 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import ClassNames from 'classnames' import { NavLink } from 'react-router-dom' import * as CoreIcon from '@nrk/core-icons/jsx' @@ -38,6 +39,7 @@ interface IRundownHeaderProps { export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() + const [simplified, setSimplified] = useState(false) return ( <> @@ -85,9 +87,11 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader
- - - +
setSimplified((s) => !s)}> + + + +
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 8c98979360..3a978a640a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -4,8 +4,15 @@ import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' +import { getRemainingDurationFromCurrentPart } from './remainingDuration' -export function RundownHeaderDurations({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { +export function RundownHeaderDurations({ + playlist, + simplified, +}: { + playlist: DBRundownPlaylist + simplified?: boolean +}): JSX.Element | null { const { t } = useTranslation() const timingDurations = useTiming() @@ -13,12 +20,25 @@ export function RundownHeaderDurations({ playlist }: { playlist: DBRundownPlayli const planned = expectedDuration != null ? RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true) : null - const remainingMs = timingDurations.remainingPlaylistDuration - const startedMs = playlist.startedPlayback - const estDuration = - remainingMs != null && startedMs != null - ? (timingDurations.currentTime ?? Date.now()) - startedMs + remainingMs - : null + const now = timingDurations.currentTime ?? Date.now() + const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId + + let estDuration: number | null = null + if (currentPartInstanceId && timingDurations.partStartsAt && timingDurations.partExpectedDurations) { + const remaining = getRemainingDurationFromCurrentPart( + currentPartInstanceId, + timingDurations.partStartsAt, + timingDurations.partExpectedDurations + ) + if (remaining != null) { + const elapsed = + playlist.startedPlayback != null + ? now - playlist.startedPlayback + : (timingDurations.asDisplayedPlaylistDuration ?? 0) + estDuration = elapsed + remaining + } + } + const estimated = estDuration != null ? RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true) : null @@ -27,7 +47,7 @@ export function RundownHeaderDurations({ playlist }: { playlist: DBRundownPlayli return (
{planned ? {planned} : null} - {estimated ? {estimated} : null} + {!simplified && estimated ? {estimated} : null}
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 4bdc83b378..a656041756 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -1,39 +1,33 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' - -export function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { +import { getRemainingDurationFromCurrentPart } from './remainingDuration' + +export function RundownHeaderExpectedEnd({ + playlist, + simplified, +}: { + playlist: DBRundownPlaylist + simplified?: boolean +}): JSX.Element | null { const { t } = useTranslation() const timingDurations = useTiming() const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - const now = timingDurations.currentTime ?? Date.now() - // Calculate Est. End by summing partExpectedDurations for all parts after the current one. - // Both partStartsAt and partExpectedDurations use PartInstanceId keys, so they match. let estEnd: number | null = null const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId - const partStartsAt = timingDurations.partStartsAt - const partExpectedDurations = timingDurations.partExpectedDurations - - if (currentPartInstanceId && partStartsAt && partExpectedDurations) { - const currentKey = unprotectString(currentPartInstanceId) - const currentStartsAt = partStartsAt[currentKey] - - if (currentStartsAt != null) { - let remainingDuration = 0 - for (const [partId, startsAt] of Object.entries(partStartsAt)) { - if (startsAt > currentStartsAt) { - remainingDuration += partExpectedDurations[partId] ?? 0 - } - } - if (remainingDuration > 0) { - estEnd = now + remainingDuration - } + if (currentPartInstanceId && timingDurations.partStartsAt && timingDurations.partExpectedDurations) { + const remaining = getRemainingDurationFromCurrentPart( + currentPartInstanceId, + timingDurations.partStartsAt, + timingDurations.partExpectedDurations + ) + if (remaining != null && remaining > 0) { + estEnd = now + remaining } } @@ -42,7 +36,7 @@ export function RundownHeaderExpectedEnd({ playlist }: { playlist: DBRundownPlay return (
{expectedEnd ? : null} - {estEnd ? : null} + {!simplified && estEnd ? : null}
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 7bc995f23e..8042bea57b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -2,16 +2,37 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' -export function RundownHeaderPlannedStart({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element | null { +export function RundownHeaderPlannedStart({ + playlist, + simplified, +}: { + playlist: DBRundownPlaylist + simplified?: boolean +}): JSX.Element | null { const { t } = useTranslation() + const timingDurations = useTiming() const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) if (expectedStart == null) return null + const now = timingDurations.currentTime ?? Date.now() + const diff = now - expectedStart + return (
+ {!simplified && + (playlist.startedPlayback ? ( + + ) : ( + + {diff >= 0 && '-'} + {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} + + ))}
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts b/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts new file mode 100644 index 0000000000..b54bb6c74f --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts @@ -0,0 +1,26 @@ +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' + +/** + * Compute the sum of expected durations of all parts after the current part. + * Uses partStartsAt to determine ordering and partExpectedDurations for the values. + * Returns 0 if the current part can't be found or there are no future parts. + */ +export function getRemainingDurationFromCurrentPart( + currentPartInstanceId: PartInstanceId, + partStartsAt: Record, + partExpectedDurations: Record +): number | null { + const currentKey = unprotectString(currentPartInstanceId) + const currentStartsAt = partStartsAt[currentKey] + + if (currentStartsAt == null) return null + + let remaining = 0 + for (const [partId, startsAt] of Object.entries(partStartsAt)) { + if (startsAt > currentStartsAt) { + remaining += partExpectedDurations[partId] ?? 0 + } + } + return remaining +} From c6e1de54651a157b56cd8a75eed397c3b61b4adc Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:13:11 +0100 Subject: [PATCH 31/79] New top bar UI: refactor class names --- .../RundownHeader/RundownContextMenu.tsx | 2 +- .../RundownHeader/RundownHeader.scss | 91 +++++++++++++------ .../RundownHeader/RundownHeader.tsx | 19 ++-- .../RundownHeader/RundownHeaderDurations.tsx | 14 ++- .../RundownHeaderExpectedEnd.tsx | 10 +- .../RundownHeaderPlannedStart.tsx | 4 +- .../RundownHeader/RundownHeaderTimers.tsx | 26 +++--- .../RundownHeaderTimingDisplay.tsx | 10 +- .../RundownView/RundownTiming/TimeOfDay.tsx | 5 +- 9 files changed, 117 insertions(+), 64 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index b91da2b0b2..3941357cff 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -172,7 +172,7 @@ export function RundownHamburgerButton(): JSX.Element { }, []) return ( - ) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index c00eafc7cc..28b61de95e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -27,13 +27,13 @@ &.active { background: $color-header-on-air; - .rundown-header__segment-remaining, - .rundown-header__onair-remaining, - .countdown { + .rundown-header__timers-segment-remaining, + .rundown-header__timers-onair-remaining, + .rundown-header__show-timers-countdown { color: #fff; } - .timing__header_t-timers__timer__label { + .rundown-header__clocks-timers__timer__label { color: rgba(255, 255, 255, 0.9); } @@ -65,7 +65,7 @@ gap: 1em; } - .rundown-header__center { + .rundown-header__clocks { display: flex; align-items: center; justify-content: center; @@ -97,20 +97,35 @@ } } - .rundown-header__timing-display { + .rundown-header__clocks-clock-group { + display: flex; + flex-direction: column; + align-items: center; + } + + .rundown-header__clocks-playlist-name { + @extend .rundown-header__hoverable-label; + font-size: 0.65em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 20em; + } + + .rundown-header__clocks-timing-display { display: flex; align-items: center; margin-right: 0.5em; margin-left: 2em; - .rundown-header__diff { + .rundown-header__clocks-diff { display: flex; align-items: center; gap: 0.4em; font-variant-numeric: tabular-nums; white-space: nowrap; - .rundown-header__diff__label { + .rundown-header__clocks-diff__label { @extend .rundown-header__hoverable-label; font-size: 0.7em; font-variation-settings: @@ -130,7 +145,7 @@ opacity: 0.6; } - .rundown-header__diff__chip { + .rundown-header__clocks-diff__chip { font-size: 1.2em; padding: 0em 0.3em; border-radius: 999px; @@ -151,15 +166,15 @@ letter-spacing: -0.02em; } - &.rundown-header__diff--under { - .rundown-header__diff__chip { + &.rundown-header__clocks-diff--under { + .rundown-header__clocks-diff__chip { background-color: #ff0; //$general-fast-color; color: #000; } } - &.rundown-header__diff--over { - .rundown-header__diff__chip { + &.rundown-header__clocks-diff--over { + .rundown-header__clocks-diff__chip { background-color: $general-late-color; color: #fff; } @@ -167,7 +182,7 @@ } } - .timing__header_t-timers { + .rundown-header__clocks-timers { position: absolute; left: 28%; /* Position exactly between the 15% left edge content and the 50% center clock */ top: 0; @@ -177,7 +192,7 @@ justify-content: center; /* Center vertically against the entire header height */ align-items: flex-end; - .timing__header_t-timers__timer { + .rundown-header__clocks-timers__timer { white-space: nowrap; line-height: 1.25; @@ -191,7 +206,7 @@ color: #fff; } - .timing__header_t-timers__timer__sign { + .rundown-header__clocks-timers__timer__sign { display: inline-block; width: 0.6em; text-align: center; @@ -201,14 +216,14 @@ margin-right: 0.3em; } - .timing__header_t-timers__timer__part { + .rundown-header__clocks-timers__timer__part { color: #fff; - &.timing__header_t-timers__timer__part--dimmed { + &.rundown-header__clocks-timers__timer__part--dimmed { color: #888; font-weight: 400; } } - .timing__header_t-timers__timer__separator { + .rundown-header__clocks-timers__timer__separator { margin: 0 0.05em; color: #888; } @@ -216,7 +231,7 @@ } } - .rundown-header__hamburger-btn { + .rundown-header__menu-btn { background: none; border: none; color: #40b8fa99; @@ -230,7 +245,7 @@ transition: color 0.2s; } - .rundown-header__timers { + .rundown-header__onair { display: flex; flex-direction: column; align-items: stretch; @@ -261,8 +276,8 @@ transition: opacity 0.2s; } - .rundown-header__segment-remaining, - .rundown-header__onair-remaining { + .rundown-header__timers-segment-remaining, + .rundown-header__timers-onair-remaining { display: flex; align-items: center; justify-content: space-between; @@ -280,7 +295,7 @@ } // Stacked Plan. Start / Plan. End / Est. End in right section - .rundown-header__endtimes { + .rundown-header__show-timers-endtimes { display: flex; flex-direction: column; justify-content: flex-start; @@ -288,14 +303,34 @@ min-width: 7em; } - .rundown-header__timing-group { + .rundown-header__show-timers { display: flex; align-items: center; gap: 1em; cursor: pointer; } - .rundown-header__onair-remaining__label { + .rundown-header__show-timers-countdown { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.6em; + transition: color 0.2s; + + .countdown__label { + @extend .rundown-header__hoverable-label; + white-space: nowrap; + } + + .countdown__value { + margin-left: auto; + font-size: 1.4em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + } + } + + .rundown-header__timers-onair-remaining__label { background-color: $general-live-color; color: #ffffff; padding: 0.03em 0.45em 0.02em 0.2em; @@ -339,7 +374,7 @@ } &:hover { - .rundown-header__hamburger-btn { + .rundown-header__menu-btn { color: #40b8fa; } @@ -347,7 +382,7 @@ opacity: 1; } - .rundown-header__onair-remaining__label { + .rundown-header__timers-onair-remaining__label { opacity: 1; } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 321de6c504..b60efd7180 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -59,17 +59,17 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader
{playlist.currentPartInfo && ( -
- - {t('Seg. Budg.')} +
+ + {t('Seg. Budg.')} - - {t('On Air')} + + {t('On Air')} -
+
- +
+ + {playlist.name} +
-
setSimplified((s) => !s)}> +
setSimplified((s) => !s)}> diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 3a978a640a..8c800c5f94 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -45,9 +45,17 @@ export function RundownHeaderDurations({ if (!planned && !estimated) return null return ( -
- {planned ? {planned} : null} - {!simplified && estimated ? {estimated} : null} +
+ {planned ? ( + + {planned} + + ) : null} + {!simplified && estimated ? ( + + {estimated} + + ) : null}
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index a656041756..fe90f5b80a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -34,9 +34,13 @@ export function RundownHeaderExpectedEnd({ if (!expectedEnd && !estEnd) return null return ( -
- {expectedEnd ? : null} - {!simplified && estEnd ? : null} +
+ {expectedEnd ? ( + + ) : null} + {!simplified && estEnd ? ( + + ) : null}
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 8042bea57b..f764b037fe 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -22,8 +22,8 @@ export function RundownHeaderPlannedStart({ const diff = now - expectedStart return ( -
- +
+ {!simplified && (playlist.startedPlayback ? ( diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 9e265990f4..77e4606263 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -20,7 +20,7 @@ export const RundownHeaderTimers: React.FC = ({ tTimers }) => { const activeTimers = tTimers.filter((t) => t.mode) return ( -
+
{activeTimers.map((timer) => ( ))} @@ -48,28 +48,28 @@ function SingleTimer({ timer }: ISingleTimerProps) { return ( - {timerSign} + {timerSign} {parts.map((p, i) => ( {p} - {i < parts.length - 1 && :} + {i < parts.length - 1 && :} ))} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx index b6f1c971c1..fceea32777 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -15,12 +15,14 @@ export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDis const isUnder = overUnderClock <= 0 return ( -
+
- {isUnder ? 'Under' : 'Over'} - + {isUnder ? 'Under' : 'Over'} + {isUnder ? '−' : '+'} {timeStr} diff --git a/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx b/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx index 47f205ffd7..75c17b02dd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx @@ -1,11 +1,12 @@ import { useTiming } from './withTiming.js' import Moment from 'react-moment' +import classNames from 'classnames' -export function TimeOfDay(): JSX.Element { +export function TimeOfDay({ className }: { className?: string }): JSX.Element { const timingDurations = useTiming() return ( - + ) From 9e75447af2ea6cdc4736fb73ddfd31581407a828 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:42:37 +0100 Subject: [PATCH 32/79] New top bar UI: remove caret on text and restore legacy component for remaining part time for old uses --- .../CurrentPartOrSegmentRemaining.tsx | 33 ++++++++++++++++++- .../RundownHeader/RundownHeader.scss | 4 +-- .../RundownHeader/RundownHeader.tsx | 6 ++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx index e2a6143637..1f85a03ec4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -80,7 +80,7 @@ function vibrate(displayTime: number) { } } -export const CurrentPartOrSegmentRemaining: React.FC = (props) => { +function usePartRemaining(props: IPartRemainingProps) { const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) const prevPartInstanceId = useRef(null) @@ -131,6 +131,37 @@ export const CurrentPartOrSegmentRemaining: React.FC = (pro if (displayTimecode === undefined) return null displayTimecode *= -1 + return { displayTimecode } +} + +/** + * Original version used across the app — renders a plain with role="timer". + */ +export const CurrentPartOrSegmentRemaining: React.FC = (props) => { + const result = usePartRemaining(props) + if (!result) return null + + const { displayTimecode } = result + + return ( + 0 ? props.heavyClassName : undefined)} + role="timer" + > + {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} + + ) +} + +/** + * RundownHeader variant — renders inside a component with label support. + */ +export const RundownHeaderPartRemaining: React.FC = (props) => { + const result = usePartRemaining(props) + if (!result) return null + + const { displayTimecode } = result + return ( {t('Seg. Budg.')} - {t('On Air')} - From c40538b3d63d9fd588cda03477426fe57fb43fb7 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:15:55 +0100 Subject: [PATCH 33/79] new top bar UI: make playlist name hidden until hovered over --- .../ui/RundownView/RundownHeader/RundownHeader.scss | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index b33f011554..f04e5eea11 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -106,12 +106,14 @@ } .rundown-header__clocks-playlist-name { - @extend .rundown-header__hoverable-label; font-size: 0.65em; white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; max-width: 20em; + color: #fff; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease; } .rundown-header__clocks-timing-display { @@ -389,5 +391,11 @@ .rundown-header__close-btn { opacity: 1; } + + .rundown-header__clocks-clock-group { + .rundown-header__clocks-playlist-name { + max-height: 1.5em; + } + } } } From 8ffbbd4371734793bb38583e724ce911da0964f4 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 16:38:10 +0100 Subject: [PATCH 34/79] chore: Added single-pixel line at the bottom of the Top Bar. --- .../client/ui/RundownView/RundownHeader/RundownHeader.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 167a15b4db..c65472851d 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -28,6 +28,7 @@ &.active { background: $color-header-on-air; + border-bottom: 1px solid #256b91; .rundown-header__timers-segment-remaining, .rundown-header__timers-onair-remaining, @@ -106,6 +107,7 @@ } .rundown-header__clocks-playlist-name { + //@extend .rundown-header__hoverable-label; font-size: 0.65em; white-space: nowrap; text-overflow: ellipsis; @@ -410,6 +412,8 @@ .rundown-header__clocks-clock-group { .rundown-header__clocks-playlist-name { + @extend .rundown-header__hoverable-label; + font-size: 0.65em; max-height: 1.5em; } } From 3dbfc0dfc838117e95abdc119576a2c51d14a490 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 17:03:06 +0100 Subject: [PATCH 35/79] chore: Created two distinct styles for two types of counters. --- .../RundownView/RundownHeader/Countdown.scss | 5 ++- .../RundownView/RundownHeader/Countdown.tsx | 4 +- .../RundownHeader/RundownHeader.scss | 43 ++++++++++++++++--- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 4a0819ba62..0907365e80 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -14,9 +14,10 @@ white-space: nowrap; } - &__value { + &__counter, + &__timeofday { margin-left: auto; - font-size: 1.4em; + font-size: 1.3em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx index 7e79aaab31..c51a8b1bfd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -11,10 +11,12 @@ interface IProps { } export function Countdown({ label, time, className, children }: IProps): JSX.Element { + const valueClassName = time !== undefined ? 'countdown__timeofday' : 'countdown__counter' + return ( {label && {label}} - + {time !== undefined ? : children} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index c65472851d..de68b45ee7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -77,8 +77,7 @@ .timing-clock { color: #40b8fa; font-size: 1.4em; - - letter-spacing: 0 em; + letter-spacing: 0em; transition: color 0.2s; &.time-now { @@ -207,7 +206,7 @@ font-size: 0.65em; } - .countdown__value { + .countdown__counter { color: #fff; } @@ -342,11 +341,45 @@ white-space: nowrap; } - .countdown__value { + .countdown__counter { margin-left: auto; - font-size: 1.4em; + font-size: 1.3em; + letter-spacing: 0em; + font-variation-settings: + 'wdth' 50, + 'wght' 550, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 33, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + } + + .countdown__timeofday { + margin-left: auto; + font-size: 1.3em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; + font-variation-settings: + 'wdth' 70, + 'wght' 400, + 'slnt' -5, + 'GRAD' 0, + 'opsz' 44, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } } From 38db359a4b610dff8cdc78eca26d335b5211e4ba Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 19:58:21 +0100 Subject: [PATCH 36/79] chore: Small tweaks to the typographic styles of Top Bar counters. --- .../RundownView/RundownHeader/RundownHeader.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index de68b45ee7..fe337e0c68 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -133,6 +133,7 @@ .rundown-header__clocks-diff__label { @extend .rundown-header__hoverable-label; font-size: 0.7em; + opacity: 0.6; font-variation-settings: 'wdth' 25, 'wght' 500, @@ -147,13 +148,13 @@ 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; - opacity: 0.6; } .rundown-header__clocks-diff__chip { - font-size: 1.2em; + font-size: 1.4em; padding: 0em 0.3em; border-radius: 999px; + letter-spacing: -0.02em; font-variation-settings: 'wdth' 25, 'wght' 600, @@ -168,12 +169,11 @@ 'YTLC' 548, 'YTDE' -203, 'YTUC' 712; - letter-spacing: -0.02em; } &.rundown-header__clocks-diff--under { .rundown-header__clocks-diff__chip { - background-color: #ff0; //$general-fast-color; + background-color: #ff0; // Should probably be changed to $general-fast-color; color: #000; } } @@ -346,11 +346,11 @@ font-size: 1.3em; letter-spacing: 0em; font-variation-settings: - 'wdth' 50, + 'wdth' 60, 'wght' 550, 'slnt' 0, 'GRAD' 0, - 'opsz' 33, + 'opsz' 40, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, @@ -365,13 +365,13 @@ margin-left: auto; font-size: 1.3em; font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; + letter-spacing: 0.02em; font-variation-settings: 'wdth' 70, 'wght' 400, 'slnt' -5, 'GRAD' 0, - 'opsz' 44, + 'opsz' 40, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, From af310ea87f94d02ffba01f5eeb383ed0e62e5fb4 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:29:00 +0100 Subject: [PATCH 37/79] New top bar UI: visual tweaks --- .../RundownView/RundownHeader/Countdown.scss | 23 ++++++- .../RundownHeader/RundownHeader.scss | 69 +++++++++---------- .../RundownHeader/RundownHeader.tsx | 13 ++-- .../RundownHeader/RundownHeaderTimers.tsx | 2 +- .../RundownView/RundownTiming/TimeOfDay.tsx | 6 +- 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 4a0819ba62..9a11264a47 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -6,12 +6,13 @@ align-items: baseline; justify-content: space-between; gap: 0.6em; - //color: rgba(255, 255, 255, 0.6); transition: color 0.2s; &__label { @extend .rundown-header__hoverable-label; white-space: nowrap; + position: relative; + top: -0.6em; /* Visually push the label up to align with cap height */ } &__value { @@ -20,4 +21,24 @@ font-variant-numeric: tabular-nums; letter-spacing: 0.05em; } + + &--counter { + .countdown__label { + font-size: 0.65em; + } + .countdown__value { + color: #fff; + line-height: 1; + } + } + + &--timeofday { + .countdown__label { + font-size: 0.7em; + } + .countdown__value { + color: $general-fast-color; + font-weight: 300; + } + } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 167a15b4db..4d5c9e3cfa 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -105,22 +105,49 @@ align-items: center; } + .rundown-header__clocks-top-row { + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + .rundown-header__clocks-playlist-name { font-size: 0.65em; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 20em; + display: flex; + flex-direction: row; + justify-content: center; + gap: 0.4em; + max-width: 40em; color: #fff; max-height: 0; overflow: hidden; transition: max-height 0.2s ease; + + .rundown-name, + .playlist-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 0 1 auto; + min-width: 0; + } + .playlist-name { + font-weight: 700; + } + } + + .rundown-header__clocks-time-now { + @extend .countdown--timeofday; + .countdown__value { + margin-left: 0; // Center it since there's no label + } } .rundown-header__clocks-timing-display { + margin-right: 0.5em; display: flex; align-items: center; - margin-right: 0.5em; - margin-left: 2em; .rundown-header__clocks-diff { display: flex; @@ -187,28 +214,16 @@ } .rundown-header__clocks-timers { - position: absolute; - left: 28%; /* Position exactly between the 15% left edge content and the 50% center clock */ - top: 0; - bottom: 0; display: flex; flex-direction: column; justify-content: center; /* Center vertically against the entire header height */ align-items: flex-end; + margin-right: 3em; .rundown-header__clocks-timers__timer { white-space: nowrap; line-height: 1.25; - .countdown__label { - @extend .rundown-header__hoverable-label; - font-size: 0.65em; - } - - .countdown__value { - color: #fff; - } - .rundown-header__clocks-timers__timer__sign { display: inline-block; width: 0.6em; @@ -329,23 +344,7 @@ } .rundown-header__show-timers-countdown { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.6em; - transition: color 0.2s; - - .countdown__label { - @extend .rundown-header__hoverable-label; - white-space: nowrap; - } - - .countdown__value { - margin-left: auto; - font-size: 1.4em; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; - } + @extend .countdown; } .rundown-header__timers-onair-remaining__label { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 35e40d8238..bf0e125d35 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -37,7 +37,7 @@ interface IRundownHeaderProps { layout: RundownLayoutRundownHeader | undefined } -export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeaderProps): JSX.Element { +export function RundownHeader({ playlist, studio, firstRundown, currentRundown }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() const [simplified, setSimplified] = useState(false) @@ -82,10 +82,15 @@ export function RundownHeader({ playlist, studio, firstRundown }: IRundownHeader
-
- - {playlist.name} +
+ + +
+
+ {(currentRundown ?? firstRundown)?.name} + {playlist.name} +
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index a2cae3c1b6..9a118c367d 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -50,7 +50,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { return ( ): JSX.Element { const timingDurations = useTiming() return ( - + + + ) } From 30d32b9965ece4a8ff293ba90f2caffe554f9d8b Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:58:28 +0100 Subject: [PATCH 38/79] New top bar UI: fix circular scss dependencies --- .../RundownView/RundownHeader/Countdown.scss | 4 ++-- .../RundownHeader/RundownHeader.scss | 22 +++-------------- .../ui/RundownView/RundownHeader/_shared.scss | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 9a11264a47..b51a201464 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -1,5 +1,5 @@ @import '../../../styles/colorScheme'; -@import './RundownHeader.scss'; +@import './shared'; .countdown { display: flex; @@ -9,7 +9,7 @@ transition: color 0.2s; &__label { - @extend .rundown-header__hoverable-label; + @extend %hoverable-label; white-space: nowrap; position: relative; top: -0.6em; /* Visually push the label up to align with cap height */ diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 4d5c9e3cfa..b0ef4a2075 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -1,4 +1,6 @@ @import '../../../styles/colorScheme'; +@import './shared'; +@import './Countdown'; .rundown-header { height: 64px; @@ -288,25 +290,7 @@ // Common label style for header labels that react to hover .rundown-header__hoverable-label { - font-size: 0.75em; - font-variation-settings: - 'wdth' 25, - 'wght' 500, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 14, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; - letter-spacing: 0.01em; - text-transform: uppercase; - opacity: 0.6; - transition: opacity 0.2s; + @extend %hoverable-label; } .rundown-header__timers-segment-remaining, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss new file mode 100644 index 0000000000..993397b9b4 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss @@ -0,0 +1,24 @@ +// Shared placeholder used by both RundownHeader.scss and Countdown.scss. +// Extracted to break the circular @import dependency. + +%hoverable-label { + font-size: 0.75em; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + letter-spacing: 0.01em; + text-transform: uppercase; + opacity: 0.6; + transition: opacity 0.2s; +} From 264bb6e01f3be0e8b3d0949bcb209af8013dbe2e Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:15:10 +0100 Subject: [PATCH 39/79] Top bar UI: fix clocks alignment --- .../RundownHeader/RundownHeader.scss | 92 +++++++++---------- .../RundownHeader/RundownHeader.tsx | 2 +- 2 files changed, 46 insertions(+), 48 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index f3e26ab6aa..4828d00742 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -75,7 +75,6 @@ align-items: center; justify-content: center; flex: 1; - .timing-clock { color: #40b8fa; font-size: 1.4em; @@ -115,7 +114,6 @@ } .rundown-header__clocks-playlist-name { - //@extend .rundown-header__hoverable-label; font-size: 0.65em; display: flex; flex-direction: row; @@ -215,62 +213,62 @@ } } } + } - .rundown-header__clocks-timers { - display: flex; - flex-direction: column; - justify-content: center; /* Center vertically against the entire header height */ - align-items: flex-end; - margin-right: 3em; + .rundown-header__clocks-timers { + margin-left: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; - .rundown-header__clocks-timers__timer { - white-space: nowrap; - line-height: 1.25; + .rundown-header__clocks-timers__timer { + white-space: nowrap; + line-height: 1.25; - .countdown__label { - @extend .rundown-header__hoverable-label; - font-size: 0.65em; - } + .countdown__label { + @extend .rundown-header__hoverable-label; + font-size: 0.65em; + } - .countdown__counter { - color: #fff; - } + .countdown__counter { + color: #fff; + } - .rundown-header__clocks-timers__timer__sign { - display: inline-block; - width: 0.6em; - text-align: center; - font-size: 1.1em; - color: #fff; - margin-right: 0.3em; - } + .rundown-header__clocks-timers__timer__sign { + display: inline-block; + width: 0.6em; + text-align: center; + font-size: 1.1em; + color: #fff; + margin-right: 0.3em; + } - .rundown-header__clocks-timers__timer__part { - color: #fff; - &.rundown-header__clocks-timers__timer__part--dimmed { - color: #888; - font-weight: 400; - } - } - .rundown-header__clocks-timers__timer__separator { - margin: 0 0.05em; + .rundown-header__clocks-timers__timer__part { + color: #fff; + &.rundown-header__clocks-timers__timer__part--dimmed { color: #888; + font-weight: 400; } + } + .rundown-header__clocks-timers__timer__separator { + margin: 0 0.05em; + color: #888; + } - .rundown-header__clocks-timers__timer__over-under { - font-size: 0.75em; - font-weight: 400; - font-variant-numeric: tabular-nums; - margin-left: 0.5em; - white-space: nowrap; + .rundown-header__clocks-timers__timer__over-under { + font-size: 0.75em; + font-weight: 400; + font-variant-numeric: tabular-nums; + margin-left: 0.5em; + white-space: nowrap; - &.rundown-header__clocks-timers__timer__over-under--over { - color: $general-late-color; - } + &.rundown-header__clocks-timers__timer__over-under--over { + color: $general-late-color; + } - &.rundown-header__clocks-timers__timer__over-under--under { - color: #0f0; - } + &.rundown-header__clocks-timers__timer__over-under--under { + color: #0f0; } } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index bf0e125d35..22c2e77e7a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -78,10 +78,10 @@ export function RundownHeader({ playlist, studio, firstRundown, currentRundown }
)} +
-
From 090344954075d4e828657653eca93158bf707efc Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Wed, 4 Mar 2026 09:31:03 +0100 Subject: [PATCH 40/79] chore: Created the two separate font stylings for the Over/Under pill, but they are not yet called correctly in code. --- .../RundownHeader/RundownHeader.scss | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 4828d00742..fa01a21b9f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -177,17 +177,39 @@ 'YTUC' 712; } - .rundown-header__clocks-diff__chip { - font-size: 1.4em; + .rundown-header__clocks-diff__chip--under { + font-size: 1.3em; padding: 0em 0.3em; + line-height: 1em; border-radius: 999px; letter-spacing: -0.02em; font-variation-settings: 'wdth' 25, - 'wght' 600, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 25, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + } + .rundown-header__clocks-diff__chip--over { + font-size: 1.3em; + padding: 0em 0.3em; + line-height: 1em; + border-radius: 999px; + letter-spacing: -0.02em; + font-variation-settings: + 'wdth' 25, + 'wght' 700, 'slnt' 0, 'GRAD' 0, - 'opsz' 20, + 'opsz' 25, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, @@ -208,7 +230,7 @@ &.rundown-header__clocks-diff--over { .rundown-header__clocks-diff__chip { background-color: $general-late-color; - color: #fff; + color: #000000; } } } From bd175d791f53fe0e4fc28fd3b384f97f7f205a37 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:06:33 +0100 Subject: [PATCH 41/79] New top bar ui: visual changes - fix hover transition on text in playlist name, time of day color and time of day timers styles --- .../ui/RundownView/RundownHeader/Countdown.scss | 12 ++++++++++-- .../ui/RundownView/RundownHeader/RundownHeader.scss | 11 +++++++++-- .../ui/RundownView/RundownHeader/RundownHeader.tsx | 10 ++++++++-- .../RundownHeader/RundownHeaderTimers.tsx | 4 +++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 8fdcfbd41f..5d43bc9a5f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -15,12 +15,20 @@ top: -0.6em; /* Visually push the label up to align with cap height */ } - &__counter, + &__counter { + margin-left: auto; + font-size: 1.3em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + } + &__timeofday { margin-left: auto; font-size: 1.3em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; + font-style: italic; + font-weight: 300; } &--counter { @@ -38,7 +46,7 @@ font-size: 0.7em; } .countdown__value { - color: $general-fast-color; + color: #40b8fa; font-weight: 300; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index fa01a21b9f..0e13ca789b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -248,6 +248,15 @@ white-space: nowrap; line-height: 1.25; + &.countdown--timeofday { + .rundown-header__clocks-timers__timer__part, + .rundown-header__clocks-timers__timer__sign, + .rundown-header__clocks-timers__timer__separator { + font-style: italic; + font-weight: 300; + } + } + .countdown__label { @extend .rundown-header__hoverable-label; font-size: 0.65em; @@ -423,8 +432,6 @@ .rundown-header__clocks-clock-group { .rundown-header__clocks-playlist-name { - @extend .rundown-header__hoverable-label; - font-size: 0.65em; max-height: 1.5em; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 22c2e77e7a..161adb45d4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -37,7 +37,13 @@ interface IRundownHeaderProps { layout: RundownLayoutRundownHeader | undefined } -export function RundownHeader({ playlist, studio, firstRundown, currentRundown }: IRundownHeaderProps): JSX.Element { +export function RundownHeader({ + playlist, + studio, + firstRundown, + currentRundown, + rundownCount, +}: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() const [simplified, setSimplified] = useState(false) @@ -89,7 +95,7 @@ export function RundownHeader({ playlist, studio, firstRundown, currentRundown }
{(currentRundown ?? firstRundown)?.name} - {playlist.name} + {rundownCount > 1 && {playlist.name}}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 9a118c367d..2b552cbc01 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -50,7 +50,9 @@ function SingleTimer({ timer }: ISingleTimerProps) { return ( Date: Wed, 4 Mar 2026 18:19:34 +0100 Subject: [PATCH 42/79] New top bar UI: unify styles, fix visual issues --- .../ui/RundownView/RundownHeader/Countdown.scss | 15 +++++++-------- .../ui/RundownView/RundownHeader/Countdown.tsx | 2 +- .../RundownHeader/RundownHeader.scss | 17 +++++++++++------ .../RundownView/RundownHeader/RundownHeader.tsx | 5 ++++- .../RundownHeaderTimingDisplay.tsx | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 5d43bc9a5f..dc57ff2f7c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -13,41 +13,40 @@ white-space: nowrap; position: relative; top: -0.6em; /* Visually push the label up to align with cap height */ + margin-left: auto; } &__counter { - margin-left: auto; font-size: 1.3em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; + color: #fff; + line-height: 1; } &__timeofday { - margin-left: auto; font-size: 1.3em; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; font-style: italic; font-weight: 300; + color: #fff; + line-height: 1; } + /* Modifier classes — only used for label font-size overrides */ &--counter { .countdown__label { font-size: 0.65em; } - .countdown__value { - color: #fff; - line-height: 1; - } } &--timeofday { .countdown__label { font-size: 0.7em; } - .countdown__value { + .countdown__timeofday { color: #40b8fa; - font-weight: 300; } } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx index c51a8b1bfd..419f323bb6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -15,7 +15,7 @@ export function Countdown({ label, time, className, children }: IProps): JSX.Ele return ( - {label && {label}} + {label} {time !== undefined ? : children} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 0e13ca789b..e784298b5f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -221,14 +221,14 @@ } &.rundown-header__clocks-diff--under { - .rundown-header__clocks-diff__chip { + .rundown-header__clocks-diff__chip--under { background-color: #ff0; // Should probably be changed to $general-fast-color; color: #000; } } &.rundown-header__clocks-diff--over { - .rundown-header__clocks-diff__chip { + .rundown-header__clocks-diff__chip--over { background-color: $general-late-color; color: #000000; } @@ -355,15 +355,19 @@ display: flex; flex-direction: column; justify-content: flex-start; - gap: 0.15em; + gap: 0.1em; min-width: 7em; } .rundown-header__show-timers { display: flex; - align-items: center; + align-items: flex-start; gap: 1em; - cursor: pointer; + cursor: zoom-out; + + &.rundown-header__show-timers--simplified { + cursor: zoom-in; + } } .rundown-header__show-timers-countdown { @@ -418,7 +422,8 @@ color: #40b8fa; } - .rundown-header__hoverable-label { + .rundown-header__hoverable-label, + .countdown__label { opacity: 1; } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 161adb45d4..a1482b22da 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -101,7 +101,10 @@ export function RundownHeader({
-
setSimplified((s) => !s)}> +
setSimplified((s) => !s)} + > diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx index fceea32777..050c85af88 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -22,7 +22,7 @@ export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDis }`} > {isUnder ? 'Under' : 'Over'} - + {isUnder ? '−' : '+'} {timeStr} From 1a405ac2342faeec4bbf4e6dd4e42c501d1e2267 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 09:16:38 +0100 Subject: [PATCH 43/79] chore: Playlist and Rundown font styling. --- .../RundownHeader/RundownHeader.scss | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index e784298b5f..bc53f5b387 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -114,7 +114,21 @@ } .rundown-header__clocks-playlist-name { - font-size: 0.65em; + font-size: 0.7em; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; display: flex; flex-direction: row; justify-content: center; @@ -134,7 +148,20 @@ min-width: 0; } .playlist-name { - font-weight: 700; + font-variation-settings: + 'wdth' 25, + 'wght' 700, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } } From 033d22b475df18d70c82002cf9760b2c02c757d8 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:47:40 +0100 Subject: [PATCH 44/79] New top bar UI: fix countdown classes --- .../RundownView/RundownHeader/Countdown.scss | 42 ++++++++++++++++--- .../RundownHeader/RundownHeaderTimers.tsx | 2 +- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index dc57ff2f7c..dee8721f31 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -17,21 +17,53 @@ } &__counter { - font-size: 1.3em; font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; color: #fff; line-height: 1; + + margin-left: auto; + font-size: 1.3em; + letter-spacing: 0em; + font-variation-settings: + 'wdth' 60, + 'wght' 550, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 40, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } &__timeofday { - font-size: 1.3em; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; font-style: italic; font-weight: 300; color: #fff; line-height: 1; + + margin-left: auto; + font-size: 1.3em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + font-variation-settings: + 'wdth' 70, + 'wght' 400, + 'slnt' -5, + 'GRAD' 0, + 'opsz' 40, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } /* Modifier classes — only used for label font-size overrides */ diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 2b552cbc01..73b5707d42 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -18,7 +18,7 @@ export const RundownHeaderTimers: React.FC = ({ tTimers }) => { return null } - const activeTimers = tTimers.filter((t) => t.mode) + const activeTimers = tTimers.filter((t) => t.mode).slice(0, 2) return (
From eba0f26cf9dd44cd8e0aed70c30840a6196ab71f Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 10:44:55 +0100 Subject: [PATCH 45/79] chore: Counter and TimeOf Day styling. --- .../client/ui/RundownView/RundownHeader/Countdown.scss | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index dee8721f31..e29e7d4ccb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -17,10 +17,8 @@ } &__counter { - font-variant-numeric: tabular-nums; - color: #fff; + color: #ffffff; line-height: 1; - margin-left: auto; font-size: 1.3em; letter-spacing: 0em; @@ -41,14 +39,10 @@ } &__timeofday { - font-style: italic; - font-weight: 300; color: #fff; line-height: 1; - margin-left: auto; font-size: 1.3em; - font-variant-numeric: tabular-nums; letter-spacing: 0.02em; font-variation-settings: 'wdth' 70, From b6469248c02473294938227c064ee9d62455ebd4 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:21:03 +0100 Subject: [PATCH 46/79] Top bar UI: unify over/under in t-timers --- .../RundownHeader/RundownHeader.scss | 233 +++++++++--------- .../RundownHeader/RundownHeaderTimers.tsx | 40 ++- 2 files changed, 161 insertions(+), 112 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index bc53f5b387..d433762c65 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -116,19 +116,19 @@ .rundown-header__clocks-playlist-name { font-size: 0.7em; font-variation-settings: - 'wdth' 25, - 'wght' 500, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 14, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; display: flex; flex-direction: row; justify-content: center; @@ -149,19 +149,19 @@ } .playlist-name { font-variation-settings: - 'wdth' 25, - 'wght' 700, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 14, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; + 'wdth' 25, + 'wght' 700, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; } } @@ -176,90 +176,90 @@ margin-right: 0.5em; display: flex; align-items: center; + } + } - .rundown-header__clocks-diff { - display: flex; - align-items: center; - gap: 0.4em; - font-variant-numeric: tabular-nums; - white-space: nowrap; + .rundown-header__clocks-diff { + display: flex; + align-items: center; + gap: 0.4em; + font-variant-numeric: tabular-nums; + white-space: nowrap; - .rundown-header__clocks-diff__label { - @extend .rundown-header__hoverable-label; - font-size: 0.7em; - opacity: 0.6; - font-variation-settings: - 'wdth' 25, - 'wght' 500, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 14, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; - } + .rundown-header__clocks-diff__label { + @extend .rundown-header__hoverable-label; + font-size: 0.7em; + opacity: 0.6; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + } - .rundown-header__clocks-diff__chip--under { - font-size: 1.3em; - padding: 0em 0.3em; - line-height: 1em; - border-radius: 999px; - letter-spacing: -0.02em; - font-variation-settings: - 'wdth' 25, - 'wght' 500, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 25, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; - } - .rundown-header__clocks-diff__chip--over { - font-size: 1.3em; - padding: 0em 0.3em; - line-height: 1em; - border-radius: 999px; - letter-spacing: -0.02em; - font-variation-settings: - 'wdth' 25, - 'wght' 700, - 'slnt' 0, - 'GRAD' 0, - 'opsz' 25, - 'XOPQ' 96, - 'XTRA' 468, - 'YOPQ' 79, - 'YTAS' 750, - 'YTFI' 738, - 'YTLC' 548, - 'YTDE' -203, - 'YTUC' 712; - } + .rundown-header__clocks-diff__chip--under { + font-size: 1.3em; + padding: 0em 0.3em; + line-height: 1em; + border-radius: 999px; + letter-spacing: -0.02em; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 25, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + } + .rundown-header__clocks-diff__chip--over { + font-size: 1.3em; + padding: 0em 0.3em; + line-height: 1em; + border-radius: 999px; + letter-spacing: -0.02em; + font-variation-settings: + 'wdth' 25, + 'wght' 700, + 'slnt' 0, + 'GRAD' 0, + 'opsz' 25, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; + } - &.rundown-header__clocks-diff--under { - .rundown-header__clocks-diff__chip--under { - background-color: #ff0; // Should probably be changed to $general-fast-color; - color: #000; - } - } + &.rundown-header__clocks-diff--under { + .rundown-header__clocks-diff__chip--under { + background-color: #ff0; // Should probably be changed to $general-fast-color; + color: #000; + } + } - &.rundown-header__clocks-diff--over { - .rundown-header__clocks-diff__chip--over { - background-color: $general-late-color; - color: #000000; - } - } + &.rundown-header__clocks-diff--over { + .rundown-header__clocks-diff__chip--over { + background-color: $general-late-color; + color: #000000; } } } @@ -315,18 +315,29 @@ } .rundown-header__clocks-timers__timer__over-under { - font-size: 0.75em; - font-weight: 400; - font-variant-numeric: tabular-nums; - margin-left: 0.5em; + display: inline-block; + vertical-align: middle; + font-size: 0.65em; + padding: 0.05em 0.35em; + border-radius: 999px; white-space: nowrap; + letter-spacing: -0.02em; + margin-left: 0.5em; + font-variant-numeric: tabular-nums; + font-variation-settings: + 'wdth' 25, + 'wght' 500, + 'slnt' 0, + 'opsz' 14; &.rundown-header__clocks-timers__timer__over-under--over { - color: $general-late-color; + background-color: $general-late-color; + color: #000; } &.rundown-header__clocks-timers__timer__over-under--under { - color: #0f0; + background-color: #ff0; + color: #000; } } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 73b5707d42..bb2545499f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -14,6 +14,44 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() + tTimers = [ + { + index: 1, + label: 'T-timer mock 1', + mode: { type: 'countdown' }, + state: { + zeroTime: 1772700194670 + 5 * 60 * 1000, + duration: 0, + paused: false, + }, + estimateState: { + zeroTime: 1772700194670 + 7 * 60 * 1000, + duration: 0, + paused: false, + }, + }, + { + index: 2, + label: 'T-timer mock 2', + mode: null, + state: { + zeroTime: 1772700194670 + 45 * 60 * 1000, + duration: 0, + paused: false, + }, + }, + { + index: 3, + label: 'T-timer mock 3', + mode: null, + state: { + zeroTime: 1772700194670 - 15 * 60 * 1000, + duration: 0, + paused: false, + }, + }, + ] as unknown as [RundownTTimer, RundownTTimer, RundownTTimer] + if (!tTimers?.length) { return null } @@ -83,7 +121,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { 'rundown-header__clocks-timers__timer__over-under--under': overUnder < 0, })} > - {overUnder >= 0 ? '+' : '-'} + {overUnder > 0 ? '+' : '−'} {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} )} From c0f38eb95d07a797c077cf53cb4023cad67c69ac Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:08:59 +0100 Subject: [PATCH 47/79] Top bar UI: change layout of t-timers to grid --- .../RundownHeader/RundownHeader.scss | 23 +++++++++++++++---- .../RundownHeader/RundownHeaderTimers.tsx | 6 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index d433762c65..62758901f6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -266,12 +266,19 @@ .rundown-header__clocks-timers { margin-left: auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-end; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + justify-content: end; + column-gap: 0.6em; + row-gap: 0.15em; + + .rundown-header__clocks-timers__row { + display: contents; + } .rundown-header__clocks-timers__timer { + display: contents; white-space: nowrap; line-height: 1.25; @@ -287,10 +294,18 @@ .countdown__label { @extend .rundown-header__hoverable-label; font-size: 0.65em; + margin-left: 0; + top: 0; + text-align: right; + white-space: nowrap; } .countdown__counter { color: #fff; + margin-left: 0; + display: flex; + align-items: baseline; + gap: 0; } .rundown-header__clocks-timers__timer__sign { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index bb2545499f..c08bb67f82 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -33,7 +33,7 @@ export const RundownHeaderTimers: React.FC = ({ tTimers }) => { { index: 2, label: 'T-timer mock 2', - mode: null, + mode: { type: 'freeRun' }, state: { zeroTime: 1772700194670 + 45 * 60 * 1000, duration: 0, @@ -61,7 +61,9 @@ export const RundownHeaderTimers: React.FC = ({ tTimers }) => { return (
{activeTimers.map((timer) => ( - +
+ +
))}
) From c279847fa72bb476b7d98553a9ad75a6b95628d1 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:33:17 +0100 Subject: [PATCH 48/79] Top bar UI: add dimming to inactive timer parts --- .../RundownView/RundownHeader/Countdown.scss | 14 +++++ .../RundownView/RundownHeader/Countdown.tsx | 51 ++++++++++++++++--- .../CurrentPartOrSegmentRemaining.tsx | 1 + .../RundownHeader/RundownHeader.scss | 8 +-- .../RundownHeader/RundownHeaderDurations.tsx | 29 +++++------ .../RundownHeader/RundownHeaderTimers.tsx | 35 ++++++++----- 6 files changed, 100 insertions(+), 38 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index e29e7d4ccb..2b893e99e4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -75,4 +75,18 @@ color: #40b8fa; } } + + &__digit { + &--dimmed { + opacity: 0.4; + } + } + + &__sep { + margin: 0 0.05em; + + &--dimmed { + opacity: 0.4; + } + } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx index 419f323bb6..71218a06de 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -3,22 +3,61 @@ import Moment from 'react-moment' import classNames from 'classnames' import './Countdown.scss' +const THRESHOLDS = [3600000, 60000, 1] // hours, minutes, seconds + interface IProps { label?: string time?: number className?: string children?: React.ReactNode + ms?: number +} + +function DimmedValue({ value, ms }: { readonly value: string; readonly ms: number }): JSX.Element { + const parts = value.split(':') + const absDiff = Math.abs(ms) + + return ( + <> + {parts.map((p, i) => { + const offset = 3 - parts.length + const isDimmed = absDiff < THRESHOLDS[i + offset] + return ( + + {p} + {i < parts.length - 1 && ( + + : + + )} + + ) + })} + + ) +} + +function renderContent(time: number | undefined, ms: number | undefined, children: React.ReactNode): React.ReactNode { + if (time !== undefined) { + return + } + if (ms !== undefined && typeof children === 'string') { + return + } + return children } -export function Countdown({ label, time, className, children }: IProps): JSX.Element { - const valueClassName = time !== undefined ? 'countdown__timeofday' : 'countdown__counter' +export function Countdown({ label, time, className, children, ms }: IProps): JSX.Element { + const valueClassName = time === undefined ? 'countdown__counter' : 'countdown__timeofday' return ( - {label} - - {time !== undefined ? : children} - + {label && {label}} + {renderContent(time, ms, children)} ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx index 1f85a03ec4..585c19441d 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -166,6 +166,7 @@ export const RundownHeaderPartRemaining: React.FC = (props) 0 ? props.heavyClassName : undefined)} + ms={displayTimecode || 0} > {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 62758901f6..d609114922 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -320,13 +320,15 @@ .rundown-header__clocks-timers__timer__part { color: #fff; &.rundown-header__clocks-timers__timer__part--dimmed { - color: #888; - font-weight: 400; + opacity: 0.4; } } .rundown-header__clocks-timers__timer__separator { margin: 0 0.05em; - color: #888; + color: #fff; + &.rundown-header__clocks-timers__timer__separator--dimmed { + opacity: 0.4; + } } .rundown-header__clocks-timers__timer__over-under { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 8c800c5f94..2018192cd9 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -10,15 +10,13 @@ export function RundownHeaderDurations({ playlist, simplified, }: { - playlist: DBRundownPlaylist - simplified?: boolean + readonly playlist: DBRundownPlaylist + readonly simplified?: boolean }): JSX.Element | null { const { t } = useTranslation() const timingDurations = useTiming() const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) - const planned = - expectedDuration != null ? RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true) : null const now = timingDurations.currentTime ?? Date.now() const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId @@ -32,28 +30,25 @@ export function RundownHeaderDurations({ ) if (remaining != null) { const elapsed = - playlist.startedPlayback != null - ? now - playlist.startedPlayback - : (timingDurations.asDisplayedPlaylistDuration ?? 0) + playlist.startedPlayback == null + ? (timingDurations.asDisplayedPlaylistDuration ?? 0) + : now - playlist.startedPlayback estDuration = elapsed + remaining } } - const estimated = - estDuration != null ? RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true) : null - - if (!planned && !estimated) return null + if (expectedDuration == null && estDuration == null) return null return (
- {planned ? ( - - {planned} + {expectedDuration != null ? ( + + {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estimated ? ( - - {estimated} + {!simplified && estDuration != null ? ( + + {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} ) : null}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index c08bb67f82..9d6a0846e7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -104,18 +104,29 @@ function SingleTimer({ timer }: ISingleTimerProps) { })} > {timerSign} - {parts.map((p, i) => ( - - - {p} - - {i < parts.length - 1 && :} - - ))} + {parts.map((p, i) => { + const isDimmed = timer.mode!.type !== 'timeOfDay' && Math.abs(diff) < [3600000, 60000, 1][i] + return ( + + + {p} + + {i < parts.length - 1 && ( + + : + + )} + + ) + })} {!!overUnder && ( Date: Thu, 5 Mar 2026 14:25:09 +0100 Subject: [PATCH 49/79] chore: Tweaks to styling of T-timers. --- .../ui/RundownView/RundownHeader/RundownHeader.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index d609114922..f06d64b5c2 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -314,7 +314,7 @@ text-align: center; font-size: 1.1em; color: #fff; - margin-right: 0.3em; + margin-right: 0.0em; } .rundown-header__clocks-timers__timer__part { @@ -324,7 +324,7 @@ } } .rundown-header__clocks-timers__timer__separator { - margin: 0 0.05em; + margin: 0 0em; color: #fff; &.rundown-header__clocks-timers__timer__separator--dimmed { opacity: 0.4; @@ -418,10 +418,10 @@ display: flex; align-items: flex-start; gap: 1em; - cursor: zoom-out; + cursor: pointer; &.rundown-header__show-timers--simplified { - cursor: zoom-in; + cursor: pointer; } } From 31e0f8f8396e58e58bf7ebafff3d5b6988e9a717 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:37:10 +0100 Subject: [PATCH 50/79] Top bar UI: css tweaks --- .../RundownView/RundownHeader/Countdown.scss | 6 ++- .../RundownView/RundownHeader/Countdown.tsx | 14 +++-- .../RundownHeader/RundownHeader.scss | 25 +++------ .../RundownHeader/RundownHeaderTimers.tsx | 54 ++++++------------- 4 files changed, 36 insertions(+), 63 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 2b893e99e4..ef1311d7ae 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -5,15 +5,17 @@ display: flex; align-items: baseline; justify-content: space-between; - gap: 0.6em; + gap: 0.3em; transition: color 0.2s; &__label { @extend %hoverable-label; white-space: nowrap; position: relative; - top: -0.6em; /* Visually push the label up to align with cap height */ + top: -0.4em; /* Visually push the label up to align with cap height */ margin-left: auto; + text-align: right; + width: 100%; } &__counter { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx index 71218a06de..85994d3f88 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.tsx @@ -11,11 +11,12 @@ interface IProps { className?: string children?: React.ReactNode ms?: number + postfix?: React.ReactNode } -function DimmedValue({ value, ms }: { readonly value: string; readonly ms: number }): JSX.Element { +function DimmedValue({ value, ms }: { readonly value: string; readonly ms?: number }): JSX.Element { const parts = value.split(':') - const absDiff = Math.abs(ms) + const absDiff = ms !== undefined ? Math.abs(ms) : Infinity return ( <> @@ -45,19 +46,22 @@ function renderContent(time: number | undefined, ms: number | undefined, childre if (time !== undefined) { return } - if (ms !== undefined && typeof children === 'string') { + if (typeof children === 'string') { return } return children } -export function Countdown({ label, time, className, children, ms }: IProps): JSX.Element { +export function Countdown({ label, time, className, children, ms, postfix }: IProps): JSX.Element { const valueClassName = time === undefined ? 'countdown__counter' : 'countdown__timeofday' return ( {label && {label}} - {renderContent(time, ms, children)} + + {renderContent(time, ms, children)} + {postfix} + ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index f06d64b5c2..4373efb07b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -271,7 +271,7 @@ align-items: baseline; justify-content: end; column-gap: 0.6em; - row-gap: 0.15em; + row-gap: 0.1em; .rundown-header__clocks-timers__row { display: contents; @@ -283,19 +283,16 @@ line-height: 1.25; &.countdown--timeofday { - .rundown-header__clocks-timers__timer__part, - .rundown-header__clocks-timers__timer__sign, - .rundown-header__clocks-timers__timer__separator { + .countdown__digit, + .countdown__sep { font-style: italic; font-weight: 300; + color: #40b8fa; } } - .countdown__label { @extend .rundown-header__hoverable-label; - font-size: 0.65em; margin-left: 0; - top: 0; text-align: right; white-space: nowrap; } @@ -314,21 +311,15 @@ text-align: center; font-size: 1.1em; color: #fff; - margin-right: 0.0em; + margin-right: 0em; } - .rundown-header__clocks-timers__timer__part { + .countdown__digit { color: #fff; - &.rundown-header__clocks-timers__timer__part--dimmed { - opacity: 0.4; - } } - .rundown-header__clocks-timers__timer__separator { + .countdown__sep { margin: 0 0em; color: #fff; - &.rundown-header__clocks-timers__timer__separator--dimmed { - opacity: 0.4; - } } .rundown-header__clocks-timers__timer__over-under { @@ -392,7 +383,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.8em; + gap: 0.3em; color: rgba(255, 255, 255, 0.6); transition: color 0.2s; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 9d6a0846e7..eeec472363 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -80,10 +80,6 @@ function SingleTimer({ timer }: ISingleTimerProps) { const diff = calculateTTimerDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) - const parts = timeStr.split(':') - - const timerSign = diff >= 0 ? '+' : '-' - const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning const overUnder = calculateTTimerOverUnder(timer, now) @@ -102,42 +98,22 @@ function SingleTimer({ timer }: ISingleTimerProps) { 'rundown-header__clocks-timers__timer__isComplete': timer.mode!.type === 'countdown' && timer.state !== null && diff <= 0, })} + ms={timer.mode!.type !== 'timeOfDay' ? diff : undefined} + postfix={ + overUnder ? ( + 0, + 'rundown-header__clocks-timers__timer__over-under--under': overUnder < 0, + })} + > + {overUnder > 0 ? '+' : '−'} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + + ) : undefined + } > - {timerSign} - {parts.map((p, i) => { - const isDimmed = timer.mode!.type !== 'timeOfDay' && Math.abs(diff) < [3600000, 60000, 1][i] - return ( - - - {p} - - {i < parts.length - 1 && ( - - : - - )} - - ) - })} - {!!overUnder && ( - 0, - 'rundown-header__clocks-timers__timer__over-under--under': overUnder < 0, - })} - > - {overUnder > 0 ? '+' : '−'} - {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} - - )} + {timeStr} ) } From c220ca4be5fc23afc93fbedf84eab8bbd24f3eb8 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:41:53 +0100 Subject: [PATCH 51/79] Top bar UI: css tweaks --- .../client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index eeec472363..01dc9323f9 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -108,7 +108,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { })} > {overUnder > 0 ? '+' : '−'} - {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, false, true, false, true)} ) : undefined } From 4a28bf805c2b672cc24d4e0577d27a8a99b1fa06 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 16:04:24 +0100 Subject: [PATCH 52/79] chore: Tweaked vertical label placement. --- .../src/client/ui/RundownView/RundownHeader/Countdown.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index ef1311d7ae..3a691c3e25 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -12,7 +12,7 @@ @extend %hoverable-label; white-space: nowrap; position: relative; - top: -0.4em; /* Visually push the label up to align with cap height */ + top: -0.55em; /* Visually push the label up to align with cap height */ margin-left: auto; text-align: right; width: 100%; From bdbc0f0fe903745acd9592d4d684d5aab2ea1b64 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:54:21 +0100 Subject: [PATCH 53/79] Top bar UI: css tweaks --- .../client/ui/RundownView/RundownHeader/RundownHeader.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 4373efb07b..d4bd107b27 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -384,11 +384,14 @@ align-items: center; justify-content: space-between; gap: 0.3em; - color: rgba(255, 255, 255, 0.6); + color: #fff; transition: color 0.2s; &__label { @extend .rundown-header__hoverable-label; + opacity: 1; + position: relative; + top: -0.4em; /* Match alignment from Countdown.scss */ } .overtime { @@ -421,7 +424,7 @@ } .rundown-header__timers-onair-remaining__label { - background-color: $general-live-color; + background-color: var(--general-live-color); color: #ffffff; padding: 0.03em 0.45em 0.02em 0.2em; border-radius: 2px 999px 999px 2px; From 5d9b41559eb8867a1284441cedfc46b0b0385f67 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:41:48 +0100 Subject: [PATCH 54/79] New Top Bar UI: align onair styles with timeline, hide segment budget when it's not used --- .../webui/src/client/lib/rundownTiming.ts | 17 +++++++----- .../CurrentPartOrSegmentRemaining.tsx | 26 +++++++++++++++++-- .../RundownHeader/RundownHeader.scss | 9 +++++-- .../RundownHeader/RundownHeader.tsx | 14 ++++------ 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index 77d5716b9b..7d76b7da45 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -166,12 +166,17 @@ export class RundownTimingCalculator { const liveSegment = segmentsMap.get(liveSegmentIds.segmentId) if (liveSegment?.segmentTiming?.countdownType === CountdownType.SEGMENT_BUDGET_DURATION) { - remainingBudgetOnCurrentSegment = - (playlist.segmentsStartedPlayback?.[unprotectString(liveSegmentIds.segmentPlayoutId)] ?? - lastStartedPlayback ?? - now) + - (liveSegment.segmentTiming.budgetDuration ?? 0) - - now + const budgetDuration = liveSegment.segmentTiming.budgetDuration ?? 0 + if (budgetDuration > 0) { + remainingBudgetOnCurrentSegment = + (playlist.segmentsStartedPlayback?.[ + unprotectString(liveSegmentIds.segmentPlayoutId) + ] ?? + lastStartedPlayback ?? + now) + + budgetDuration - + now + } } } segmentDisplayDuration = 0 diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx index 585c19441d..3772d964c1 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -125,7 +125,8 @@ function usePartRemaining(props: IPartRemainingProps) { let displayTimecode = timingDurations.remainingTimeOnCurrentPart if (props.preferSegmentTime) { - displayTimecode = timingDurations.remainingBudgetOnCurrentSegment ?? displayTimecode + if (timingDurations.remainingBudgetOnCurrentSegment === undefined) return null + displayTimecode = timingDurations.remainingBudgetOnCurrentSegment } if (displayTimecode === undefined) return null @@ -166,9 +167,30 @@ export const RundownHeaderPartRemaining: React.FC = (props) 0 ? props.heavyClassName : undefined)} - ms={displayTimecode || 0} > {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} ) } + +/** + * RundownHeader Segment Budget variant — renders inside a wrapper with a label, and handles hiding when value is missing or 0. + */ +export const RundownHeaderSegmentBudget: React.FC<{ + currentPartInstanceId: PartInstanceId | null + label?: string +}> = ({ currentPartInstanceId, label }) => { + const result = usePartRemaining({ currentPartInstanceId, preferSegmentTime: true }) + if (!result) return null + + const { displayTimecode } = result + + return ( + + {label} + 0 ? 'overtime' : undefined)}> + {RundownUtils.formatDiffToTimecode(displayTimecode || 0, true, false, true, false, true, '', false, true)} + + + ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index d4bd107b27..40f423a874 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -391,10 +391,15 @@ @extend .rundown-header__hoverable-label; opacity: 1; position: relative; - top: -0.4em; /* Match alignment from Countdown.scss */ + top: -0.2em; /* Match alignment from Countdown.scss */ } - .overtime { + .countdown__counter { + color: $general-countdown-to-next-color; + } + + .overtime, + .overtime .countdown__counter { color: $general-late-color; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index a1482b22da..195aba4b41 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -13,7 +13,7 @@ import Navbar from 'react-bootstrap/Navbar' import { RundownContextMenu, RundownHeaderContextMenuTrigger, RundownHamburgerButton } from './RundownContextMenu' import { useTranslation } from 'react-i18next' import { TimeOfDay } from '../RundownTiming/TimeOfDay' -import { RundownHeaderPartRemaining } from '../RundownHeader/CurrentPartOrSegmentRemaining' +import { RundownHeaderPartRemaining, RundownHeaderSegmentBudget } from '../RundownHeader/CurrentPartOrSegmentRemaining' import { RundownHeaderTimers } from './RundownHeaderTimers' import { RundownHeaderTimingDisplay } from './RundownHeaderTimingDisplay' @@ -66,14 +66,10 @@ export function RundownHeader({ {playlist.currentPartInfo && (
- - {t('Seg. Budg.')} - - + {t('On Air')} Date: Fri, 6 Mar 2026 15:54:36 +0100 Subject: [PATCH 55/79] chore: Corrected the vertical alignment of the ON AIR label. --- .../src/client/ui/RundownView/RundownHeader/RundownHeader.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 40f423a874..72c362b89e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -432,6 +432,7 @@ background-color: var(--general-live-color); color: #ffffff; padding: 0.03em 0.45em 0.02em 0.2em; + top: 0em; border-radius: 2px 999px 999px 2px; // Label font styling override meant to match the ON AIR label on the On Air line font-size: 0.8em; From 13dd50a96c250f14b9adbd3f831adbdedd4438ac Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:09:43 +0100 Subject: [PATCH 56/79] chore: Tweaked the T-timer Over/Under pill and narrowed the gap between counters and labels. --- .../RundownHeader/RundownHeader.scss | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 72c362b89e..23f85995be 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -270,7 +270,7 @@ grid-template-columns: auto auto; align-items: baseline; justify-content: end; - column-gap: 0.6em; + column-gap: 0.3em; row-gap: 0.1em; .rundown-header__clocks-timers__row { @@ -301,7 +301,7 @@ color: #fff; margin-left: 0; display: flex; - align-items: baseline; + align-items: center; gap: 0; } @@ -324,19 +324,28 @@ .rundown-header__clocks-timers__timer__over-under { display: inline-block; - vertical-align: middle; - font-size: 0.65em; - padding: 0.05em 0.35em; + line-height: -1em; + font-size: 0.75em; + padding: 0.05em 0.25em; border-radius: 999px; white-space: nowrap; letter-spacing: -0.02em; - margin-left: 0.5em; + margin-left: 0.25em; + margin-top: 0em; font-variant-numeric: tabular-nums; font-variation-settings: 'wdth' 25, - 'wght' 500, + 'wght' 600, 'slnt' 0, - 'opsz' 14; + 'opsz' 14, + 'XOPQ' 96, + 'XTRA' 468, + 'YOPQ' 79, + 'YTAS' 750, + 'YTFI' 738, + 'YTLC' 548, + 'YTDE' -203, + 'YTUC' 712; &.rundown-header__clocks-timers__timer__over-under--over { background-color: $general-late-color; From d8521d13785048a909ee00595b19452c9a066e13 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:28:10 +0100 Subject: [PATCH 57/79] chore: Made the Show Timers group glow when the user hovers over the group, to better indicate that it is clickable. --- .../RundownView/RundownHeader/RundownHeader.scss | 16 ++++++++++++++++ .../RundownView/RundownHeader/RundownHeader.tsx | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 23f85995be..37f2a927fa 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -427,6 +427,21 @@ align-items: flex-start; gap: 1em; cursor: pointer; + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + + &:hover { + text-shadow: 0 0 12px rgba(255, 255, 255, 1); + } + + &:focus-visible { + text-shadow: 0 0 12px rgba(255, 255, 255, 1); + } &.rundown-header__show-timers--simplified { cursor: pointer; @@ -504,5 +519,6 @@ max-height: 1.5em; } } + } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 195aba4b41..165402e0e8 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -97,14 +97,15 @@ export function RundownHeader({
-
setSimplified((s) => !s)} > -
+ From 8ce74806e8d9744822d3d88f29796c87a22e0028 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:36:21 +0100 Subject: [PATCH 58/79] chore: Tweak to vertical counter label alignment. --- .../src/client/ui/RundownView/RundownHeader/Countdown.scss | 2 +- .../src/client/ui/RundownView/RundownHeader/RundownHeader.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 3a691c3e25..c4d4ffcd26 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -12,7 +12,7 @@ @extend %hoverable-label; white-space: nowrap; position: relative; - top: -0.55em; /* Visually push the label up to align with cap height */ + top: -0.51em; /* Visually push the label up to align with cap height */ margin-left: auto; text-align: right; width: 100%; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 37f2a927fa..5ce55e63ef 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -400,7 +400,7 @@ @extend .rundown-header__hoverable-label; opacity: 1; position: relative; - top: -0.2em; /* Match alignment from Countdown.scss */ + top: -0.16em; /* Match alignment from Countdown.scss */ } .countdown__counter { From f6760c57e1974af07fd0c0533303b16f6ec38a93 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 15:26:10 +0000 Subject: [PATCH 59/79] feat: Add optional estimateState to T-Timer data type So we can measure if we are over or under time --- packages/corelib/src/dataModel/RundownPlaylist.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 2629f9a0b2..e426fb3f8b 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -168,15 +168,18 @@ export interface RundownTTimer { /** The estimated time when we expect to reach the anchor part, for calculating over/under diff. * * Based on scheduled durations of remaining parts and segments up to the anchor. - * Running means we are progressing towards the anchor (estimate moves with real time). - * Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed). + * The over/under diff is calculated as the difference between this estimate and the timer's target (state.zeroTime). * - * Calculated automatically when anchorPartId is set, or can be set manually by a blueprint. + * Running means we are progressing towards the anchor (estimate moves with real time) + * Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed) + * + * Calculated automatically when anchorPartId is set, or can be set manually by a blueprint if custom logic is needed. */ estimateState?: TimerState - /** The target Part that this timer is counting towards (the "timing anchor"). + /** The target Part that this timer is counting towards (the "timing anchor") * + * This is typically a "break" part or other milestone in the rundown. * When set, the server calculates estimateState based on when we expect to reach this part. * If not set, estimateState is not calculated automatically but can still be set manually by a blueprint. */ From af76300c76d8b2ff733a6c078377a3c6e48e5045 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 13:19:36 +0000 Subject: [PATCH 60/79] feat: Add function to Caclulate estimates for anchored T-Timers --- packages/job-worker/src/playout/tTimers.ts | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index af86616f82..2f327550f1 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -4,9 +4,14 @@ import type { RundownTTimer, TimerState, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' +import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { JobContext } from '../jobs/index.js' +import { PlayoutModel } from './model/PlayoutModel.js' export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) @@ -167,3 +172,142 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe }) return parsed ? parsed.getTime() : null } + +/** + * Recalculate T-Timer estimates based on timing anchors + * + * For each T-Timer that has an anchorPartId set, this function: + * 1. Iterates through ordered parts from current/next onwards + * 2. Accumulates expected durations until the anchor part is reached + * 3. Updates estimateState with the calculated duration + * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * + * @param context Job context + * @param playoutModel The playout model containing the playlist and parts + */ +export function recalculateTTimerEstimates(context: JobContext, playoutModel: PlayoutModel): void { + const span = context.startSpan('recalculateTTimerEstimates') + + const playlist = playoutModel.playlist + const tTimers = playlist.tTimers + + // Find which timers have anchors that need calculation + const timerAnchors = new Map() + for (const timer of tTimers) { + if (timer.anchorPartId) { + const existingTimers = timerAnchors.get(timer.anchorPartId) ?? [] + existingTimers.push(timer.index) + timerAnchors.set(timer.anchorPartId, existingTimers) + } + } + + // If no timers have anchors, nothing to do + if (timerAnchors.size === 0) { + if (span) span.end() + return + } + + const currentPartInstance = playoutModel.currentPartInstance?.partInstance + const nextPartInstance = playoutModel.nextPartInstance?.partInstance + + // Get ordered parts to iterate through + const orderedParts = playoutModel.getAllOrderedParts() + + // Start from next part if available, otherwise current, otherwise first playable part + let startPartIndex: number | undefined + if (nextPartInstance) { + // We have a next part selected, start from there + startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) + } else if (currentPartInstance) { + // No next, but we have current - start from the part after current + const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) + if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { + startPartIndex = currentIndex + 1 + } + } + + // If we couldn't find a starting point, start from the first playable part + startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) + + if (startPartIndex === undefined || startPartIndex < 0) { + // No parts to iterate through, clear estimates + for (const timer of tTimers) { + if (timer.anchorPartId) { + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + if (span) span.end() + return + } + + // Iterate through parts and accumulate durations + const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) + + const now = getCurrentTime() + let accumulatedDuration = 0 + + // Calculate remaining time for current part + // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) + // Account for playOffset (e.g., from play-from-anywhere feature) + let isPushing = false + if (currentPartInstance) { + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + accumulatedDuration = Math.max(0, remaining) + } + } + + for (const part of playablePartsSlice) { + // Add this part's expected duration to the accumulator + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + accumulatedDuration += partDuration + + // Check if this part is an anchor for any timer + const timersForThisPart = timerAnchors.get(part._id) + if (timersForThisPart) { + for (const timerIndex of timersForThisPart) { + const timer = tTimers[timerIndex - 1] + + // Update the timer's estimate + const estimateState: TimerState = isPushing + ? literal({ + paused: true, + duration: accumulatedDuration, + }) + : literal({ + paused: false, + zeroTime: now + accumulatedDuration, + }) + + playoutModel.updateTTimer({ ...timer, estimateState }) + } + // Remove this anchor since we've processed it + timerAnchors.delete(part._id) + } + + // Early exit if we've resolved all timers + if (timerAnchors.size === 0) { + break + } + } + + // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) + // Any remaining entries in timerAnchors are anchors that weren't reached + for (const timerIndices of timerAnchors.values()) { + for (const timerIndex of timerIndices) { + const timer = tTimers[timerIndex - 1] + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + + if (span) span.end() +} From a156181ff9139288b04d01113f58f62246c09981 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:45:27 +0000 Subject: [PATCH 61/79] feat: Add RecalculateTTimerEstimates job and integrate into playout workflow --- packages/corelib/src/worker/studio.ts | 8 ++++ packages/job-worker/src/ingest/commit.ts | 21 ++++++--- packages/job-worker/src/playout/setNext.ts | 4 ++ .../job-worker/src/playout/tTimersJobs.ts | 44 +++++++++++++++++++ .../job-worker/src/workers/studio/jobs.ts | 3 ++ 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/job-worker/src/playout/tTimersJobs.ts diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 6eb045fc5e..18516b1d66 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -126,6 +126,12 @@ export enum StudioJobs { */ OnTimelineTriggerTime = 'onTimelineTriggerTime', + /** + * Recalculate T-Timer estimates based on current playlist state + * Called after setNext, takes, and ingest changes to update timing anchor estimates + */ + RecalculateTTimerEstimates = 'recalculateTTimerEstimates', + /** * Update the timeline with a regenerated Studio Baseline * Has no effect if a Playlist is active @@ -412,6 +418,8 @@ export type StudioJobFunc = { [StudioJobs.OnPlayoutPlaybackChanged]: (data: OnPlayoutPlaybackChangedProps) => void [StudioJobs.OnTimelineTriggerTime]: (data: OnTimelineTriggerTimeProps) => void + [StudioJobs.RecalculateTTimerEstimates]: () => void + [StudioJobs.UpdateStudioBaseline]: () => string | false [StudioJobs.CleanupEmptyPlaylists]: () => void diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 47e26f850c..31f6ce0313 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -29,6 +29,7 @@ import { clone, groupByToMapFunc } from '@sofie-automation/corelib/dist/lib' import { PlaylistLock } from '../jobs/lock.js' import { syncChangesToPartInstances } from './syncChangesToPartInstance.js' import { ensureNextPartIsValid } from './updateNext.js' +import { recalculateTTimerEstimates } from '../playout/tTimers.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { getTranslatedMessage, ServerTranslatedMesssages } from '../notes.js' import _ from 'underscore' @@ -234,6 +235,16 @@ export async function CommitIngestOperation( // update the quickloop in case we did any changes to things involving marker playoutModel.updateQuickLoopState() + // wait for the ingest changes to save + await pSaveIngest + + // do some final playout checks, which may load back some Parts data + // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above + await ensureNextPartIsValid(context, playoutModel) + + // Recalculate T-Timer estimates after ingest changes + recalculateTTimerEstimates(context, playoutModel) + playoutModel.deferAfterSave(() => { // Run in the background, we don't want to hold onto the lock to do this context @@ -248,13 +259,6 @@ export async function CommitIngestOperation( triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) }) - // wait for the ingest changes to save - await pSaveIngest - - // do some final playout checks, which may load back some Parts data - // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above - await ensureNextPartIsValid(context, playoutModel) - // save the final playout changes await playoutModel.saveAllToDatabase() } finally { @@ -613,6 +617,9 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( const shouldUpdateTimeline = await ensureNextPartIsValid(context, playoutModel) + // Recalculate T-Timer estimates after playlist changes + recalculateTTimerEstimates(context, playoutModel) + if (playoutModel.playlist.activationId || shouldUpdateTimeline) { triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 8739e289a3..45209a6494 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -33,6 +33,7 @@ import { import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { convertNoteToNotification } from '../notifications/util.js' import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore.js' +import { recalculateTTimerEstimates } from './tTimers.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -96,6 +97,9 @@ export async function setNextPart( await cleanupOrphanedItems(context, playoutModel) + // Recalculate T-Timer estimates based on the new next part + recalculateTTimerEstimates(context, playoutModel) + if (span) span.end() } diff --git a/packages/job-worker/src/playout/tTimersJobs.ts b/packages/job-worker/src/playout/tTimersJobs.ts new file mode 100644 index 0000000000..b1fede7642 --- /dev/null +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -0,0 +1,44 @@ +import { JobContext } from '../jobs/index.js' +import { recalculateTTimerEstimates } from './tTimers.js' +import { runWithPlayoutModel, runWithPlaylistLock } from './lock.js' + +/** + * Handle RecalculateTTimerEstimates job + * This is called after setNext, takes, and ingest changes to update T-Timer estimates + * Since this job doesn't take a playlistId parameter, it finds the active playlist in the studio + */ +export async function handleRecalculateTTimerEstimates(context: JobContext): Promise { + // Find active playlists in this studio (projection to just get IDs) + const activePlaylistIds = await context.directCollections.RundownPlaylists.findFetch( + { + studioId: context.studioId, + activationId: { $exists: true }, + }, + { + projection: { + _id: 1, + }, + } + ) + + if (activePlaylistIds.length === 0) { + // No active playlist, nothing to do + return + } + + // Process each active playlist (typically there's only one) + for (const playlistRef of activePlaylistIds) { + await runWithPlaylistLock(context, playlistRef._id, async (lock) => { + // Fetch the full playlist object + const playlist = await context.directCollections.RundownPlaylists.findOne(playlistRef._id) + if (!playlist) { + // Playlist was removed between query and lock + return + } + + await runWithPlayoutModel(context, playlist, lock, null, async (playoutModel) => { + recalculateTTimerEstimates(context, playoutModel) + }) + }) + } +} diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index be5d81787d..7b66526a4d 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -49,6 +49,7 @@ import { handleActivateAdlibTesting } from '../../playout/adlibTesting.js' import { handleExecuteBucketAdLibOrAction } from '../../playout/bucketAdlibJobs.js' import { handleSwitchRouteSet } from '../../studio/routeSet.js' import { handleCleanupOrphanedExpectedPackageReferences } from '../../playout/expectedPackages.js' +import { handleRecalculateTTimerEstimates } from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -87,6 +88,8 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.OnPlayoutPlaybackChanged]: handleOnPlayoutPlaybackChanged, [StudioJobs.OnTimelineTriggerTime]: handleTimelineTriggerTime, + [StudioJobs.RecalculateTTimerEstimates]: handleRecalculateTTimerEstimates, + [StudioJobs.UpdateStudioBaseline]: handleUpdateStudioBaseline, [StudioJobs.CleanupEmptyPlaylists]: handleRemoveEmptyPlaylists, From 640f477de757990953a7164816f7615aed16bf56 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:46:30 +0000 Subject: [PATCH 62/79] feat: add timeout for T-Timer recalculations when pushing expected to start --- packages/job-worker/src/playout/tTimers.ts | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 2f327550f1..15f2e27a37 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -9,9 +9,18 @@ import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' +import { logger } from '../logging.js' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' + +/** + * Map of active setTimeout timeouts by studioId + * Used to clear previous timeout when recalculation is triggered before the timeout fires + */ +const activeTimeouts = new Map() export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) @@ -189,6 +198,14 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const span = context.startSpan('recalculateTTimerEstimates') const playlist = playoutModel.playlist + + // Clear any existing timeout for this studio + const existingTimeout = activeTimeouts.get(playlist.studioId) + if (existingTimeout) { + clearTimeout(existingTimeout) + activeTimeouts.delete(playlist.studioId) + } + const tTimers = playlist.tTimers // Find which timers have anchors that need calculation @@ -204,7 +221,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // If no timers have anchors, nothing to do if (timerAnchors.size === 0) { if (span) span.end() - return + return undefined } const currentPartInstance = playoutModel.currentPartInstance?.partInstance @@ -263,6 +280,17 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl isPushing = remaining < 0 accumulatedDuration = Math.max(0, remaining) + + // Schedule next recalculation for when current part ends (if not pushing and no autoNext) + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = remaining + 5 // 5ms buffer + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } } From 18c85a48bc62cf1f43bfff7f429ba73e883c70bb Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:47:51 +0000 Subject: [PATCH 63/79] feat: queue initial T-Timer recalculation when job-worker restarts This will ensure a timeout is set for the next expected push start time. --- packages/job-worker/src/workers/studio/child.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index 57974fbb73..138bfd10d0 100644 --- a/packages/job-worker/src/workers/studio/child.ts +++ b/packages/job-worker/src/workers/studio/child.ts @@ -1,5 +1,6 @@ import { studioJobHandlers } from './jobs.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { MongoClient } from 'mongodb' import { createMongoConnection, getMongoCollections, IDirectCollections } from '../../db/index.js' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -75,6 +76,16 @@ export class StudioWorkerChild { } logger.info(`Studio thread for ${this.#studioId} initialised`) + + // Queue initial T-Timer recalculation to set up timers after startup + this.#queueJob( + getStudioQueueName(this.#studioId), + StudioJobs.RecalculateTTimerEstimates, + undefined, + undefined + ).catch((err) => { + logger.error(`Failed to queue initial T-Timer recalculation: ${err}`) + }) } async lockChange(lockId: string, locked: boolean): Promise { if (!this.#staticData) throw new Error('Worker not initialised') From 6d86a9a951cb2ece6e64f1724f04c019f39da3b2 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 16:10:48 +0000 Subject: [PATCH 64/79] feat(blueprints): Add blueprint interface methods for T-Timer estimate management Add three new methods to IPlaylistTTimer interface: - clearEstimate() - Clear both manual estimates and anchor parts - setEstimateAnchorPart(partId) - Set anchor part for automatic calculation - setEstimateTime(time, paused?) - Manually set estimate as timestamp - setEstimateDuration(duration, paused?) - Manually set estimate as duration When anchor part is set, automatically queues RecalculateTTimerEstimates job. Manual estimates clear anchor parts and vice versa. Updated TTimersService to accept JobContext for job queueing capability. Updated all blueprint context instantiations and tests. --- .../src/context/tTimersContext.ts | 36 ++++ .../blueprints/context/OnSetAsNextContext.ts | 2 +- .../src/blueprints/context/OnTakeContext.ts | 2 +- .../context/RundownActivationContext.ts | 2 +- .../SyncIngestUpdateToPartInstanceContext.ts | 15 +- .../src/blueprints/context/adlibActions.ts | 2 +- .../context/services/TTimersService.ts | 90 ++++++++- .../services/__tests__/TTimersService.test.ts | 188 ++++++++++++------ .../src/ingest/syncChangesToPartInstance.ts | 1 + 9 files changed, 263 insertions(+), 75 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 8747f450a2..cce8ca198d 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -71,6 +71,42 @@ export interface IPlaylistTTimer { * @returns True if the timer was restarted, false if it could not be restarted */ restart(): boolean + + /** + * Clear any estimate (manual or anchor-based) for this timer + * This removes both manual estimates set via setEstimateTime/setEstimateDuration + * and automatic estimates based on anchor parts set via setEstimateAnchorPart. + */ + clearEstimate(): void + + /** + * Set the anchor part for automatic estimate calculation + * When set, the server automatically calculates when we expect to reach this part + * based on remaining part durations, and updates the estimate accordingly. + * Clears any manual estimate set via setEstimateTime/setEstimateDuration. + * @param partId The ID of the part to use as timing anchor + */ + setEstimateAnchorPart(partId: string): void + + /** + * Manually set the estimate as an absolute timestamp + * Use this when you have custom logic for calculating when you expect to reach a timing point. + * Clears any anchor part set via setAnchorPart. + * @param time Unix timestamp (milliseconds) when we expect to reach the timing point + * @param paused If true, we're currently delayed/pushing (estimate won't update with time passing). + * If false (default), we're progressing normally (estimate counts down in real-time). + */ + setEstimateTime(time: number, paused?: boolean): void + + /** + * Manually set the estimate as a relative duration from now + * Use this when you want to express the estimate as "X milliseconds from now". + * Clears any anchor part set via setAnchorPart. + * @param duration Milliseconds until we expect to reach the timing point + * @param paused If true, we're currently delayed/pushing (estimate won't update with time passing). + * If false (default), we're progressing normally (estimate counts down in real-time). + */ + setEstimateDuration(duration: number, paused?: boolean): void } export type IPlaylistTTimerState = diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 0e2f530946..2a9ff33ad9 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -50,7 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) - this.#tTimersService = TTimersService.withPlayoutModel(playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(playoutModel, context) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index f403d33723..dbf70196b5 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -66,7 +66,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex ) { super(contextInfo, _context, showStyle, watchedPackages) this.isTakeAborted = false - this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index 3f0b47cc1d..0e631d8833 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -48,7 +48,7 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._previousState = options.previousState this._currentState = options.currentState - this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel, this._context) } get previousState(): IRundownActivationContextState { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 3bbec8cdaa..61e2dcb486 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -3,6 +3,7 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns import { normalizeArrayToMap, omit } from '@sofie-automation/corelib/dist/lib' import { protectString, protectStringArray, unprotectStringArray } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel.js' +import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import _ from 'underscore' import { ContextInfo } from './CommonContext.js' @@ -45,6 +46,7 @@ export class SyncIngestUpdateToPartInstanceContext implements ISyncIngestUpdateToPartInstanceContext { readonly #context: JobContext + readonly #playoutModel: PlayoutModel readonly #proposedPieceInstances: Map> readonly #tTimersService: TTimersService readonly #changedTTimers = new Map() @@ -61,6 +63,7 @@ export class SyncIngestUpdateToPartInstanceContext constructor( context: JobContext, + playoutModel: PlayoutModel, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, @@ -80,12 +83,18 @@ export class SyncIngestUpdateToPartInstanceContext ) this.#context = context + this.#playoutModel = playoutModel this.#partInstance = partInstance this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') - this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => { - this.#changedTTimers.set(updatedTimer.index, updatedTimer) - }) + this.#tTimersService = new TTimersService( + playlist.tTimers, + (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }, + this.#playoutModel, + this.#context + ) } getTimer(index: RundownTTimerIndex): IPlaylistTTimer { diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 8c41cc7d7d..0544c90ecd 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -117,7 +117,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService ) { super(contextInfo, _context, showStyle, watchedPackages) - this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b1eeafd49c..aee1064e57 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -3,7 +3,10 @@ import type { IPlaylistTTimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { assertNever } from '@sofie-automation/corelib/dist/lib' +import type { TimerState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { assertNever, literal } from '@sofie-automation/corelib/dist/lib' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { @@ -14,27 +17,36 @@ import { restartTTimer, resumeTTimer, validateTTimerIndex, + recalculateTTimerEstimates, } from '../../../playout/tTimers.js' import { getCurrentTime } from '../../../lib/time.js' +import type { JobContext } from '../../../jobs/index.js' export class TTimersService { readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] constructor( timers: ReadonlyDeep, - emitChange: (updatedTimer: ReadonlyDeep) => void + emitChange: (updatedTimer: ReadonlyDeep) => void, + playoutModel: PlayoutModel, + jobContext: JobContext ) { this.timers = [ - new PlaylistTTimerImpl(timers[0], emitChange), - new PlaylistTTimerImpl(timers[1], emitChange), - new PlaylistTTimerImpl(timers[2], emitChange), + new PlaylistTTimerImpl(timers[0], emitChange, playoutModel, jobContext), + new PlaylistTTimerImpl(timers[1], emitChange, playoutModel, jobContext), + new PlaylistTTimerImpl(timers[2], emitChange, playoutModel, jobContext), ] } - static withPlayoutModel(playoutModel: PlayoutModel): TTimersService { - return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => { - playoutModel.updateTTimer(updatedTimer) - }) + static withPlayoutModel(playoutModel: PlayoutModel, jobContext: JobContext): TTimersService { + return new TTimersService( + playoutModel.playlist.tTimers, + (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }, + playoutModel, + jobContext + ) } getTimer(index: RundownTTimerIndex): IPlaylistTTimer { @@ -50,6 +62,8 @@ export class TTimersService { export class PlaylistTTimerImpl implements IPlaylistTTimer { readonly #emitChange: (updatedTimer: ReadonlyDeep) => void + readonly #playoutModel: PlayoutModel + readonly #jobContext: JobContext #timer: ReadonlyDeep @@ -96,9 +110,18 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } } - constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + constructor( + timer: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void, + playoutModel: PlayoutModel, + jobContext: JobContext + ) { this.#timer = timer this.#emitChange = emitChange + this.#playoutModel = playoutModel + this.#jobContext = jobContext + + validateTTimerIndex(timer.index) } setLabel(label: string): void { @@ -168,4 +191,51 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#emitChange(newTimer) return true } + + clearEstimate(): void { + this.#timer = { + ...this.#timer, + anchorPartId: undefined, + estimateState: undefined, + } + this.#emitChange(this.#timer) + } + + setEstimateAnchorPart(partId: string): void { + this.#timer = { + ...this.#timer, + anchorPartId: protectString(partId), + estimateState: undefined, // Clear manual estimate + } + this.#emitChange(this.#timer) + + // Recalculate estimates immediately since we already have the playout model + recalculateTTimerEstimates(this.#jobContext, this.#playoutModel) + } + + setEstimateTime(time: number, paused: boolean = false): void { + const estimateState: TimerState = paused + ? literal({ paused: true, duration: time - getCurrentTime() }) + : literal({ paused: false, zeroTime: time }) + + this.#timer = { + ...this.#timer, + anchorPartId: undefined, // Clear automatic anchor + estimateState, + } + this.#emitChange(this.#timer) + } + + setEstimateDuration(duration: number, paused: boolean = false): void { + const estimateState: TimerState = paused + ? literal({ paused: true, duration }) + : literal({ paused: false, zeroTime: getCurrentTime() + duration }) + + this.#timer = { + ...this.#timer, + anchorPartId: undefined, // Clear automatic anchor + estimateState, + } + this.#emitChange(this.#timer) + } } diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 2fe7a21b29..9f8355cac6 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -6,6 +6,11 @@ import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/coreli import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { mock, MockProxy } from 'jest-mock-extended' import type { ReadonlyDeep } from 'type-fest' +import type { JobContext } from '../../../../jobs/index.js' + +function createMockJobContext(): MockProxy { + return mock() +} function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { const mockPlayoutModel = mock() @@ -42,8 +47,10 @@ describe('TTimersService', () => { it('should create three timer instances', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) @@ -54,8 +61,9 @@ describe('TTimersService', () => { it('from playout model', () => { const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const mockJobContext = createMockJobContext() - const service = TTimersService.withPlayoutModel(mockPlayoutModel) + const service = TTimersService.withPlayoutModel(mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) const timer = service.getTimer(1) @@ -71,8 +79,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 1', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(1) @@ -82,8 +92,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 2', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(2) @@ -93,8 +105,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 3', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(3) @@ -104,8 +118,10 @@ describe('TTimersService', () => { it('should throw for invalid index', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') @@ -120,10 +136,11 @@ describe('TTimersService', () => { tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[1].state = { paused: false, zeroTime: 65000 } - const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(tTimers, updateFn, mockPlayoutModel, mockJobContext) service.clearAllTimers() @@ -149,7 +166,9 @@ describe('PlaylistTTimerImpl', () => { it('should return the correct index', () => { const tTimers = createEmptyTTimers() const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.index).toBe(2) }) @@ -158,16 +177,19 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[1].label = 'Custom Label' const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.label).toBe('Custom Label') }) it('should return null state when no mode is set', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toBeNull() }) @@ -177,7 +199,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -191,7 +215,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: 3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -209,7 +235,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -229,7 +257,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 2000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -249,7 +279,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -270,7 +302,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -285,9 +319,10 @@ describe('PlaylistTTimerImpl', () => { describe('setLabel', () => { it('should update the label', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.setLabel('New Label') @@ -306,7 +341,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.clearTimer() @@ -322,9 +359,10 @@ describe('PlaylistTTimerImpl', () => { describe('startCountdown', () => { it('should start a running countdown with default options', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(60000) @@ -342,9 +380,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused countdown', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) @@ -364,9 +403,10 @@ describe('PlaylistTTimerImpl', () => { describe('startFreeRun', () => { it('should start a running free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun() @@ -382,9 +422,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun({ startPaused: true }) @@ -402,9 +443,10 @@ describe('PlaylistTTimerImpl', () => { describe('startTimeOfDay', () => { it('should start a timeOfDay timer with time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('15:30') @@ -425,9 +467,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with numeric timestamp', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const targetTimestamp = 1737331200000 timer.startTimeOfDay(targetTimestamp) @@ -449,9 +492,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with stopAtZero false', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('18:00', { stopAtZero: false }) @@ -472,9 +516,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with 12-hour format', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('5:30pm') @@ -495,18 +540,20 @@ describe('PlaylistTTimerImpl', () => { it('should throw for invalid time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') }) it('should throw for empty time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') }) @@ -518,7 +565,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -538,7 +587,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 70000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -557,9 +608,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -576,7 +628,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -591,7 +645,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: -3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -611,7 +667,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -622,9 +680,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -641,7 +700,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -656,7 +717,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 40000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -682,7 +745,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -704,7 +769,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -721,7 +788,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -750,7 +819,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -760,9 +831,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index afee746ca2..41de01b1bf 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -130,6 +130,7 @@ export class SyncChangesToPartInstancesWorker { const syncContext = new SyncIngestUpdateToPartInstanceContext( this.#context, + this.#playoutModel, { name: `Update to ${existingPartInstance.partInstance.part.externalId}`, identifier: `rundownId=${existingPartInstance.partInstance.part.rundownId},segmentId=${existingPartInstance.partInstance.part.segmentId}`, From ae88fa756ca1e8e29a440452cf4167c988ef70f4 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:02 +0000 Subject: [PATCH 65/79] feat: Add ignoreQuickLoop parameter to getOrderedPartsAfterPlayhead function --- packages/job-worker/src/playout/lookahead/util.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 72bb201dc6..99d692d259 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -34,11 +34,16 @@ export function isPieceInstance(piece: Piece | PieceInstance | PieceInstancePiec /** * Excludes the previous, current and next part + * @param context Job context + * @param playoutModel The playout model + * @param partCount Maximum number of parts to return + * @param ignoreQuickLoop If true, ignores quickLoop markers and returns parts in linear order. Defaults to false for backwards compatibility. */ export function getOrderedPartsAfterPlayhead( context: JobContext, playoutModel: PlayoutModel, - partCount: number + partCount: number, + ignoreQuickLoop: boolean = false ): ReadonlyDeep[] { if (partCount <= 0) { return [] @@ -66,7 +71,7 @@ export function getOrderedPartsAfterPlayhead( null, orderedSegments, orderedParts, - { ignoreUnplayable: true, ignoreQuickLoop: false } + { ignoreUnplayable: true, ignoreQuickLoop } ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything From e1b26f4b9befe9fbcf0d14be9dad006900869245 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:19 +0000 Subject: [PATCH 66/79] feat: Refactor recalculateTTimerEstimates to use getOrderedPartsAfterPlayhead for improved part iteration --- packages/job-worker/src/playout/tTimers.ts | 28 ++++------------------ 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 15f2e27a37..0615294d71 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,13 +8,13 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { logger } from '../logging.js' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' /** * Map of active setTimeout timeouts by studioId @@ -225,28 +225,13 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const currentPartInstance = playoutModel.currentPartInstance?.partInstance - const nextPartInstance = playoutModel.nextPartInstance?.partInstance - // Get ordered parts to iterate through + // Get ordered parts after playhead (excludes previous, current, and next) + // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior const orderedParts = playoutModel.getAllOrderedParts() + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, orderedParts.length, true) - // Start from next part if available, otherwise current, otherwise first playable part - let startPartIndex: number | undefined - if (nextPartInstance) { - // We have a next part selected, start from there - startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) - } else if (currentPartInstance) { - // No next, but we have current - start from the part after current - const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) - if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { - startPartIndex = currentIndex + 1 - } - } - - // If we couldn't find a starting point, start from the first playable part - startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) - - if (startPartIndex === undefined || startPartIndex < 0) { + if (playablePartsSlice.length === 0 && !currentPartInstance) { // No parts to iterate through, clear estimates for (const timer of tTimers) { if (timer.anchorPartId) { @@ -257,9 +242,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl return } - // Iterate through parts and accumulate durations - const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) - const now = getCurrentTime() let accumulatedDuration = 0 From 7d3258a217887a82845d340aa78e33d6c92dacb2 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 22:49:19 +0000 Subject: [PATCH 67/79] test: Add tests for new T-Timers functions --- .../services/__tests__/TTimersService.test.ts | 224 ++++++++++++++++++ .../src/playout/__tests__/tTimersJobs.test.ts | 211 +++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 9f8355cac6..8922d386cc 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -842,4 +842,228 @@ describe('PlaylistTTimerImpl', () => { expect(updateFn).not.toHaveBeenCalled() }) }) + + describe('clearEstimate', () => { + it('should clear both anchorPartId and estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + + it('should work when estimates are already cleared', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + }) + + describe('setEstimateAnchorPart', () => { + it('should set anchorPartId and clear estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateAnchorPart('part123') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: 'part123', + estimateState: undefined, + }) + }) + + it('should not queue job or throw error', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + // Should not throw + expect(() => timer.setEstimateAnchorPart('part456')).not.toThrow() + + // Job queue should not be called (recalculate is called directly) + expect(mockJobContext.queueStudioJob).not.toHaveBeenCalled() + }) + }) + + describe('setEstimateTime', () => { + it('should set estimateState with absolute time (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 50000 }, + }) + }) + + it('should set estimateState with absolute time (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 50000 }, + }) + ) + }) + }) + + describe('setEstimateDuration', () => { + it('should set estimateState with relative duration (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) + }) + }) + + it('should set estimateState with relative duration (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 30000 }, + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 40000 }, + }) + ) + }) + }) }) diff --git a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts new file mode 100644 index 0000000000..e6623a952b --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -0,0 +1,211 @@ +import { setupDefaultJobEnvironment, MockJobContext } from '../../__mocks__/context.js' +import { handleRecalculateTTimerEstimates } from '../tTimersJobs.js' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' + +describe('tTimersJobs', () => { + let context: MockJobContext + + beforeEach(() => { + context = setupDefaultJobEnvironment() + }) + + describe('handleRecalculateTTimerEstimates', () => { + it('should handle studio with active playlists', async () => { + // Create an active playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Test Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle studio with no active playlists', async () => { + // Create an inactive playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Inactive Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: undefined, // Not active + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors (just does nothing) + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle multiple active playlists', async () => { + // Create multiple active playlists + const playlistId1 = protectString('playlist1') + const playlistId2 = protectString('playlist2') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId1, + externalId: 'test1', + studioId: context.studioId, + name: 'Active Playlist 1', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId2, + externalId: 'test2', + studioId: context.studioId, + name: 'Active Playlist 2', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation2'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors, processing both playlists + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle playlist deleted between query and lock', async () => { + // This test is harder to set up properly, but the function should handle it + // by checking if playlist exists after acquiring lock + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + }) +}) From 7e41fea17724acf837945324cddf8a9470186b71 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 5 Feb 2026 12:05:45 +0000 Subject: [PATCH 68/79] feat(T-Timers): Add segment budget timing support to estimate calculations Implements segment budget timing for T-Timer estimate calculations in recalculateTTimerEstimates(). When a segment has a budgetDuration set, the function now: - Uses the segment budget instead of individual part durations - Tracks budget consumption as parts are traversed - Ignores budget timing if the anchor is within the budget segment (anchor part uses normal part duration timing) This matches the front-end timing behavior in rundownTiming.ts and ensures server-side estimates align with UI countdown calculations for budget-controlled segments. --- packages/job-worker/src/playout/tTimers.ts | 137 +++++++++++++-------- 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 0615294d71..b1c9b6192e 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,7 +8,7 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' @@ -183,13 +183,17 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe } /** - * Recalculate T-Timer estimates based on timing anchors + * Recalculate T-Timer estimates based on timing anchors using segment budget timing. * - * For each T-Timer that has an anchorPartId set, this function: - * 1. Iterates through ordered parts from current/next onwards - * 2. Accumulates expected durations until the anchor part is reached - * 3. Updates estimateState with the calculated duration - * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * Uses a single-pass algorithm with two accumulators: + * - totalAccumulator: Accumulated time across completed segments + * - segmentAccumulator: Accumulated time within current segment + * + * At each segment boundary: + * - If segment has a budget → use segment budget duration + * - Otherwise → use accumulated part durations + * + * Handles starting mid-segment with budget by calculating remaining budget time. * * @param context Job context * @param playoutModel The playout model containing the playlist and parts @@ -243,76 +247,113 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const now = getCurrentTime() - let accumulatedDuration = 0 - // Calculate remaining time for current part - // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) - // Account for playOffset (e.g., from play-from-anywhere feature) + // Initialize accumulators + let totalAccumulator = 0 + let segmentAccumulator = 0 let isPushing = false + let currentSegmentId: SegmentId | undefined = undefined + + // Handle current part/segment if (currentPartInstance) { - const currentPartDuration = - currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration - if (currentPartDuration) { - const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback - const startedPlayback = - currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now - const playOffset = currentPartInstance.timings?.playOffset || 0 - const elapsed = now - startedPlayback - playOffset - const remaining = currentPartDuration - elapsed - - isPushing = remaining < 0 - accumulatedDuration = Math.max(0, remaining) - - // Schedule next recalculation for when current part ends (if not pushing and no autoNext) - if (!isPushing && !currentPartInstance.part.autoNext) { - const delay = remaining + 5 // 5ms buffer - const timeoutId = setTimeout(() => { - context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { - logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) - }) - }, delay) - activeTimeouts.set(playlist.studioId, timeoutId) + currentSegmentId = currentPartInstance.segmentId + const currentSegment = playoutModel.findSegment(currentPartInstance.segmentId) + const currentSegmentBudget = currentSegment?.segment.segmentTiming?.budgetDuration + + if (currentSegmentBudget === undefined) { + // Normal part duration timing + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } + } else { + // Segment budget timing - we're already inside a budgeted segment + const segmentStartedPlayback = + playlist.segmentsStartedPlayback?.[currentPartInstance.segmentId as unknown as string] + if (segmentStartedPlayback) { + const segmentElapsed = now - segmentStartedPlayback + const remaining = currentSegmentBudget - segmentElapsed + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } else { + totalAccumulator = currentSegmentBudget } } + + // Schedule next recalculation + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = totalAccumulator + 5 + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } + // Single pass through parts for (const part of playablePartsSlice) { - // Add this part's expected duration to the accumulator - const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 - accumulatedDuration += partDuration + // Detect segment boundary + if (part.segmentId !== currentSegmentId) { + // Flush previous segment + if (currentSegmentId !== undefined) { + const lastSegment = playoutModel.findSegment(currentSegmentId) + const segmentBudget = lastSegment?.segment.segmentTiming?.budgetDuration + + // Use budget if it exists, otherwise use accumulated part durations + if (segmentBudget !== undefined) { + totalAccumulator += segmentBudget + } else { + totalAccumulator += segmentAccumulator + } + } + + // Reset for new segment + segmentAccumulator = 0 + currentSegmentId = part.segmentId + } - // Check if this part is an anchor for any timer + // Check if this part is an anchor const timersForThisPart = timerAnchors.get(part._id) if (timersForThisPart) { + const anchorTime = totalAccumulator + segmentAccumulator + for (const timerIndex of timersForThisPart) { const timer = tTimers[timerIndex - 1] - // Update the timer's estimate const estimateState: TimerState = isPushing ? literal({ paused: true, - duration: accumulatedDuration, + duration: anchorTime, }) : literal({ paused: false, - zeroTime: now + accumulatedDuration, + zeroTime: now + anchorTime, }) playoutModel.updateTTimer({ ...timer, estimateState }) } - // Remove this anchor since we've processed it + timerAnchors.delete(part._id) } - // Early exit if we've resolved all timers - if (timerAnchors.size === 0) { - break - } + // Accumulate this part's duration + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + segmentAccumulator += partDuration } - // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) - // Any remaining entries in timerAnchors are anchors that weren't reached - for (const timerIndices of timerAnchors.values()) { + // Clear estimates for unresolved anchors + for (const [, timerIndices] of timerAnchors.entries()) { for (const timerIndex of timerIndices) { const timer = tTimers[timerIndex - 1] playoutModel.updateTTimer({ ...timer, estimateState: undefined }) From 2a48e27c1aadcf3c688709ddd134087c935b0503 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 20 Feb 2026 10:51:13 +0000 Subject: [PATCH 69/79] Fix test by adding missing mocks --- .../src/ingest/__tests__/syncChangesToPartInstance.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 3f63fe8858..6fd99f4862 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -118,6 +118,9 @@ describe('SyncChangesToPartInstancesWorker', () => { { findPart: jest.fn(() => undefined), getGlobalPieces: jest.fn(() => []), + getAllOrderedParts: jest.fn(() => []), + getOrderedSegments: jest.fn(() => []), + findAdlibPiece: jest.fn(() => undefined), }, mockOptions ) From f2e8dd91101a70cd1627d7ebe62e33fdd44a221f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 25 Feb 2026 10:37:56 +0000 Subject: [PATCH 70/79] feat(T-Timers): Add convenience method to set estimate anchor part by externalId --- .../blueprints-integration/src/context/tTimersContext.ts | 9 +++++++++ .../src/blueprints/context/services/TTimersService.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index cce8ca198d..28e03b8ad6 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -88,6 +88,15 @@ export interface IPlaylistTTimer { */ setEstimateAnchorPart(partId: string): void + /** + * Set the anchor part for automatic estimate calculation, looked up by its externalId. + * This is a convenience method when you know the externalId of the part (e.g. set during ingest) + * but not its internal PartId. If no part with the given externalId is found, this is a no-op. + * Clears any manual estimate set via setEstimateTime/setEstimateDuration. + * @param externalId The externalId of the part to use as timing anchor + */ + setEstimateAnchorPartByExternalId(externalId: string): void + /** * Manually set the estimate as an absolute timestamp * Use this when you have custom logic for calculating when you expect to reach a timing point. diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index aee1064e57..d5e4150e6a 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -6,7 +6,7 @@ import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/coreli import type { TimerState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { assertNever, literal } from '@sofie-automation/corelib/dist/lib' -import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { @@ -213,6 +213,13 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { recalculateTTimerEstimates(this.#jobContext, this.#playoutModel) } + setEstimateAnchorPartByExternalId(externalId: string): void { + const part = this.#playoutModel.getAllOrderedParts().find((p) => p.externalId === externalId) + if (!part) return + + this.setEstimateAnchorPart(unprotectString(part._id)) + } + setEstimateTime(time: number, paused: boolean = false): void { const estimateState: TimerState = paused ? literal({ paused: true, duration: time - getCurrentTime() }) From 80150aa06dbb9e7d28284e4b883dda67e25ee8c6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 18 Feb 2026 17:01:13 +0000 Subject: [PATCH 71/79] feat(T-Timers): Add pauseTime field to timer estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional pauseTime field to TimerState type to indicate when a timer should automatically pause (when current part ends and overrun begins). Benefits: - Client can handle running→paused transition locally without server update - Reduces latency in state transitions - Server still triggers recalculation on Take/part changes - More declarative timing ("pause at this time" vs "set paused now") Implementation: - When not pushing: pauseTime = now + currentPartRemainingTime - When already pushing: pauseTime = null - Client should display timer as paused when now >= pauseTime --- packages/corelib/src/dataModel/RundownPlaylist.ts | 5 +++++ packages/job-worker/src/playout/tTimers.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e426fb3f8b..de3c92a54b 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -130,6 +130,7 @@ export interface RundownTTimerModeTimeOfDay { * Timing state for 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. + * pauseTime indicates when the timer should automatically pause (when current part ends and overrun begins). */ export type TimerState = | { @@ -137,12 +138,16 @@ export type TimerState = paused: false /** The absolute timestamp (ms) when the timer reaches/reached zero */ zeroTime: number + /** Optional timestamp when the timer should pause (when current part ends) */ + pauseTime?: number | null } | { /** Whether the timer is paused */ paused: true /** The frozen duration value in milliseconds */ duration: number + /** Optional timestamp when the timer should pause (null when already paused/pushing) */ + pauseTime?: number | null } export type RundownTTimerIndex = 1 | 2 | 3 diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index b1c9b6192e..e843ed40c0 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -301,6 +301,9 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } } + // Save remaining current part time for pauseTime calculation + const currentPartRemainingTime = totalAccumulator + // Single pass through parts for (const part of playablePartsSlice) { // Detect segment boundary @@ -335,10 +338,12 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl ? literal({ paused: true, duration: anchorTime, + pauseTime: null, // Already paused/pushing }) : literal({ paused: false, zeroTime: now + anchorTime, + pauseTime: now + currentPartRemainingTime, // When current part ends and pushing begins }) playoutModel.updateTTimer({ ...timer, estimateState }) From 30b032d93b9144ba2d75075070968272bb2a2de3 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:21:56 +0000 Subject: [PATCH 72/79] Remove timeout based update of T-Timer now we have pauseTime --- packages/job-worker/src/playout/tTimers.ts | 29 +--------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index e843ed40c0..917aa31027 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,20 +8,11 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { PartId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' -import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { logger } from '../logging.js' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' -/** - * Map of active setTimeout timeouts by studioId - * Used to clear previous timeout when recalculation is triggered before the timeout fires - */ -const activeTimeouts = new Map() - export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) } @@ -203,13 +194,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const playlist = playoutModel.playlist - // Clear any existing timeout for this studio - const existingTimeout = activeTimeouts.get(playlist.studioId) - if (existingTimeout) { - clearTimeout(existingTimeout) - activeTimeouts.delete(playlist.studioId) - } - const tTimers = playlist.tTimers // Find which timers have anchors that need calculation @@ -288,17 +272,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl totalAccumulator = currentSegmentBudget } } - - // Schedule next recalculation - if (!isPushing && !currentPartInstance.part.autoNext) { - const delay = totalAccumulator + 5 - const timeoutId = setTimeout(() => { - context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { - logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) - }) - }, delay) - activeTimeouts.set(playlist.studioId, timeoutId) - } } // Save remaining current part time for pauseTime calculation From 76b669ac73868372cda59f9a448a3bc92d8bd0e4 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:23:33 +0000 Subject: [PATCH 73/79] docs(T-Timers): Add client rendering logic for pauseTime Document the client-side logic for rendering timer states with pauseTime support: - paused === true: use frozen duration - pauseTime && now >= pauseTime: use zeroTime - pauseTime (auto-pause) - otherwise: use zeroTime - now (running normally) --- packages/corelib/src/dataModel/RundownPlaylist.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index de3c92a54b..b7ab807d24 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -131,6 +131,20 @@ export interface RundownTTimerModeTimeOfDay { * When running, the client calculates current time from zeroTime. * When paused, the duration is frozen and sent directly. * pauseTime indicates when the timer should automatically pause (when current part ends and overrun begins). + * + * Client rendering logic: + * ```typescript + * if (state.paused === true) { + * // Manually paused by user or already pushing/overrun + * duration = state.duration + * } else if (state.pauseTime && now >= state.pauseTime) { + * // Auto-pause at overrun (current part ended) + * duration = state.zeroTime - state.pauseTime + * } else { + * // Running normally + * duration = state.zeroTime - now + * } + * ``` */ export type TimerState = | { From 55632b94c07f8ce77ade7e145569f4d4992e2b30 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 13:20:36 +0000 Subject: [PATCH 74/79] feat(T-Timers): Add timerStateToDuration helper function Add timerStateToDuration() function to calculate current timer duration from TimerState, handling all three cases: - Manually paused or already pushing - Auto-pause at overrun (pauseTime) - Running normally Also rename "currentTime" to "currentDuration" in "calculateTTimerDiff" method --- .../corelib/src/dataModel/RundownPlaylist.ts | 21 +++++++++++++++++++ packages/webui/src/client/lib/tTimerUtils.ts | 16 +++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index b7ab807d24..410f275fb7 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -164,6 +164,27 @@ export type TimerState = pauseTime?: number | null } +/** + * Calculate the current duration for a timer state. + * Handles paused, auto-pause (pauseTime), and running states. + * + * @param state The timer state + * @param now Current timestamp in milliseconds + * @returns The current duration in milliseconds + */ +export function timerStateToDuration(state: TimerState, now: number): number { + if (state.paused) { + // Manually paused by user or already pushing/overrun + return state.duration + } else if (state.pauseTime && now >= state.pauseTime) { + // Auto-pause at overrun (current part ended) + return state.zeroTime - state.pauseTime + } else { + // Running normally + return state.zeroTime - now + } +} + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index 08ec4f19e2..de7377a5b0 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -1,4 +1,4 @@ -import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownTTimer, timerStateToDuration } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' /** * Calculate the display diff for a T-Timer. @@ -11,19 +11,19 @@ export function calculateTTimerDiff(timer: RundownTTimer, now: number): number { } // Get current time: either frozen duration or calculated from zeroTime - const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + const currentDuration = timerStateToDuration(timer.state, now) // Free run counts up, so negate to get positive elapsed time if (timer.mode?.type === 'freeRun') { - return -currentTime + return -currentDuration } // Apply stopAtZero if configured - if (timer.mode?.stopAtZero && currentTime < 0) { + if (timer.mode?.stopAtZero && currentDuration < 0) { return 0 } - return currentTime + return currentDuration } /** @@ -40,10 +40,8 @@ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): num return undefined } - const duration = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now - const estimateDuration = timer.estimateState.paused - ? timer.estimateState.duration - : timer.estimateState.zeroTime - now + const duration = timerStateToDuration(timer.state, now) + const estimateDuration = timerStateToDuration(timer.estimateState, now) return duration - estimateDuration } From 40d90d55f8cf2eb910a7669d10ca48b6f8febbd6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:29:35 +0000 Subject: [PATCH 75/79] Fix sign of over/under calculation If the estimate is big, the output should be positive for over. --- packages/webui/src/client/lib/tTimerUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index de7377a5b0..8b5a0938ea 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -43,7 +43,7 @@ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): num const duration = timerStateToDuration(timer.state, now) const estimateDuration = timerStateToDuration(timer.estimateState, now) - return duration - estimateDuration + return estimateDuration - duration } // TODO: remove this mock From b647e5fad6d6de6a4865e699d79b08f2a58cadec Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:55:45 +0000 Subject: [PATCH 76/79] Include next part in calculation --- packages/job-worker/src/playout/tTimers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 917aa31027..5bd5f04c05 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -277,6 +277,14 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // Save remaining current part time for pauseTime calculation const currentPartRemainingTime = totalAccumulator + // Add the next part to the beginning of playablePartsSlice + // getOrderedPartsAfterPlayhead excludes both current and next, so we need to prepend next + // This allows the loop to handle it normally, including detecting if it's an anchor + const nextPartInstance = playoutModel.nextPartInstance?.partInstance + if (nextPartInstance) { + playablePartsSlice.unshift(nextPartInstance.part) + } + // Single pass through parts for (const part of playablePartsSlice) { // Detect segment boundary From ac306b60965352460439d07598716c57e1528e04 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:42:53 +0000 Subject: [PATCH 77/79] Don't fetch all parts just to get a max length Just use infininty. The total length may not even be long enough in certain edge cases, for example if you requeue the first segment while later in the showl. --- packages/job-worker/src/playout/tTimers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 5bd5f04c05..bb005e52b7 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -216,8 +216,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // Get ordered parts after playhead (excludes previous, current, and next) // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior - const orderedParts = playoutModel.getAllOrderedParts() - const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, orderedParts.length, true) + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, Infinity, true) if (playablePartsSlice.length === 0 && !currentPartInstance) { // No parts to iterate through, clear estimates From 1132ad0292bbb57cc397952b9af09b87b7877b20 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:43:25 +0000 Subject: [PATCH 78/79] Ensure we recalculate timings when we queue segments --- packages/job-worker/src/playout/setNext.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 45209a6494..4f4f2a1953 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -529,6 +529,10 @@ export async function queueNextSegment( } else { playoutModel.setQueuedSegment(null) } + + // Recalculate timer estimates as the queued segment affects what comes after next + recalculateTTimerEstimates(context, playoutModel) + span?.end() return { queuedSegmentId: queuedSegment?.segment?._id ?? null } } From 9abc53e858bc63e3f65eec17cca5af14cd09a2dc Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 9 Mar 2026 17:55:44 +0000 Subject: [PATCH 79/79] Fix linting issue --- .../ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx index 18318ac74f..7a6328d03e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx @@ -5,7 +5,7 @@ import { useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' import { PartInstances, PieceInstances } from '../../../collections' import { VTContent } from '@sofie-automation/blueprints-integration' -export function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }) { +export function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }): JSX.Element | null { const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) const freezeFrameIcon = useTracker(