From 749c8a2f9da3fde28adcb4a84f7555ca2b744ca8 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 17:02:29 +0000 Subject: [PATCH 001/136] feat: RundownPlaylist T-Timers which are controllable from blueprints --- meteor/__mocks__/defaultCollectionObjects.ts | 5 + meteor/server/__tests__/cronjobs.test.ts | 1 + .../__tests__/externalMessageQueue.test.ts | 1 + .../api/__tests__/peripheralDevice.test.ts | 1 + meteor/server/migration/X_X_X.ts | 27 +- meteor/yarn.lock | 87 +-- .../src/context/adlibActionContext.ts | 4 +- .../src/context/onSetAsNextContext.ts | 3 +- .../src/context/onTakeContext.ts | 4 +- .../src/context/rundownContext.ts | 7 +- .../src/context/tTimersContext.ts | 119 +++ .../corelib/src/dataModel/RundownPlaylist.ts | 81 ++ packages/job-worker/package.json | 1 + .../src/__mocks__/defaultCollectionObjects.ts | 6 + .../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 | 168 ++++ .../services/__tests__/TTimersService.test.ts | 734 ++++++++++++++++++ .../__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 | 601 ++++++++++++++ .../src/playout/model/PlayoutModel.ts | 7 + .../model/implementation/PlayoutModelImpl.ts | 9 + packages/job-worker/src/playout/tTimers.ts | 172 ++++ packages/job-worker/src/rundownPlaylists.ts | 10 + .../src/topics/__tests__/utils.ts | 1 + .../src/__mocks__/defaultCollectionObjects.ts | 5 + .../lib/__tests__/rundownTiming.test.ts | 6 + packages/yarn.lock | 186 ++++- 34 files changed, 2511 insertions(+), 79 deletions(-) create mode 100644 packages/blueprints-integration/src/context/tTimersContext.ts create mode 100644 packages/job-worker/src/blueprints/context/services/TTimersService.ts 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 create mode 100644 packages/job-worker/src/playout/tTimers.ts diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index bf9e8fd75e..20d437d597 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 6b7cc68514..2d45733ccc 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -622,6 +622,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, diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index b37a2f4496..4403220846 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -58,7 +58,30 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, - // Add your migration here - new ContainerIdsToObjectWithOverridesMigrationStep(), + { + 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: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], + }, + }, + { multi: true } + ) + }, + }, + // Add your migration here ]) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 7b66ee3c2a..bfce2542a3 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -12,17 +12,6 @@ __metadata: languageName: node linkType: hard -"@acuminous/bitsyntax@npm:^0.1.2": - version: 0.1.2 - resolution: "@acuminous/bitsyntax@npm:0.1.2" - dependencies: - buffer-more-ints: "npm:~1.0.0" - debug: "npm:^4.3.4" - safe-buffer: "npm:~5.1.2" - checksum: 10/abdc4313ae08e52fb8eeaebf53759c3b9a38983a696d77c46c24de1c065247355a1b5c02ad3618700d3fb3628ccf3ec39227a080bd1fe7adc864144ccf84b0cc - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -1357,7 +1346,7 @@ __metadata: "@sofie-automation/blueprints-integration": "npm:26.3.0-2" "@sofie-automation/corelib": "npm:26.3.0-2" "@sofie-automation/shared-lib": "npm:26.3.0-2" - amqplib: "npm:0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.15.0" mongodb: "npm:^6.21.0" @@ -2239,17 +2228,6 @@ __metadata: languageName: node linkType: hard -"amqplib@npm:0.10.5": - version: 0.10.5 - resolution: "amqplib@npm:0.10.5" - dependencies: - "@acuminous/bitsyntax": "npm:^0.1.2" - buffer-more-ints: "npm:~1.0.0" - url-parse: "npm:~1.5.10" - checksum: 10/bcf4bda790f8a356ba4c7d3054ae3ee397a48d6c4d51f1015f703dd7205c097ba9772577567a06eb470d13e0becdc4163c857299e50eb5a4bc888e3007832f87 - languageName: node - linkType: hard - "ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" @@ -2897,13 +2875,6 @@ __metadata: languageName: node linkType: hard -"buffer-more-ints@npm:~1.0.0": - version: 1.0.0 - resolution: "buffer-more-ints@npm:1.0.0" - checksum: 10/603a7f35793426c8efd733eb716c2c3bf3e2f5bab95ca13ba31546d89ead3636586479c5a0d8438dd015115361a3b09b1b37ddabc170b6d42bc6c6dc2554dc61 - languageName: node - linkType: hard - "buffer-xor@npm:^1.0.3": version: 1.0.3 resolution: "buffer-xor@npm:1.0.3" @@ -3089,6 +3060,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:^4.2.0": version: 4.4.0 resolution: "ci-info@npm:4.4.0" @@ -6775,8 +6753,8 @@ __metadata: linkType: hard "koa@npm:^2.13.4": - version: 2.16.4 - resolution: "koa@npm:2.16.4" + version: 2.16.3 + resolution: "koa@npm:2.16.3" dependencies: accepts: "npm:^1.3.5" cache-content-type: "npm:^1.0.0" @@ -6801,7 +6779,7 @@ __metadata: statuses: "npm:^1.5.0" type-is: "npm:^1.6.16" vary: "npm:^1.1.2" - checksum: 10/f49e76c2cb7db4facbf215eef964c1eb3f0012c2f64490dfd9b349727e11c7f429f4bf16a47f725e41325415ffebefab0ca6ece3b1187518b42f979e4dbf6e01 + checksum: 10/62b6bc4939003eab2b77d523207e252f4eed3f75471fce3b50fe46a80fb01b9f425d4094437f25e3579ad90bcf43b652c166ac5b58d277255ed82a0ea7069ac8 languageName: node linkType: hard @@ -8532,11 +8510,11 @@ __metadata: linkType: hard "qs@npm:^6.12.3, qs@npm:^6.5.2": - version: 6.15.0 - resolution: "qs@npm:6.15.0" + version: 6.14.1 + resolution: "qs@npm:6.14.1" dependencies: side-channel: "npm:^1.1.0" - checksum: 10/a3458f2f389285c3512e0ebc55522ee370ac7cb720ba9f0eff3e30fb2bb07631caf556c08e2a3d4481a371ac14faa9ceb7442a0610c5a7e55b23a5bdee7b701c + checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5 languageName: node linkType: hard @@ -8556,13 +8534,6 @@ __metadata: languageName: node linkType: hard -"querystringify@npm:^2.1.1": - version: 2.2.0 - resolution: "querystringify@npm:2.2.0" - checksum: 10/46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 - languageName: node - linkType: hard - "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -8860,13 +8831,6 @@ __metadata: languageName: node linkType: hard -"requires-port@npm:^1.0.0": - version: 1.0.0 - resolution: "requires-port@npm:1.0.0" - checksum: 10/878880ee78ccdce372784f62f52a272048e2d0827c29ae31e7f99da18b62a2b9463ea03a75f277352f4697c100183debb0532371ad515a2d49d4bfe596dd4c20 - languageName: node - linkType: hard - "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -8994,7 +8958,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1, safe-buffer@npm:~5.1.2": +"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" checksum: 10/7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a @@ -9058,7 +9022,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3": +"semver@npm:^7.0.0, semver@npm:^7.7.2, semver@npm:^7.7.3": version: 7.7.3 resolution: "semver@npm:7.7.3" bin: @@ -9067,6 +9031,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": + version: 7.7.1 + resolution: "semver@npm:7.7.1" + bin: + semver: bin/semver.js + checksum: 10/4cfa1eb91ef3751e20fc52e47a935a0118d56d6f15a837ab814da0c150778ba2ca4f1a4d9068b33070ea4273629e615066664c2cfcd7c272caf7a8a0f6518b2c + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -10419,16 +10392,6 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:~1.5.10": - version: 1.5.10 - resolution: "url-parse@npm:1.5.10" - dependencies: - querystringify: "npm:^2.1.1" - requires-port: "npm:^1.0.0" - checksum: 10/c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad - languageName: node - linkType: hard - "url@npm:^0.11.4": version: 0.11.4 resolution: "url@npm:0.11.4" diff --git a/packages/blueprints-integration/src/context/adlibActionContext.ts b/packages/blueprints-integration/src/context/adlibActionContext.ts index 838d304287..00da91558d 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, ITriggerIngestChangeContext } from './executeTsrActionContext.js' import { IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece } from '../index.js' import { IRouteSetMethods } from './routeSetContext.js' +import { ITTimersContext } from './tTimersContext.js' /** Actions */ export interface IDataStoreMethods { @@ -28,7 +29,8 @@ export interface IActionExecutionContext IPartAndPieceActionContext, IExecuteTSRActionsContext, ITriggerIngestChangeContext, - 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 009d7052ad..217bdd2c42 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -13,12 +13,13 @@ import { import { ITriggerIngestChangeContext } from './executeTsrActionContext.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, ITriggerIngestChangeContext { +export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext, ITriggerIngestChangeContext, 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 6c46d41ea9..20e462092f 100644 --- a/packages/blueprints-integration/src/context/onTakeContext.ts +++ b/packages/blueprints-integration/src/context/onTakeContext.ts @@ -1,6 +1,7 @@ import { IBlueprintPart, IBlueprintPiece, IEventContext, IShowStyleUserContext, Time } from '../index.js' import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext, ITriggerIngestChangeContext } 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 @@ -11,7 +12,8 @@ export interface IOnTakeContext IShowStyleUserContext, IEventContext, IExecuteTSRActionsContext, - ITriggerIngestChangeContext { + ITriggerIngestChangeContext, + 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..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 @@ -13,7 +14,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..8747f450a2 --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,119 @@ +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: IPlaylistTTimerIndex): IPlaylistTTimer + + /** + * Clear all T-timers + */ + clearAllTimers(): void +} + +export interface IPlaylistTTimer { + readonly index: IPlaylistTTimerIndex + + /** 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 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 + */ + startFreeRun(options?: { startPaused?: boolean }): void + + /** + * If the current mode supports being paused, pause the timer + * 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 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 and timeOfDay modes + * @returns True if the timer was restarted, false if it could not be restarted + */ + restart(): boolean +} + +export type IPlaylistTTimerState = + | IPlaylistTTimerStateCountdown + | IPlaylistTTimerStateFreeRun + | IPlaylistTTimerStateTimeOfDay + +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 +} + +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 d8bc66f809..357a471bef 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,6 +94,81 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay + +export interface RundownTTimerModeFreeRun { + readonly type: 'freeRun' + /** + * 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 + */ + pauseTime: number | null + /** The direction to count */ + // direction: 'up' | 'down' // TODO: does this make sense? +} +export interface RundownTTimerModeCountdown { + readonly type: 'countdown' + /** + * 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 + */ + pauseTime: number | null + /** + * The duration of the countdown in milliseconds + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + 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 + +export interface RundownTTimer { + readonly index: RundownTTimerIndex + + /** 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 */ @@ -182,6 +257,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 diff --git a/packages/job-worker/package.json b/packages/job-worker/package.json index 71360033af..5dd185767e 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -40,6 +40,7 @@ "@sofie-automation/corelib": "26.3.0-2", "@sofie-automation/shared-lib": "26.3.0-2", "amqplib": "0.10.5", + "chrono-node": "^2.9.0", "deepmerge": "^4.3.1", "elastic-apm-node": "^4.15.0", "mongodb": "^6.21.0", diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 9f194ce196..37bbdefe64 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: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 543d37d5df..7b6c977b8d 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -27,11 +27,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, emitIngestOperation } 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( @@ -44,6 +49,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) + this.#tTimersService = new TTimersService(playoutModel) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -163,4 +169,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 4dad8a18d4..eabaa1f751 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -31,8 +31,13 @@ import { import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration/dist/context/quickLoopInfo' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints, emitIngestOperation } 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 partToQueueAfterTake: QueueablePartAndPieces | undefined @@ -61,6 +66,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> { @@ -189,4 +195,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 1019d55501..7b5768f750 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 { @@ -75,4 +81,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 0510481b19..353a558d7a 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -38,6 +38,9 @@ import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration import { setNextPartFromPart } from '../../playout/setNext.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints, emitIngestOperation } 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 @@ -70,6 +73,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 * */ @@ -112,6 +117,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> { @@ -285,4 +291,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..b8ef3c7e21 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,168 @@ +import type { + IPlaylistTTimer, + 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 { PlayoutModel } from '../../../playout/model/PlayoutModel.js' +import { ReadonlyDeep } from 'type-fest' +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 + + readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] + + constructor(playoutModel: PlayoutModel) { + this.playoutModel = playoutModel + + this.timers = [ + new PlaylistTTimerImpl(playoutModel, 1), + new PlaylistTTimerImpl(playoutModel, 2), + new PlaylistTTimerImpl(playoutModel, 3), + ] + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + validateTTimerIndex(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 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 { + mode: 'countdown', + currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + duration: rawMode.duration, + paused: !!rawMode.pauseTime, + stopAtZero: rawMode.stopAtZero, + } + case 'freeRun': + return { + mode: 'freeRun', + 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: + assertNever(rawMode) + return null + } + } + + constructor(playoutModel: PlayoutModel, index: RundownTTimerIndex) { + this.#playoutModel = playoutModel + this.#index = index + + validateTTimerIndex(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 { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createCountdownTTimer(duration, { + stopAtZero: options?.stopAtZero ?? true, + startPaused: options?.startPaused ?? false, + }), + }) + } + 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, + mode: createFreeRunTTimer({ + startPaused: options?.startPaused ?? false, + }), + }) + } + pause(): boolean { + const newTimer = pauseTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true + } + resume(): boolean { + const newTimer = resumeTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true + } + restart(): boolean { + 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/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..7943a89592 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,734 @@ +/* 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, + }) + }) + + 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', () => { + 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('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() + 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() + }) + + 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', () => { + 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() + }) + + 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', () => { + 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 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) + 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 68033783d5..af5c32cb77 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 53709ddf99..3b1de10668 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..144baca1a5 --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,601 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { + validateTTimerIndex, + pauseTTimer, + resumeTTimer, + restartTTimer, + createCountdownTTimer, + createFreeRunTTimer, + calculateTTimerCurrentTime, + calculateNextTimeOfDayTarget, + createTimeOfDayTTimer, +} from '../tTimers.js' +import type { + RundownTTimerMode, + RundownTTimerModeTimeOfDay, +} 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) + }) + }) + + 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/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 5158e6ae4a..f84a098b28 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' @@ -380,6 +381,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 5923d568f5..3fe579c388 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' @@ -80,6 +81,7 @@ import { getStudioTimeline, } from '../../timeline/generate.js' import { deNowifyMultiGatewayTimeline } from '../../timeline/multi-gateway.js' +import { validateTTimerIndex } from '../../tTimers.js' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -903,6 +905,13 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + updateTTimer(timer: RundownTTimer): void { + validateTTimerIndex(timer.index) + + this.playlistImpl.tTimers[timer.index - 1] = timer + this.#playlistHasChanged = true + } + #lastMonotonicNowInPlayout = getCurrentTime() getNowInPlayout(): number { const nowOffsetLatency = this.getNowOffsetLatency() ?? 0 diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts new file mode 100644 index 0000000000..5477491d71 --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,172 @@ +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}`) +} + +/** + * 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') { + if (timer.pauseTime) { + // Already paused + return timer + } + + 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) { + // 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 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 + } +} + +/** + * 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, + } +} + +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 + * @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 + } +} + +/** + * 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/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a..eb61a94b06 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: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], ...clone(existingPlaylist), @@ -332,6 +337,11 @@ function defaultPlaylistForRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], ...clone(existingPlaylist), 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, } } diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index f27e2de36a..d73f2ebb47 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: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, 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..f57f33d4ed 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: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) } diff --git a/packages/yarn.lock b/packages/yarn.lock index 4f46f8ae52..4ad64d34d4 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -417,7 +417,7 @@ __metadata: languageName: node linkType: hard -"@asyncapi/generator-react-sdk@npm:*, @asyncapi/generator-react-sdk@npm:^1.1.2": +"@asyncapi/generator-react-sdk@npm:*": version: 1.1.3 resolution: "@asyncapi/generator-react-sdk@npm:1.1.3" dependencies: @@ -435,6 +435,24 @@ __metadata: languageName: node linkType: hard +"@asyncapi/generator-react-sdk@npm:^1.1.2": + version: 1.1.2 + resolution: "@asyncapi/generator-react-sdk@npm:1.1.2" + dependencies: + "@asyncapi/parser": "npm:^3.1.0" + "@babel/core": "npm:7.12.9" + "@babel/preset-env": "npm:^7.12.7" + "@babel/preset-react": "npm:^7.12.7" + "@rollup/plugin-babel": "npm:^5.2.1" + babel-plugin-source-map-support: "npm:^2.1.3" + prop-types: "npm:^15.7.2" + react: "npm:^17.0.1" + rollup: "npm:^2.60.1" + source-map-support: "npm:^0.5.19" + checksum: 10/2bdc65653def9e551373c8955d7ea7d2f80ecc5a449b72af52bd10ab0c69aa498dc94bfcfc8d58148d80b7b7b4b16966a7d2be65499cffcda6edfb671c651d98 + languageName: node + linkType: hard + "@asyncapi/generator@npm:^2.11.0": version: 2.11.0 resolution: "@asyncapi/generator@npm:2.11.0" @@ -656,7 +674,16 @@ __metadata: languageName: node linkType: hard -"@asyncapi/specs@npm:^6.0.0-next-major-spec.9, @asyncapi/specs@npm:^6.11.1": +"@asyncapi/specs@npm:^6.0.0-next-major-spec.9": + version: 6.8.1 + resolution: "@asyncapi/specs@npm:6.8.1" + dependencies: + "@types/json-schema": "npm:^7.0.11" + checksum: 10/27f945d43157c14d74b36f65571eb9b16043be768d06fd48ce1b9749b11ecdfd36cd2b0f294c50d66f61df19703c8caf62569a406220b492d4fb9cce0b84c0ce + languageName: node + linkType: hard + +"@asyncapi/specs@npm:^6.11.1": version: 6.11.1 resolution: "@asyncapi/specs@npm:6.11.1" dependencies: @@ -1122,7 +1149,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.25.9, @babel/plugin-syntax-jsx@npm:^7.27.1": +"@babel/plugin-syntax-jsx@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/plugin-syntax-jsx@npm:7.25.9" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/bb609d1ffb50b58f0c1bac8810d0e46a4f6c922aa171c458f3a19d66ee545d36e782d3bffbbc1fed0dc65a558bdce1caf5279316583c0fff5a2c1658982a8563 + languageName: node + linkType: hard + +"@babel/plugin-syntax-jsx@npm:^7.27.1": version: 7.28.6 resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" dependencies: @@ -1221,7 +1259,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.25.9, @babel/plugin-syntax-typescript@npm:^7.27.1": +"@babel/plugin-syntax-typescript@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/plugin-syntax-typescript@npm:7.25.9" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0e9821e8ba7d660c36c919654e4144a70546942ae184e85b8102f2322451eae102cbfadbcadd52ce077a2b44b400ee52394c616feab7b5b9f791b910e933fd33 + languageName: node + linkType: hard + +"@babel/plugin-syntax-typescript@npm:^7.27.1": version: 7.28.6 resolution: "@babel/plugin-syntax-typescript@npm:7.28.6" dependencies: @@ -4198,7 +4247,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/external-editor@npm:^1.0.0, @inquirer/external-editor@npm:^1.0.2": +"@inquirer/external-editor@npm:^1.0.0": version: 1.0.3 resolution: "@inquirer/external-editor@npm:1.0.3" dependencies: @@ -4213,6 +4262,21 @@ __metadata: languageName: node linkType: hard +"@inquirer/external-editor@npm:^1.0.2": + version: 1.0.2 + resolution: "@inquirer/external-editor@npm:1.0.2" + dependencies: + chardet: "npm:^2.1.0" + iconv-lite: "npm:^0.7.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/d0c5c73249b8153f4cf872c4fba01c57a7653142a4cad496f17ed03ef3769330a4b3c519b68d70af69d4bb33003d2599b66b2242be85411c0b027ff383619666 + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.13": version: 1.0.13 resolution: "@inquirer/figures@npm:1.0.13" @@ -4728,7 +4792,18 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.0, @jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": +"@jridgewell/gen-mapping@npm:^0.3.0, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/81587b3c4dd8e6c60252122937cea0c637486311f4ed208b52b62aae2e7a87598f63ec330e6cd0984af494bfb16d3f0d60d3b21d7e5b4aedd2602ff3fe9d32e2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12": version: 0.3.13 resolution: "@jridgewell/gen-mapping@npm:0.3.13" dependencies: @@ -4755,6 +4830,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10/832e513a85a588f8ed4f27d1279420d8547743cc37fcad5a5a76fc74bb895b013dfe614d0eed9cb860048e6546b798f8f2652020b4b2ba0561b05caa8c654b10 + languageName: node + linkType: hard + "@jridgewell/source-map@npm:^0.3.3": version: 0.3.5 resolution: "@jridgewell/source-map@npm:0.3.5" @@ -7139,6 +7221,7 @@ __metadata: "@sofie-automation/corelib": "npm:26.3.0-2" "@sofie-automation/shared-lib": "npm:26.3.0-2" amqplib: "npm:0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.15.0" jest: "npm:^30.2.0" @@ -9004,7 +9087,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.33": version: 17.0.35 resolution: "@types/yargs@npm:17.0.35" dependencies: @@ -9013,6 +9096,15 @@ __metadata: languageName: node linkType: hard +"@types/yargs@npm:^17.0.8": + version: 17.0.24 + resolution: "@types/yargs@npm:17.0.24" + dependencies: + "@types/yargs-parser": "npm:*" + checksum: 10/03d9a985cb9331b2194a52d57a66aad88bf46aa32b3968a71cc6f39fb05c74f0709f0dd3aa9c0b29099cfe670343e3b1bd2ac6df2abfab596ede4453a616f63f + languageName: node + linkType: hard + "@types/yauzl@npm:^2.9.1": version: 2.10.0 resolution: "@types/yauzl@npm:2.10.0" @@ -10393,7 +10485,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"axios@npm:^1.11.0, axios@npm:^1.12.0": +"axios@npm:^1.11.0": version: 1.13.6 resolution: "axios@npm:1.13.6" dependencies: @@ -10404,6 +10496,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"axios@npm:^1.12.0": + version: 1.13.3 + resolution: "axios@npm:1.13.3" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10/2ceca9215671f9c2bcd5d8a0a1a667e9a35f9f7cfae88f25bba773ed9612de6cac50b2bf8be5e6918cbd2db601b4431ca87a00bffd9682939a8b85da9c89345a + languageName: node + linkType: hard + "b4a@npm:^1.6.4": version: 1.7.3 resolution: "b4a@npm:1.7.3" @@ -11532,6 +11635,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"chardet@npm:^2.1.0": + version: 2.1.0 + resolution: "chardet@npm:2.1.0" + checksum: 10/8085fd8e5b1234fafacb279b4dab84dc127f512f953441daf09fc71ade70106af0dff28e86bfda00bab0de61fb475fa9003c87f82cbad3da02a4f299bfd427da + languageName: node + linkType: hard + "chardet@npm:^2.1.1": version: 2.1.1 resolution: "chardet@npm:2.1.1" @@ -11654,6 +11764,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" @@ -15639,7 +15756,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.11, follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.6": + version: 1.15.9 + resolution: "follow-redirects@npm:1.15.9" + peerDependenciesMeta: + debug: + optional: true + checksum: 10/e3ab42d1097e90d28b913903841e6779eb969b62a64706a3eb983e894a5db000fbd89296f45f08885a0e54cd558ef62e81be1165da9be25a6c44920da10f424c + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.11": version: 1.15.11 resolution: "follow-redirects@npm:1.15.11" peerDependenciesMeta: @@ -16482,6 +16609,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"has@npm:^1.0.3": + version: 1.0.3 + resolution: "has@npm:1.0.3" + dependencies: + function-bind: "npm:^1.1.1" + checksum: 10/a449f3185b1d165026e8d25f6a8c3390bd25c201ff4b8c1aaf948fc6a5fcfd6507310b8c00c13a3325795ea9791fcc3d79d61eafa313b5750438fc19183df57b + languageName: node + linkType: hard + "hash-base@npm:^3.0.0, hash-base@npm:^3.1.2": version: 3.1.2 resolution: "hash-base@npm:3.1.2" @@ -17653,7 +17789,16 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1, is-core-module@npm:^2.5.0, is-core-module@npm:^2.8.1": +"is-core-module@npm:^2.13.0, is-core-module@npm:^2.5.0, is-core-module@npm:^2.8.1": + version: 2.13.0 + resolution: "is-core-module@npm:2.13.0" + dependencies: + has: "npm:^1.0.3" + checksum: 10/55ccb5ccd208a1e088027065ee6438a99367e4c31c366b52fbaeac8fa23111cd17852111836d904da604801b3286d38d3d1ffa6cd7400231af8587f021099dc6 + languageName: node + linkType: hard + +"is-core-module@npm:^2.16.1": version: 2.16.1 resolution: "is-core-module@npm:2.16.1" dependencies: @@ -29064,6 +29209,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"tr46@npm:^5.0.0": + version: 5.0.0 + resolution: "tr46@npm:5.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/29155adb167d048d3c95d181f7cb5ac71948b4e8f3070ec455986e1f34634acae50ae02a3c8d448121c3afe35b76951cd46ed4c128fd80264280ca9502237a3e + languageName: node + linkType: hard + "tr46@npm:^5.1.0": version: 5.1.1 resolution: "tr46@npm:5.1.1" @@ -30874,7 +31028,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.0 || ^13.0.0, whatwg-url@npm:^14.1.1": +"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.0 || ^13.0.0": + version: 14.1.1 + resolution: "whatwg-url@npm:14.1.1" + dependencies: + tr46: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10/803bede3ec6c8f14de0d84ac6032479646b5a2b08f5a7289366c3461caed9d7888d171e2846b59798869191037562c965235c2eed6ff2e266c05a2b4a6ce0160 + languageName: node + linkType: hard + +"whatwg-url@npm:^14.1.1": version: 14.2.0 resolution: "whatwg-url@npm:14.2.0" dependencies: From c295446167c0d8298ec637ac5addc312a21063da Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 20 Jan 2026 14:36:56 +0000 Subject: [PATCH 002/136] fix: expose IBlueprintSegmentDB through getSegment to bluerpints --- .../blueprints-integration/src/context/onSetAsNextContext.ts | 4 ++-- .../src/context/partsAndPieceActionContext.ts | 4 ++-- .../job-worker/src/blueprints/context/OnSetAsNextContext.ts | 4 ++-- packages/job-worker/src/blueprints/context/OnTakeContext.ts | 4 ++-- packages/job-worker/src/blueprints/context/adlibActions.ts | 4 ++-- .../context/services/PartAndPieceInstanceActionService.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 217bdd2c42..6a209b11af 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -6,7 +6,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IShowStyleUserContext, } from '../index.js' @@ -57,7 +57,7 @@ export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContex /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise /** Gets the Segment. This primarily allows for accessing metadata */ - getSegment(segment: 'current' | 'next'): Promise + getSegment(segment: 'current' | 'next'): Promise /** Get a list of the upcoming Parts in the Rundown, in the order that they will be Taken * diff --git a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts index 6f10958eeb..a5a2b9c998 100644 --- a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts +++ b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts @@ -7,7 +7,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, Time, } from '../index.js' import { BlueprintQuickLookInfo } from './quickLoopInfo.js' @@ -50,7 +50,7 @@ export interface IPartAndPieceActionContext { /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise /** Gets the Segment. This primarily allows for accessing metadata */ - getSegment(segment: 'current' | 'next'): Promise + getSegment(segment: 'current' | 'next'): Promise /** Get a list of the upcoming Parts in the Rundown, in the order that they will be Taken * diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 7b6c977b8d..ce97a44238 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -9,7 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IOnSetAsNextContext, } from '@sofie-automation/blueprints-integration' @@ -84,7 +84,7 @@ export class OnSetAsNextContext return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index eabaa1f751..b5d9bf9503 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -12,7 +12,7 @@ import { TSR, IBlueprintPlayoutDevice, IOnTakeContext, - IBlueprintSegment, + IBlueprintSegmentDB, } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -86,7 +86,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex async getResolvedPieceInstances(part: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 353a558d7a..b98c3d9d10 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -14,7 +14,7 @@ import { TSR, IBlueprintPlayoutDevice, StudioRouteSet, - IBlueprintSegment, + IBlueprintSegmentDB, } from '@sofie-automation/blueprints-integration' import { PartInstanceId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -136,7 +136,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 8b7a270586..8f72b9f89d 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -9,7 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, OmitId, SomeContent, Time, @@ -145,7 +145,7 @@ export class PartAndPieceInstanceActionService { ) return resolvedInstances.map(convertResolvedPieceInstanceToBlueprints) } - getSegment(segment: 'current' | 'next'): IBlueprintSegment | undefined { + getSegment(segment: 'current' | 'next'): IBlueprintSegmentDB | undefined { const partInstance = this.#getPartInstance(segment) if (!partInstance) return undefined From c20297ebc40b538d8e66480707a1652be1c27fa9 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 20 Jan 2026 15:38:46 +0000 Subject: [PATCH 003/136] chore: ttimer debug test --- .../src/client/ui/RundownView/RundownHeader/RundownHeader.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 00466fab68..c0d2b8ae83 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -106,6 +106,10 @@ export function RundownHeader({ const rundownTimesInfo = checkRundownTimes(playlist.timing) + useEffect(() => { + console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) + }, [playlist.tTimers]) + return ( <> From 5dc0334f530b77ab3b033ec5a8caf7fe6fe57b3e Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 10:43:07 +0000 Subject: [PATCH 004/136] T-timers refactor (#71) * Simplify timer state storage Makes calculations of timing consistent for all timer types * Separate T-Timer mode and state State contains the dynamic how much time is left information . Mode contains more static information about the timer's properties etc. * Fix t-timer tests --- meteor/__mocks__/defaultCollectionObjects.ts | 6 +- meteor/server/migration/X_X_X.ts | 8 +- .../corelib/src/dataModel/RundownPlaylist.ts | 57 +-- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- .../context/services/TTimersService.ts | 51 +-- .../services/__tests__/TTimersService.test.ts | 113 +++-- .../__tests__/externalMessageQueue.test.ts | 12 +- .../syncChangesToPartInstance.test.ts | 6 +- .../src/ingest/__tests__/updateNext.test.ts | 6 +- .../__snapshots__/mosIngest.test.ts.snap | 45 ++ .../__snapshots__/playout.test.ts.snap | 3 + .../src/playout/__tests__/tTimers.test.ts | 415 ++++++++++-------- packages/job-worker/src/playout/tTimers.ts | 113 +++-- packages/job-worker/src/rundownPlaylists.ts | 12 +- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- .../lib/__tests__/rundownTiming.test.ts | 6 +- 16 files changed, 491 insertions(+), 374 deletions(-) diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index 20d437d597..0bc1d260dc 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -53,9 +53,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 4403220846..5940d5acbe 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -69,13 +69,13 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ }, migrate: async () => { await RundownPlaylists.mutableCollection.updateAsync( - { tTimers: { $exists: false } }, + { tTimers: { $exists: false } } as any, { $set: { tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }, }, diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 357a471bef..b3b3d2c943 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -98,33 +98,11 @@ export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCoun export interface RundownTTimerModeFreeRun { readonly type: 'freeRun' - /** - * 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 - */ - pauseTime: number | null - /** The direction to count */ - // direction: 'up' | 'down' // TODO: does this make sense? } export interface RundownTTimerModeCountdown { readonly type: 'countdown' /** - * 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 - */ - pauseTime: number | null - /** - * The duration of the countdown in milliseconds + * The original duration of the countdown in milliseconds, so that we know what value to reset to */ readonly duration: number @@ -136,9 +114,6 @@ export interface RundownTTimerModeCountdown { 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) @@ -151,6 +126,25 @@ export interface RundownTTimerModeTimeOfDay { readonly stopAtZero: boolean } +/** + * 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. + */ +export type TimerState = + | { + /** Whether the timer is paused */ + paused: false + /** The absolute timestamp (ms) when the timer reaches/reached zero */ + zeroTime: number + } + | { + /** Whether the timer is paused */ + paused: true + /** The frozen duration value in milliseconds */ + duration: number + } + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { @@ -159,9 +153,18 @@ export interface RundownTTimer { /** A label for the timer */ label: string - /** The current mode of the timer, or null if not configured */ + /** The current mode of the timer, or null if not configured + * + * This defines how the timer behaves + */ mode: RundownTTimerMode | null + /** The current state of the timer, or null if not configured + * + * This contains the information needed to calculate the current time of the timer + */ + state: TimerState | null + /* * Future ideas: * allowUiControl: boolean diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 37bbdefe64..c881344721 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: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b8ef3c7e21..80a7e0faf9 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -7,7 +7,6 @@ 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, createTimeOfDayTTimer, @@ -60,31 +59,35 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } get state(): IPlaylistTTimerState | null { const rawMode = this.#modelTimer.mode - switch (rawMode?.type) { + const rawState = this.#modelTimer.state + + if (!rawMode || !rawState) return null + + const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() + + switch (rawMode.type) { case 'countdown': return { mode: 'countdown', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + currentTime, duration: rawMode.duration, - paused: !!rawMode.pauseTime, + paused: rawState.paused, stopAtZero: rawMode.stopAtZero, } case 'freeRun': return { mode: 'freeRun', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), - paused: !!rawMode.pauseTime, + currentTime, + paused: rawState.paused, } case 'timeOfDay': return { mode: 'timeOfDay', - currentTime: rawMode.targetTime - getCurrentTime(), - targetTime: rawMode.targetTime, + currentTime, + targetTime: rawState.paused ? 0 : rawState.zeroTime, targetRaw: rawMode.targetRaw, stopAtZero: rawMode.stopAtZero, } - case undefined: - return null default: assertNever(rawMode) return null @@ -108,12 +111,13 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#playoutModel.updateTTimer({ ...this.#modelTimer, mode: null, + state: null, }) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createCountdownTTimer(duration, { + ...createCountdownTTimer(duration, { stopAtZero: options?.stopAtZero ?? true, startPaused: options?.startPaused ?? false, }), @@ -122,7 +126,7 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createTimeOfDayTTimer(targetTime, { + ...createTimeOfDayTTimer(targetTime, { stopAtZero: options?.stopAtZero ?? true, }), }) @@ -130,39 +134,30 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startFreeRun(options?: { startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createFreeRunTTimer({ + ...createFreeRunTTimer({ startPaused: options?.startPaused ?? false, }), }) } pause(): boolean { - const newTimer = pauseTTimer(this.#modelTimer.mode) + const newTimer = pauseTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } resume(): boolean { - const newTimer = resumeTTimer(this.#modelTimer.mode) + const newTimer = resumeTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } restart(): boolean { - const newTimer = restartTTimer(this.#modelTimer.mode) + const newTimer = restartTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } } 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 7943a89592..352a48bde3 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 @@ -23,9 +23,9 @@ function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownT 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 }, + { 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 }, ] } @@ -91,8 +91,10 @@ describe('TTimersService', () => { 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 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[1].state = { paused: false, zeroTime: 65000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const service = new TTimersService(mockPlayoutModel) @@ -151,7 +153,8 @@ describe('PlaylistTTimerImpl', () => { it('should return running freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -164,7 +167,8 @@ describe('PlaylistTTimerImpl', () => { it('should return paused freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: 3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -179,11 +183,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: null, duration: 60000, stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -200,11 +203,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 7000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 2000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -221,10 +223,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, // 10 seconds in the future targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -242,10 +244,10 @@ describe('PlaylistTTimerImpl', () => { const targetTimestamp = 1737331200000 tTimers[0].mode = { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: false, } + tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -271,6 +273,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'New Label', mode: null, + state: null, }) }) }) @@ -278,7 +281,8 @@ describe('PlaylistTTimerImpl', () => { describe('clearTimer', () => { it('should clear the timer mode', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -288,6 +292,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'Timer 1', mode: null, + state: null, }) }) }) @@ -305,11 +310,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, }) }) @@ -325,11 +329,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, duration: 30000, stopAtZero: false, }, + state: { paused: true, duration: 30000 }, }) }) }) @@ -347,9 +350,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: null, }, + state: { paused: false, zeroTime: 10000 }, }) }) @@ -365,9 +367,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: 10000, }, + state: { paused: true, duration: 0 }, }) }) }) @@ -385,10 +386,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -405,10 +409,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: true, }, + state: { + paused: false, + zeroTime: targetTimestamp, + }, }) }) @@ -427,6 +434,10 @@ describe('PlaylistTTimerImpl', () => { targetRaw: '18:00', stopAtZero: false, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -442,10 +453,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: expect.objectContaining({ type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '5:30pm', stopAtZero: true, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -469,7 +483,8 @@ describe('PlaylistTTimerImpl', () => { describe('pause', () => { it('should pause a running freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -481,15 +496,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 5000, - pauseTime: 10000, }, + state: { paused: true, duration: -5000 }, }) }) it('should pause a running countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 70000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -501,11 +516,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 5000, - pauseTime: 10000, duration: 60000, stopAtZero: true, }, + state: { paused: true, duration: 60000 }, }) }) @@ -524,10 +538,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -541,7 +555,8 @@ describe('PlaylistTTimerImpl', () => { describe('resume', () => { it('should resume a paused freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: -3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -553,15 +568,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 7000, // adjusted for pause duration - pauseTime: null, }, + state: { paused: false, zeroTime: 7000 }, // adjusted for pause duration }) }) it('should return true but not change a running timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -587,10 +602,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -604,7 +619,8 @@ describe('PlaylistTTimerImpl', () => { describe('restart', () => { it('should restart a countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 40000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -616,11 +632,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, // reset to now - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, // reset to now + duration }) }) @@ -628,11 +643,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 8000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -644,17 +658,17 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, // also reset to now (paused at start) duration: 60000, stopAtZero: false, }, + state: { paused: true, duration: 60000 }, // reset to full duration, paused }) }) it('should return false for freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -668,10 +682,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, // old target time targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -683,10 +697,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -694,10 +711,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, targetRaw: 'invalid-time-string', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index a39d82f7cc..54b97fb011 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -57,9 +57,9 @@ describe('Test external message queue static methods', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) await context.mockCollections.Rundowns.insertOne({ @@ -207,9 +207,9 @@ describe('Test sending messages to mocked endpoints', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 47ddfed664..adf7cbaeab 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -316,9 +316,9 @@ describe('SyncChangesToPartInstancesWorker', () => { timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index 91df4cc24e..cc40fff715 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -35,9 +35,9 @@ async function createMockRO(context: MockJobContext): Promise { rundownIdsInOrder: [rundownId], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) 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 af5c32cb77..a6acb97b01 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 @@ -20,16 +20,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -329,16 +332,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -630,16 +636,19 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -952,16 +961,19 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1264,16 +1276,19 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1574,16 +1589,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1852,16 +1870,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2175,16 +2196,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2506,16 +2530,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2820,16 +2847,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3134,16 +3164,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3447,16 +3480,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3753,16 +3789,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4091,16 +4130,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4405,16 +4447,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { 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 3b1de10668..964548f28e 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 @@ -82,16 +82,19 @@ exports[`Playout API Basic rundown control 4`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts index 144baca1a5..bea1a2c92b 100644 --- a/packages/job-worker/src/playout/__tests__/tTimers.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { useFakeCurrentTime, useRealCurrentTime } from '../../__mocks__/time.js' import { validateTTimerIndex, pauseTTimer, @@ -7,14 +7,10 @@ import { restartTTimer, createCountdownTTimer, createFreeRunTTimer, - calculateTTimerCurrentTime, calculateNextTimeOfDayTarget, createTimeOfDayTTimer, } from '../tTimers.js' -import type { - RundownTTimerMode, - RundownTTimerModeTimeOfDay, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('tTimers utils', () => { beforeEach(() => { @@ -51,48 +47,63 @@ describe('tTimers utils', () => { describe('pauseTTimer', () => { it('should pause a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // 60 seconds from now } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 5000, - pauseTime: 10000, // getCurrentTime() - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 60000 }, // Captured remaining time }) }) it('should pause a running freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // Started 5 seconds ago } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'freeRun', - startTime: 5000, - pauseTime: 10000, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // Elapsed time (negative for counting up) }) }) 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 timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // already paused } const result = pauseTTimer(timer) @@ -101,10 +112,13 @@ describe('tTimers utils', () => { }) it('should return unchanged freeRun timer if already paused', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: 7000, // already paused + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 5000 }, // already paused } const result = pauseTTimer(timer) @@ -112,59 +126,77 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference, unchanged }) - it('should return null for null timer', () => { - expect(pauseTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(pauseTTimer(timer)).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 timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // 30 seconds remaining } 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, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // now (10000) + duration (30000) }) }) it('should resume a paused freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 2000, - pauseTime: 6000, // paused 4 seconds after start + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // 5 seconds elapsed } 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, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // now (10000) + duration (-5000) }) }) 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 timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // already running } const result = resumeTTimer(timer) @@ -173,10 +205,13 @@ describe('tTimers utils', () => { }) it('should return freeRun timer unchanged if already running', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, // already running + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // already running } const result = resumeTTimer(timer) @@ -184,64 +219,93 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference }) - it('should return null for null timer', () => { - expect(resumeTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(resumeTTimer(timer)).toBeNull() }) }) describe('restartTTimer', () => { it('should restart a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // Partway through } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: null, - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) it('should restart a paused countdown timer (stays paused)', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 8000, - duration: 60000, - stopAtZero: false, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 15000 }, // Paused with time remaining } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: 10000, // also now (paused at start) - duration: 60000, - stopAtZero: false, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 60000 }, // Reset to full duration, still paused }) }) it('should return null for freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, } expect(restartTTimer(timer)).toBeNull() }) - it('should return null for null timer', () => { - expect(restartTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(restartTTimer(timer)).toBeNull() }) }) @@ -253,11 +317,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) @@ -268,11 +333,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: 10000, - duration: 30000, - stopAtZero: false, + mode: { + type: 'countdown', + duration: 30000, + stopAtZero: false, + }, + state: { paused: true, duration: 30000 }, }) }) @@ -300,9 +366,10 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: false }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: null, + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 10000 }, // now }) }) @@ -310,51 +377,14 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: true }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: 10000, + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 0 }, }) }) }) - 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) - }) - }) - 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() @@ -510,10 +540,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer('15:30', { stopAtZero: true }) expect(result).toEqual({ - type: 'timeOfDay', - stopAtZero: true, - targetTime: expect.any(Number), // new target time - targetRaw: '15:30', + mode: { + type: 'timeOfDay', + stopAtZero: true, + targetRaw: '15:30', + }, + state: { + paused: false, + zeroTime: expect.any(Number), // Parsed target time + }, }) }) @@ -522,10 +557,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) expect(result).toEqual({ - type: 'timeOfDay', - targetTime: timestamp, - targetRaw: timestamp, - stopAtZero: false, + mode: { + type: 'timeOfDay', + targetRaw: timestamp, + stopAtZero: false, + }, + state: { + paused: false, + zeroTime: timestamp, + }, }) }) @@ -556,28 +596,41 @@ describe('tTimers utils', () => { }) it('should restart a timeOfDay timer with valid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: '15:30', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) - expect(result).toEqual({ - ...timer, - targetTime: expect.any(Number), // new target time + expect(result).not.toBeNull() + expect(result?.mode).toEqual(timer.mode) + expect(result?.state).toEqual({ + paused: false, + zeroTime: expect.any(Number), // new target time }) - expect((result as RundownTTimerModeTimeOfDay).targetTime).toBeGreaterThan(timer.targetTime) + if (!result || !result.state || result.state.paused) { + throw new Error('Expected running timeOfDay timer state') + } + expect(result.state.zeroTime).toBeGreaterThan(1737300000000) }) it('should return null for timeOfDay timer with invalid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 'invalid', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 'invalid', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) @@ -586,11 +639,15 @@ describe('tTimers utils', () => { }) it('should return null for timeOfDay timer with unix timestamp', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 1737300000000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 1737300000000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 5477491d71..af86616f82 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,4 +1,9 @@ -import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { + RundownTTimerIndex, + RundownTTimerMode, + RundownTTimer, + TimerState, +} 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' @@ -12,16 +17,16 @@ export function validateTTimerIndex(index: number): asserts index is RundownTTim * @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') { - if (timer.pauseTime) { +export function pauseTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (timer.state.paused) { // Already paused return timer } - return { ...timer, - pauseTime: getCurrentTime(), + state: { paused: true, duration: timer.state.zeroTime - getCurrentTime() }, } } else { return null @@ -33,20 +38,17 @@ export function pauseTTimer(timer: ReadonlyDeep | null): Read * @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) { +export function resumeTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (!timer.state.paused) { // Already running return timer } - const pausedOffset = timer.startTime - timer.pauseTime - const newStartTime = getCurrentTime() + pausedOffset - return { ...timer, - startTime: newStartTime, - pauseTime: null, + state: { paused: false, zeroTime: timer.state.duration + getCurrentTime() }, } } else { return null @@ -58,21 +60,23 @@ export function resumeTTimer(timer: ReadonlyDeep | null): Rea * @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') { +export function restartTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown') { return { ...timer, - startTime: getCurrentTime(), - pauseTime: timer.pauseTime ? getCurrentTime() : null, + state: timer.state.paused + ? { paused: true, duration: timer.mode.duration } + : { paused: false, zeroTime: getCurrentTime() + timer.mode.duration }, } - } 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 + } else if (timer.mode.type === 'timeOfDay') { + const nextTime = calculateNextTimeOfDayTarget(timer.mode.targetRaw) + // If we can't calculate the next time, or it's the same, we can't restart + if (nextTime === null || (timer.state.paused ? false : nextTime === timer.state.zeroTime)) return null return { ...timer, - targetTime: nextTime, + state: { paused: false, zeroTime: nextTime }, } } else { return null @@ -80,11 +84,10 @@ export function restartTTimer(timer: ReadonlyDeep | null): Re } /** - * Create a new countdown T-timer - * @param index Timer index + * Create a new countdown T-timer mode and initial state * @param duration Duration in milliseconds * @param options Options for the countdown - * @returns The created T-timer + * @returns The created T-timer mode and state */ export function createCountdownTTimer( duration: number, @@ -92,16 +95,18 @@ export function createCountdownTTimer( stopAtZero: boolean startPaused: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: 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, + mode: { + type: 'countdown', + duration, + stopAtZero: !!options.stopAtZero, + }, + state: options.startPaused + ? { paused: true, duration: duration } + : { paused: false, zeroTime: getCurrentTime() + duration }, } } @@ -110,43 +115,35 @@ export function createTimeOfDayTTimer( options: { stopAtZero: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: 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, + mode: { + type: 'timeOfDay', + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + }, + state: { paused: false, zeroTime: nextTime }, } } /** - * Create a new free-running T-timer - * @param index Timer index + * Create a new free-running T-timer mode and initial state * @param options Options for the free-run - * @returns The created T-timer + * @returns The created T-timer mode and state */ -export function createFreeRunTTimer(options: { startPaused: boolean }): ReadonlyDeep { +export function createFreeRunTTimer(options: { startPaused: boolean }): { + mode: ReadonlyDeep + state: 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 + mode: { + type: 'freeRun', + }, + state: options.startPaused ? { paused: true, duration: 0 } : { paused: false, zeroTime: now }, } } diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index eb61a94b06..33faf33e29 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: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), @@ -338,9 +338,9 @@ function defaultPlaylistForRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index d73f2ebb47..3df301315a 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: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index f57f33d4ed..a7cabd427e 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: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) } From 10f5fd0ff7f77104dcf974350e3fe9f4a452b755 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 3 Feb 2026 16:27:59 +0000 Subject: [PATCH 005/136] feat: allow t-timers from syncIngestChanges --- .../src/context/syncIngestChangesContext.ts | 3 +- .../context-OnSetAsNextContext.test.ts | 11 +- .../__tests__/context-OnTakeContext.test.ts | 11 +- .../__tests__/context-adlibActions.test.ts | 11 +- .../blueprints/context/OnSetAsNextContext.ts | 2 +- .../src/blueprints/context/OnTakeContext.ts | 2 +- .../context/RundownActivationContext.ts | 2 +- .../SyncIngestUpdateToPartInstanceContext.ts | 94 +++--- .../src/blueprints/context/adlibActions.ts | 2 +- .../context/services/TTimersService.ts | 94 +++--- .../services/__tests__/TTimersService.test.ts | 280 ++++++++++-------- .../syncChangesToPartInstance.test.ts | 15 +- .../src/ingest/syncChangesToPartInstance.ts | 6 + 13 files changed, 318 insertions(+), 215 deletions(-) diff --git a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts index e6917d443b..668e5bfd3e 100644 --- a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts +++ b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts @@ -6,8 +6,9 @@ import type { IBlueprintPieceInstance, } from '../documents/index.js' import type { IEventContext } from './eventContext.js' +import type { ITTimersContext } from './tTimersContext.js' -export interface ISyncIngestUpdateToPartInstanceContext extends IRundownUserContext, IEventContext { +export interface ISyncIngestUpdateToPartInstanceContext extends IRundownUserContext, ITTimersContext, IEventContext { /** Sync a pieceInstance. Inserts the pieceInstance if new, updates if existing. Optionally pass in a mutated Piece, to override the content of the instance */ syncPieceInstance( pieceInstanceId: string, diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts index 5cdf53ed78..7bb1aaf986 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts @@ -9,13 +9,22 @@ import { OnSetAsNextContext } from '../context/index.js' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(setManually = false, rehearsal?: boolean) { const mockActionService = mock() const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + 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 }, + ], + }) satisfies Partial, }) const context = new OnSetAsNextContext( { diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts index 06319381fd..8ea794c883 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts @@ -9,12 +9,21 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutModelImpl } from '../../playout/model/implementation/PlayoutModelImpl.js' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(rehearsal?: boolean) { const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + 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 }, + ], + }) satisfies Partial, }) const mockActionService = mock() const context = new OnTakeContext( diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 1dcd4e99a1..b61faf8c17 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -7,13 +7,22 @@ import { JobContext, ProcessedShowStyleCompound } from '../../jobs/index.js' import { mock } from 'jest-mock-extended' import { PartAndPieceInstanceActionService } from '../context/services/PartAndPieceInstanceActionService.js' import { ProcessedShowStyleConfig } from '../config.js' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(rehearsal?: boolean) { const mockActionService = mock() const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + 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 }, + ], + }) satisfies Partial, }) const context = new ActionExecutionContext( { diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index ce97a44238..2be2d1b7f5 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -49,7 +49,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) - this.#tTimersService = new TTimersService(playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(playoutModel) } 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 b5d9bf9503..82690fa748 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 = new TTimersService(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) } 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 7b5768f750..9f6418b3a5 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 = new TTimersService(this._playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel) } get previousState(): IRundownActivationContextState { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index d8289be7d9..3bbec8cdaa 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -32,24 +32,39 @@ import { } from '@sofie-automation/corelib/dist/dataModel/Piece' import { EXPECTED_INGEST_TO_PLAYOUT_TIME } from '@sofie-automation/shared-lib/dist/core/constants' import { getCurrentTime } from '../../lib/index.js' +import { TTimersService } from './services/TTimersService.js' +import type { + DBRundownPlaylist, + RundownTTimer, + RundownTTimerIndex, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' export class SyncIngestUpdateToPartInstanceContext extends RundownUserContext implements ISyncIngestUpdateToPartInstanceContext { - private readonly _proposedPieceInstances: Map> + readonly #context: JobContext + readonly #proposedPieceInstances: Map> + readonly #tTimersService: TTimersService + readonly #changedTTimers = new Map() - private partInstance: PlayoutPartInstanceModel | null + #partInstance: PlayoutPartInstanceModel | null public get hasRemovedPartInstance(): boolean { - return !this.partInstance + return !this.#partInstance + } + + public get changedTTimers(): RundownTTimer[] { + return Array.from(this.#changedTTimers.values()) } constructor( - private readonly _context: JobContext, + context: JobContext, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, + playlist: ReadonlyDeep, rundown: ReadonlyDeep, partInstance: PlayoutPartInstanceModel, proposedPieceInstances: ReadonlyDeep, @@ -58,32 +73,43 @@ export class SyncIngestUpdateToPartInstanceContext super( contextInfo, studio, - _context.getStudioBlueprintConfig(), + context.getStudioBlueprintConfig(), showStyleCompound, - _context.getShowStyleBlueprintConfig(showStyleCompound), + context.getShowStyleBlueprintConfig(showStyleCompound), rundown ) - this.partInstance = partInstance + this.#context = context + this.#partInstance = partInstance - this._proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }) + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() } syncPieceInstance( pieceInstanceId: string, modifiedPiece?: Omit ): IBlueprintPieceInstance { - const proposedPieceInstance = this._proposedPieceInstances.get(protectString(pieceInstanceId)) + const proposedPieceInstance = this.#proposedPieceInstances.get(protectString(pieceInstanceId)) if (!proposedPieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // filter the submission to the allowed ones const piece = modifiedPiece ? postProcessPieces( - this._context, + this.#context, [ { ...modifiedPiece, @@ -92,9 +118,9 @@ export class SyncIngestUpdateToPartInstanceContext }, ], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] : proposedPieceInstance.piece @@ -103,7 +129,7 @@ export class SyncIngestUpdateToPartInstanceContext ...proposedPieceInstance, piece: piece, } - this.partInstance.mergeOrInsertPieceInstance(newPieceInstance) + this.#partInstance.mergeOrInsertPieceInstance(newPieceInstance) return convertPieceInstanceToBlueprints(newPieceInstance) } @@ -111,19 +137,19 @@ export class SyncIngestUpdateToPartInstanceContext insertPieceInstance(piece0: IBlueprintPiece): IBlueprintPieceInstance { const trimmedPiece: IBlueprintPiece = _.pick(piece0, IBlueprintPieceObjectsSampleKeys) - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const piece = postProcessPieces( - this._context, + this.#context, [trimmedPiece], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] - const newPieceInstance = this.partInstance.insertPlannedPiece(piece) + const newPieceInstance = this.#partInstance.insertPlannedPiece(piece) return convertPieceInstanceToBlueprints(newPieceInstance.pieceInstance) } @@ -134,13 +160,13 @@ export class SyncIngestUpdateToPartInstanceContext throw new Error(`Cannot update PieceInstance "${pieceInstanceId}". Some valid properties must be defined`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) - const pieceInstance = this.partInstance.getPieceInstance(protectString(pieceInstanceId)) + const pieceInstance = this.#partInstance.getPieceInstance(protectString(pieceInstanceId)) if (!pieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (pieceInstance.pieceInstance.partInstanceId !== this.partInstance.partInstance._id) { + if (pieceInstance.pieceInstance.partInstanceId !== this.#partInstance.partInstance._id) { throw new Error(`PieceInstance "${pieceInstanceId}" does not belong to the current PartInstance`) } @@ -167,13 +193,13 @@ export class SyncIngestUpdateToPartInstanceContext return convertPieceInstanceToBlueprints(pieceInstance.pieceInstance) } updatePartInstance(updatePart: Partial): IBlueprintPartInstance { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // for autoNext, the new expectedDuration cannot be shorter than the time a part has been on-air for - const expectedDuration = updatePart.expectedDuration ?? this.partInstance.partInstance.part.expectedDuration - const autoNext = updatePart.autoNext ?? this.partInstance.partInstance.part.autoNext + const expectedDuration = updatePart.expectedDuration ?? this.#partInstance.partInstance.part.expectedDuration + const autoNext = updatePart.autoNext ?? this.#partInstance.partInstance.part.autoNext if (expectedDuration && autoNext) { - const onAir = this.partInstance.partInstance.timings?.reportedStartedPlayback + const onAir = this.#partInstance.partInstance.timings?.reportedStartedPlayback const minTime = Date.now() - (onAir ?? 0) + EXPECTED_INGEST_TO_PLAYOUT_TIME if (onAir && minTime > expectedDuration) { updatePart.expectedDuration = minTime @@ -185,31 +211,31 @@ export class SyncIngestUpdateToPartInstanceContext this.showStyleCompound.blueprintId ) - if (!this.partInstance.updatePartProps(playoutUpdatePart)) { + if (!this.#partInstance.updatePartProps(playoutUpdatePart)) { throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) } - return convertPartInstanceToBlueprints(this.partInstance.partInstance) + return convertPartInstanceToBlueprints(this.#partInstance.partInstance) } removePartInstance(): void { if (this.playStatus !== 'next') throw new Error(`Only the 'next' PartInstance can be removed`) - this.partInstance = null + this.#partInstance = null } removePieceInstances(...pieceInstanceIds: string[]): string[] { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const rawPieceInstanceIdSet = new Set(protectStringArray(pieceInstanceIds)) - const pieceInstances = this.partInstance.pieceInstances.filter((p) => + const pieceInstances = this.#partInstance.pieceInstances.filter((p) => rawPieceInstanceIdSet.has(p.pieceInstance._id) ) const pieceInstanceIdsToRemove = pieceInstances.map((p) => p.pieceInstance._id) for (const id of pieceInstanceIdsToRemove) { - this.partInstance.removePieceInstance(id) + this.#partInstance.removePieceInstance(id) } return unprotectStringArray(pieceInstanceIdsToRemove) diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index b98c3d9d10..6251a791d5 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 = new TTimersService(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) } 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 80a7e0faf9..b1eeafd49c 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -18,20 +18,25 @@ import { import { getCurrentTime } from '../../../lib/time.js' export class TTimersService { - readonly playoutModel: PlayoutModel - readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] - constructor(playoutModel: PlayoutModel) { - this.playoutModel = playoutModel - + constructor( + timers: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void + ) { this.timers = [ - new PlaylistTTimerImpl(playoutModel, 1), - new PlaylistTTimerImpl(playoutModel, 2), - new PlaylistTTimerImpl(playoutModel, 3), + new PlaylistTTimerImpl(timers[0], emitChange), + new PlaylistTTimerImpl(timers[1], emitChange), + new PlaylistTTimerImpl(timers[2], emitChange), ] } + static withPlayoutModel(playoutModel: PlayoutModel): TTimersService { + return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }) + } + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { validateTTimerIndex(index) return this.timers[index - 1] @@ -44,22 +49,19 @@ export class TTimersService { } export class PlaylistTTimerImpl implements IPlaylistTTimer { - readonly #playoutModel: PlayoutModel - readonly #index: RundownTTimerIndex + readonly #emitChange: (updatedTimer: ReadonlyDeep) => void - get #modelTimer(): ReadonlyDeep { - return this.#playoutModel.playlist.tTimers[this.#index - 1] - } + #timer: ReadonlyDeep get index(): RundownTTimerIndex { - return this.#modelTimer.index + return this.#timer.index } get label(): string { - return this.#modelTimer.label + return this.#timer.label } get state(): IPlaylistTTimerState | null { - const rawMode = this.#modelTimer.mode - const rawState = this.#modelTimer.state + const rawMode = this.#timer.mode + const rawState = this.#timer.state if (!rawMode || !rawState) return null @@ -94,70 +96,76 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } } - constructor(playoutModel: PlayoutModel, index: RundownTTimerIndex) { - this.#playoutModel = playoutModel - this.#index = index - - validateTTimerIndex(index) + constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + this.#timer = timer + this.#emitChange = emitChange } setLabel(label: string): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, label: label, - }) + } + this.#emitChange(this.#timer) } clearTimer(): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, mode: null, state: null, - }) + } + this.#emitChange(this.#timer) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createCountdownTTimer(duration, { stopAtZero: options?.stopAtZero ?? true, startPaused: options?.startPaused ?? false, }), - }) + } + this.#emitChange(this.#timer) } startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createTimeOfDayTTimer(targetTime, { stopAtZero: options?.stopAtZero ?? true, }), - }) + } + this.#emitChange(this.#timer) } startFreeRun(options?: { startPaused?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createFreeRunTTimer({ startPaused: options?.startPaused ?? false, }), - }) + } + this.#emitChange(this.#timer) } pause(): boolean { - const newTimer = pauseTTimer(this.#modelTimer) + const newTimer = pauseTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(newTimer) return true } resume(): boolean { - const newTimer = resumeTTimer(this.#modelTimer) + const newTimer = resumeTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(newTimer) return true } restart(): boolean { - const newTimer = restartTTimer(this.#modelTimer) + const newTimer = restartTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(newTimer) return true } } 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 352a48bde3..2fe7a21b29 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 @@ -40,9 +40,10 @@ describe('TTimersService', () => { describe('constructor', () => { it('should create three timer instances', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const timers = createEmptyTTimers() + const updateFn = jest.fn() - const service = new TTimersService(mockPlayoutModel) + const service = new TTimersService(timers, updateFn) expect(service.timers).toHaveLength(3) expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) @@ -51,10 +52,27 @@ describe('TTimersService', () => { }) }) + it('from playout model', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + const service = TTimersService.withPlayoutModel(mockPlayoutModel) + expect(service.timers).toHaveLength(3) + + const timer = service.getTimer(1) + expect(timer.index).toBe(1) + + timer.setLabel('New Label') + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, label: 'New Label' }) + ) + }) + describe('getTimer', () => { it('should return the correct timer for index 1', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(1) @@ -62,8 +80,10 @@ describe('TTimersService', () => { }) it('should return the correct timer for index 2', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(2) @@ -71,8 +91,10 @@ describe('TTimersService', () => { }) it('should return the correct timer for index 3', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(3) @@ -80,8 +102,10 @@ describe('TTimersService', () => { }) it('should throw for invalid index', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) 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') @@ -96,22 +120,18 @@ describe('TTimersService', () => { tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[1].state = { paused: false, zeroTime: 65000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) 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 }) - ) + expect(updateFn).toHaveBeenCalledTimes(3) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 1, mode: null })) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 2, mode: null })) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 3, mode: null })) }) }) }) @@ -128,8 +148,8 @@ describe('PlaylistTTimerImpl', () => { describe('getters', () => { it('should return the correct index', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) expect(timer.index).toBe(2) }) @@ -137,16 +157,17 @@ describe('PlaylistTTimerImpl', () => { it('should return the correct label', () => { const tTimers = createEmptyTTimers() tTimers[1].label = 'Custom Label' - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) 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) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toBeNull() }) @@ -155,8 +176,8 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'freeRun', @@ -169,8 +190,8 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: 3000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'freeRun', @@ -187,8 +208,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'countdown', @@ -207,8 +228,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: true, duration: 2000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'countdown', @@ -227,8 +248,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -248,8 +269,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: false, zeroTime: targetTimestamp } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -264,12 +285,13 @@ describe('PlaylistTTimerImpl', () => { describe('setLabel', () => { it('should update the label', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.setLabel('New Label') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'New Label', mode: null, @@ -283,12 +305,12 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.clearTimer() - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: null, @@ -300,12 +322,13 @@ describe('PlaylistTTimerImpl', () => { describe('startCountdown', () => { it('should start a running countdown with default options', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startCountdown(60000) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -319,12 +342,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused countdown', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -340,12 +364,13 @@ describe('PlaylistTTimerImpl', () => { describe('startFreeRun', () => { it('should start a running free-run timer', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startFreeRun() - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -357,12 +382,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused free-run timer', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startFreeRun({ startPaused: true }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -376,12 +402,13 @@ 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) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('15:30') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -398,13 +425,14 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with numeric timestamp', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const targetTimestamp = 1737331200000 timer.startTimeOfDay(targetTimestamp) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -421,12 +449,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with stopAtZero false', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('18:00', { stopAtZero: false }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: expect.objectContaining({ @@ -443,12 +472,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with 12-hour format', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('5:30pm') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: expect.objectContaining({ @@ -465,16 +495,18 @@ describe('PlaylistTTimerImpl', () => { it('should throw for invalid time string', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) 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) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') }) @@ -485,13 +517,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -505,13 +537,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 70000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -525,13 +557,14 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should return false for timeOfDay timer (does not support pause)', () => { @@ -542,13 +575,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) }) @@ -557,13 +590,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: -3000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -577,25 +610,26 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() // Returns true because timer supports resume, but it's already running expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalled() + expect(updateFn).toHaveBeenCalled() }) it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should return false for timeOfDay timer (does not support resume)', () => { @@ -606,13 +640,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) }) @@ -621,13 +655,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 40000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -647,13 +681,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: true, duration: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -669,13 +703,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should restart a timeOfDay timer with valid targetRaw', () => { @@ -686,13 +720,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -715,37 +749,25 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).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 updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) 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' - ) + expect(updateFn).not.toHaveBeenCalled() }) }) }) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index adf7cbaeab..3f63fe8858 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -84,7 +84,7 @@ describe('SyncChangesToPartInstancesWorker', () => { describe('syncChangesToPartInstance', () => { function createMockPlayoutModel(partialModel?: Partial>) { - return mock( + const mockPlayoutModel = mock( { currentPartInstance: null, nextPartInstance: partialModel?.nextPartInstance ?? null, @@ -96,6 +96,19 @@ describe('SyncChangesToPartInstancesWorker', () => { }, mockOptions ) + + Object.defineProperty(mockPlayoutModel, 'playlist', { + get: () => + ({ + 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 }, + ], + }) satisfies Partial, + }) + + return mockPlayoutModel } function createMockPlayoutRundownModel(): PlayoutRundownModel { return mock({}, mockOptions) diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index 6f8352751c..bb39e145d5 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -135,6 +135,7 @@ export class SyncChangesToPartInstancesWorker { }, this.#context.studio, this.#showStyle, + this.#playoutModel.playlist, instanceToSync.playoutRundownModel.rundown, existingPartInstance, proposedPieceInstances, @@ -152,6 +153,11 @@ export class SyncChangesToPartInstancesWorker { newResultData, instanceToSync.playStatus ) + + // Persist t-timer changes + for (const timer of syncContext.changedTTimers) { + this.#playoutModel.updateTTimer(timer) + } } catch (err) { logger.error(`Error in showStyleBlueprint.syncIngestUpdateToPartInstance: ${stringifyError(err)}`) From 7c833cb4fef09391494c84172a578b99bc7bc1f3 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 4 Feb 2026 17:28:30 +0000 Subject: [PATCH 006/136] feat: provide allParts and an approx index to syncIngest --- .../src/api/showStyle.ts | 9 ++ ...rtInstance-computeCurrentPartIndex.test.ts | 126 ++++++++++++++++++ .../src/ingest/syncChangesToPartInstance.ts | 84 +++++++++++- 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index 0ea002a984..0e7fba3275 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -295,6 +295,15 @@ export interface BlueprintResultPart { } export interface BlueprintSyncIngestNewData { + /** All parts in the rundown, including the new/updated part */ + allParts: IBlueprintPartDB[] + /** + * An approximate index of the current part in the allParts array + * Note: this will not always be an integer, such as when the part is an adlib part + * `null` means the part could not be placed + */ + currentPartIndex: number | null + // source: BlueprintSyncIngestDataSource /** The new part */ part: IBlueprintPartDB | undefined diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts new file mode 100644 index 0000000000..6b688cf1d0 --- /dev/null +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts @@ -0,0 +1,126 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { computeCurrentPartIndex } from '../syncChangesToPartInstance.js' + +describe('computeCurrentPartIndex', () => { + function createMockSegmentsAndParts() { + const segments = [ + { + _id: protectString('segment1'), + _rank: 1, + }, + { + _id: protectString('segment1b'), + _rank: 2, + }, + { + _id: protectString('segment2'), + _rank: 3, + }, + { + _id: protectString('segment3'), + _rank: 4, + }, + ] satisfies Partial[] + const parts = [ + { + _id: protectString('part1'), + segmentId: protectString('segment1'), + _rank: 1, + }, + { + _id: protectString('part2'), + segmentId: protectString('segment1'), + _rank: 2, + }, + { + _id: protectString('part3'), + segmentId: protectString('segment2'), + _rank: 1, + }, + { + _id: protectString('part4'), + segmentId: protectString('segment2'), + _rank: 2, + }, + { + _id: protectString('part5'), + segmentId: protectString('segment3'), + _rank: 1, + }, + { + _id: protectString('part6'), + segmentId: protectString('segment3'), + _rank: 2, + }, + { + _id: protectString('part7'), + segmentId: protectString('segment3'), + _rank: 3, + }, + ] satisfies Partial[] + + return { + segments: segments as DBSegment[], + parts: parts as DBPart[], + } + } + + it('match by id', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('part3'), protectString('segment2'), 3) + expect(index).toBe(2) + }) + + it('interpolate by rank', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partY'), protectString('segment2'), 1.3) + expect(index).toBe(2.5) + }) + + it('before first part in segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partZ'), protectString('segment2'), 0) + expect(index).toBe(1.5) + }) + + it('after last part in segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partW'), protectString('segment2'), 3) + expect(index).toBe(3.5) + }) + + it('segment with no parts', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partV'), protectString('segment1b'), 1) + expect(index).toBe(1.5) + }) + + it('non-existing segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partU'), protectString('segmentX'), 1) + expect(index).toBeNull() + }) + + it('no parts at all', () => { + const segments: DBSegment[] = [] + const parts: DBPart[] = [] + + const index = computeCurrentPartIndex(segments, parts, protectString('partT'), protectString('segment1'), 1) + expect(index).toBeNull() + }) + + it('before first part', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partS'), protectString('segment1'), 0) + expect(index).toBe(-0.5) + }) +}) diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index bb39e145d5..afee746ca2 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -33,8 +33,9 @@ import { convertNoteToNotification } from '../notifications/util.js' import { PlayoutRundownModel } from '../playout/model/PlayoutRundownModel.js' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { setNextPart } from '../playout/setNext.js' -import { PartId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import type { WrappedShowStyleBlueprint } from '../blueprints/cache.js' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' type PlayStatus = 'previous' | 'current' | 'next' export interface PartInstanceToSync { @@ -195,7 +196,7 @@ export class SyncChangesToPartInstancesWorker { } } - collectNewIngestDataToSync( + private collectNewIngestDataToSync( partId: PartId, instanceToSync: PartInstanceToSync, proposedPieceInstances: PieceInstance[] @@ -210,7 +211,18 @@ export class SyncChangesToPartInstancesWorker { if (adLibPiece) referencedAdlibs.push(convertAdLibPieceToBlueprints(adLibPiece)) } + const allModelParts = this.#ingestModel.getAllOrderedParts() + return { + allParts: allModelParts.map((part) => convertPartToBlueprints(part.part)), + currentPartIndex: computeCurrentPartIndex( + this.#ingestModel.getOrderedSegments().map((s) => s.segment), + allModelParts.map((p) => p.part), + partId, + instanceToSync.existingPartInstance.partInstance.segmentId, + instanceToSync.existingPartInstance.partInstance.part._rank + ), + part: instanceToSync.newPart ? convertPartToBlueprints(instanceToSync.newPart) : undefined, pieceInstances: proposedPieceInstances.map(convertPieceInstanceToBlueprints), adLibPieces: @@ -486,3 +498,71 @@ function findLastUnorphanedPartInstanceInSegment( part: previousPart, } } + +/** + * Compute an approximate (possibly non-integer) index of the part within all parts + * This is used to give the blueprints an idea of where the part is within the rundown + * Note: this assumes each part has a unique integer rank, which is what ingest will produce + * @returns The approximate index, or `null` if the part could not be placed + */ +export function computeCurrentPartIndex( + allOrderedSegments: ReadonlyDeep[], + allOrderedParts: ReadonlyDeep[], + partId: PartId, + segmentId: SegmentId, + targetRank: number +): number | null { + // Exact match by part id + const exactIdx = allOrderedParts.findIndex((p) => p._id === partId) + if (exactIdx !== -1) return exactIdx + + // Find the segment object + const segment = allOrderedSegments.find((s) => s._id === segmentId) + if (!segment) return null + + // Prepare parts with their global indices + const partsWithGlobal = allOrderedParts.map((p, globalIndex) => ({ part: p, globalIndex })) + + // Parts in the same segment + const partsInSegment = partsWithGlobal.filter((pg) => pg.part.segmentId === segmentId) + + if (partsInSegment.length === 0) { + // Segment has no parts: place between the previous/next parts by segment order + const segmentRank = segment._rank + + const prev = partsWithGlobal.findLast((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank < segmentRank + }) + + const next = partsWithGlobal.find((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank > segmentRank + }) + + if (prev && next) return (prev.globalIndex + next.globalIndex) / 2 + if (prev) return prev.globalIndex + 0.5 + if (next) return next.globalIndex - 0.5 + + // No parts at all + return null + } + + // There are parts in the segment: decide placement by rank within the segment. + + const nextIdx = partsInSegment.findIndex((pg) => pg.part._rank > targetRank) + if (nextIdx === -1) { + // After last + return partsInSegment[partsInSegment.length - 1].globalIndex + 0.5 + } + + if (nextIdx === 0) { + // Before first + return partsInSegment[0].globalIndex - 0.5 + } + + // Between two adjacent parts: interpolate by their ranks (proportionally) + const prev = partsInSegment[nextIdx - 1] + const next = partsInSegment[nextIdx] + return prev.globalIndex + (next.globalIndex - prev.globalIndex) / 2 +} From 34d85d90ab2f5a5c415c904321d8a3248e1c6856 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 007/136] 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 972c78736b3b3d1b3ec0db87469856d7dbf48f83 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 008/136] 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 5e79da58a1645a3f22b19f1071d6bd2ed0d0e214 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 17:28:22 +0000 Subject: [PATCH 009/136] 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 12df6df0ca0d0034be7b78e6273bf5be83ca3196 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 010/136] 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 35121b538649b1bfab9a8cda9c85c4d0b2c03b62 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 011/136] 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 | 366 +++++++++--------- .../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, 1065 insertions(+), 210 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 a679007484..01f9b75c78 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -292,7 +292,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 c0d2b8ae83..3cce735080 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { 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 { 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 59f43cf757..ccaa737756 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -24,7 +24,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 cd23aaea9a..5ebb40591f 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 ef63bf9a3f0158f68ab23b4196d8bca045e28929 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 012/136] New top bar UI - WIP --- .../client/ui/ClockView/CameraScreen/Part.tsx | 2 +- .../DirectorScreen/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/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx index f8638b616f..6465595d7d 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx @@ -38,7 +38,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 { AdjustLabelFit } from '../../util/AdjustLabelFit.js' import { AutoNextStatus } from '../../RundownView/RundownTiming/AutoNextStatus.js' diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 95b09a26b0..8e9dad2dd0 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -52,7 +52,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 3cce735080..ccf7756bd3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -5,25 +5,21 @@ import { NavLink } from 'react-router-dom' 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 { 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 4adc96a346..d851610ec7 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 132266951d28043b6542b1f82cd7d9fce4419b2f 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 013/136] 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 ccf7756bd3..e5348473a4 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 fbd7c4ea3a85c5918f62a6925f157f3bc84d2e9d Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 26 Feb 2026 11:53:36 +0100 Subject: [PATCH 014/136] 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 a24d144e56b7590ffc07c9f7f35ed7d5b5d59da6 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 26 Feb 2026 13:15:55 +0100 Subject: [PATCH 015/136] 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 b1bac188c77861c8b1a7bc47ce62e419096de899 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 10:45:38 +0100 Subject: [PATCH 016/136] 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 527ad66f4d30bccfb869a7e0b5e825dfbeb2e7ed Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 12:34:39 +0100 Subject: [PATCH 017/136] 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 e846efea94a530ce7d7c0da9daec31bee97c077e Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 27 Feb 2026 12:41:00 +0100 Subject: [PATCH 018/136] 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 ad209e7788cc15f81e6e6e4da3d98cc1f21f9e39 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 019/136] 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 e5348473a4..3cd3e59270 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 { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import ClassNames from 'classnames' @@ -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 c55f73bf0f1ae6635a2387b822d4f8984056a17d 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 020/136] 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 3cd3e59270..18aac1abfd 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 8b4d8a560b3fcf5ffbd7431ea7a1b4d21bda3a4e 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 021/136] 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 e6fb449bdd3ddfcc2ca9247b902f20c2be487f99 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 022/136] 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 d6bd52dc58c5b4e80412f9038425519c1e51590d Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 13 Jan 2026 16:53:31 +0000 Subject: [PATCH 023/136] Update yarn.lock --- meteor/yarn.lock | 56 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index bfce2542a3..2f038e8f42 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -12,6 +12,17 @@ __metadata: languageName: node linkType: hard +"@acuminous/bitsyntax@npm:^0.1.2": + version: 0.1.2 + resolution: "@acuminous/bitsyntax@npm:0.1.2" + dependencies: + buffer-more-ints: "npm:~1.0.0" + debug: "npm:^4.3.4" + safe-buffer: "npm:~5.1.2" + checksum: 10/abdc4313ae08e52fb8eeaebf53759c3b9a38983a696d77c46c24de1c065247355a1b5c02ad3618700d3fb3628ccf3ec39227a080bd1fe7adc864144ccf84b0cc + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -1346,6 +1357,7 @@ __metadata: "@sofie-automation/blueprints-integration": "npm:26.3.0-2" "@sofie-automation/corelib": "npm:26.3.0-2" "@sofie-automation/shared-lib": "npm:26.3.0-2" + amqplib: "npm:0.10.5" chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.15.0" @@ -2228,6 +2240,17 @@ __metadata: languageName: node linkType: hard +"amqplib@npm:0.10.5": + version: 0.10.5 + resolution: "amqplib@npm:0.10.5" + dependencies: + "@acuminous/bitsyntax": "npm:^0.1.2" + buffer-more-ints: "npm:~1.0.0" + url-parse: "npm:~1.5.10" + checksum: 10/bcf4bda790f8a356ba4c7d3054ae3ee397a48d6c4d51f1015f703dd7205c097ba9772577567a06eb470d13e0becdc4163c857299e50eb5a4bc888e3007832f87 + languageName: node + linkType: hard + "ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" @@ -2875,6 +2898,13 @@ __metadata: languageName: node linkType: hard +"buffer-more-ints@npm:~1.0.0": + version: 1.0.0 + resolution: "buffer-more-ints@npm:1.0.0" + checksum: 10/603a7f35793426c8efd733eb716c2c3bf3e2f5bab95ca13ba31546d89ead3636586479c5a0d8438dd015115361a3b09b1b37ddabc170b6d42bc6c6dc2554dc61 + languageName: node + linkType: hard + "buffer-xor@npm:^1.0.3": version: 1.0.3 resolution: "buffer-xor@npm:1.0.3" @@ -8534,6 +8564,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10/46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -8831,6 +8868,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10/878880ee78ccdce372784f62f52a272048e2d0827c29ae31e7f99da18b62a2b9463ea03a75f277352f4697c100183debb0532371ad515a2d49d4bfe596dd4c20 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -8958,7 +9002,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": +"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1, safe-buffer@npm:~5.1.2": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" checksum: 10/7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a @@ -10392,6 +10436,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:~1.5.10": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10/c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad + languageName: node + linkType: hard + "url@npm:^0.11.4": version: 0.11.4 resolution: "url@npm:0.11.4" From 0dd04eee32b917947e6a32fe05f91a07a174edce Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 13:11:01 +0000 Subject: [PATCH 024/136] Tidy migration Remove unneccesary cast to any --- meteor/server/migration/X_X_X.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 5940d5acbe..31712163a5 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -69,7 +69,7 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ }, migrate: async () => { await RundownPlaylists.mutableCollection.updateAsync( - { tTimers: { $exists: false } } as any, + { tTimers: { $exists: false } }, { $set: { tTimers: [ From 8c171635e9594175c85a796ee749e26d3e8a2cb1 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 14 Jan 2026 14:27:48 +0000 Subject: [PATCH 025/136] Change path of import --- .../src/blueprints/context/services/TTimersService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b1eeafd49c..1344b6f976 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -15,7 +15,7 @@ import { resumeTTimer, validateTTimerIndex, } from '../../../playout/tTimers.js' -import { getCurrentTime } from '../../../lib/time.js' +import { getCurrentTime } from '../../../lib/index.js' export class TTimersService { readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] From 3470bcbac14d7c3339c4e51a9953a3131f9be4f8 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 16:32:24 +0000 Subject: [PATCH 026/136] Update yarn.lock --- packages/yarn.lock | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/yarn.lock b/packages/yarn.lock index 4ad64d34d4..6d704d11fb 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -5714,7 +5714,7 @@ __metadata: languageName: node linkType: hard -"@npmcli/package-json@npm:7.0.2, @npmcli/package-json@npm:^7.0.0": +"@npmcli/package-json@npm:7.0.2": version: 7.0.2 resolution: "@npmcli/package-json@npm:7.0.2" dependencies: @@ -5753,6 +5753,21 @@ __metadata: languageName: node linkType: hard +"@npmcli/package-json@npm:^7.0.0": + version: 7.0.1 + resolution: "@npmcli/package-json@npm:7.0.1" + dependencies: + "@npmcli/git": "npm:^7.0.0" + glob: "npm:^11.0.3" + hosted-git-info: "npm:^9.0.0" + json-parse-even-better-errors: "npm:^4.0.0" + proc-log: "npm:^5.0.0" + semver: "npm:^7.5.3" + validate-npm-package-license: "npm:^3.0.4" + checksum: 10/be69096e889ebd3b832de24c56be17784ba00529af5f16d8092c0e911ac29acaf18ba86792e791a15f0681366ffd923a696b0b0f3840b1e68407909273c23e3e + languageName: node + linkType: hard + "@npmcli/promise-spawn@npm:^3.0.0": version: 3.0.0 resolution: "@npmcli/promise-spawn@npm:3.0.0" @@ -22502,7 +22517,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"npm-packlist@npm:10.0.3, npm-packlist@npm:^10.0.1": +"npm-packlist@npm:10.0.3": version: 10.0.3 resolution: "npm-packlist@npm:10.0.3" dependencies: @@ -22512,6 +22527,16 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"npm-packlist@npm:^10.0.1": + version: 10.0.2 + resolution: "npm-packlist@npm:10.0.2" + dependencies: + ignore-walk: "npm:^8.0.0" + proc-log: "npm:^5.0.0" + checksum: 10/ff5a819ccfa6139eab2d1cee732cecec9b2eade0a82134ee89648b2a2ac0815c56fbd6117f2048d46ed48dcee83ec1f709ee9acbffdef1da48be99a681253b79 + languageName: node + linkType: hard + "npm-packlist@npm:^5.1.0": version: 5.1.3 resolution: "npm-packlist@npm:5.1.3" From 681e1d01b41f934b713be7cc60b88a0b6b2eb37d Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 17:02:29 +0000 Subject: [PATCH 027/136] lockfile --- meteor/yarn.lock | 75 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 2f038e8f42..d44156de6c 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -2189,7 +2189,16 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.4, acorn@npm:^8.14.0, acorn@npm:^8.15.0": +"acorn@npm:^8.0.4, acorn@npm:^8.14.0": + version: 8.14.0 + resolution: "acorn@npm:8.14.0" + bin: + acorn: bin/acorn + checksum: 10/6df29c35556782ca9e632db461a7f97947772c6c1d5438a81f0c873a3da3a792487e83e404d1c6c25f70513e91aa18745f6eafb1fcc3a43ecd1920b21dd173d2 + languageName: node + linkType: hard + +"acorn@npm:^8.15.0": version: 8.15.0 resolution: "acorn@npm:8.15.0" bin: @@ -2984,7 +2993,7 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": +"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" dependencies: @@ -2994,6 +3003,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "call-bind-apply-helpers@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10/6e30c621170e45f1fd6735e84d02ee8e02a3ab95cb109499d5308cbe5d1e84d0cd0e10b48cc43c76aa61450ae1b03a7f89c37c10fc0de8d4998b42aab0f268cc + languageName: node + linkType: hard + "call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" @@ -6011,7 +6030,16 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.10, is-typed-array@npm:^1.1.12, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.3, is-typed-array@npm:^1.1.9": +"is-typed-array@npm:^1.1.10, is-typed-array@npm:^1.1.12, is-typed-array@npm:^1.1.3, is-typed-array@npm:^1.1.9": + version: 1.1.12 + resolution: "is-typed-array@npm:1.1.12" + dependencies: + which-typed-array: "npm:^1.1.11" + checksum: 10/d953adfd3c41618d5e01b2a10f21817e4cdc9572772fa17211100aebb3811b6e3c2e308a0558cc87d218a30504cb90154b833013437776551bfb70606fb088ca + languageName: node + linkType: hard + +"is-typed-array@npm:^1.1.14": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -6783,8 +6811,8 @@ __metadata: linkType: hard "koa@npm:^2.13.4": - version: 2.16.3 - resolution: "koa@npm:2.16.3" + version: 2.15.3 + resolution: "koa@npm:2.15.3" dependencies: accepts: "npm:^1.3.5" cache-content-type: "npm:^1.0.0" @@ -6809,7 +6837,7 @@ __metadata: statuses: "npm:^1.5.0" type-is: "npm:^1.6.16" vary: "npm:^1.1.2" - checksum: 10/62b6bc4939003eab2b77d523207e252f4eed3f75471fce3b50fe46a80fb01b9f425d4094437f25e3579ad90bcf43b652c166ac5b58d277255ed82a0ea7069ac8 + checksum: 10/b2c2771a4ee5268f9d039ce025b9c3798a0baba8c3cf3895a6fc2d286363e0cd2c98c02a5b87f14100baa2bc17d854eed6ed80f9bd41afda1d056f803b206514 languageName: node linkType: hard @@ -6992,9 +7020,9 @@ __metadata: linkType: hard "lodash@npm:^4.0.0": - version: 4.17.23 - resolution: "lodash@npm:4.17.23" - checksum: 10/82504c88250f58da7a5a4289f57a4f759c44946c005dd232821c7688b5fcfbf4a6268f6a6cdde4b792c91edd2f3b5398c1d2a0998274432cff76def48735e233 + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 languageName: node linkType: hard @@ -9130,7 +9158,19 @@ __metadata: languageName: node linkType: hard -"sha.js@npm:^2.4.0, sha.js@npm:^2.4.12, sha.js@npm:^2.4.8": +"sha.js@npm:^2.4.0, sha.js@npm:^2.4.8": + version: 2.4.11 + resolution: "sha.js@npm:2.4.11" + dependencies: + inherits: "npm:^2.0.1" + safe-buffer: "npm:^5.0.1" + bin: + sha.js: ./bin.js + checksum: 10/d833bfa3e0a67579a6ce6e1bc95571f05246e0a441dd8c76e3057972f2a3e098465687a4369b07e83a0375a88703577f71b5b2e966809e67ebc340dbedb478c7 + languageName: node + linkType: hard + +"sha.js@npm:^2.4.12": version: 2.4.12 resolution: "sha.js@npm:2.4.12" dependencies: @@ -10641,7 +10681,20 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.11, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.11, which-typed-array@npm:^1.1.2": + version: 1.1.11 + resolution: "which-typed-array@npm:1.1.11" + dependencies: + available-typed-arrays: "npm:^1.0.5" + call-bind: "npm:^1.0.2" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.0" + checksum: 10/bc9e8690e71d6c64893c9d88a7daca33af45918861003013faf77574a6a49cc6194d32ca7826e90de341d2f9ef3ac9e3acbe332a8ae73cadf07f59b9c6c6ecad + languageName: node + linkType: hard + +"which-typed-array@npm:^1.1.16": version: 1.1.20 resolution: "which-typed-array@npm:1.1.20" dependencies: From 3c16f7398ef63428d17bf660b6e301ce235e85b8 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 028/136] SOFIE-261 | add UI for t-timers (WIP) --- .../RundownHeader/RundownHeaderTimers.tsx | 31 ++++++++++++------- .../RundownHeader_old/TimingDisplay.tsx | 1 + 2 files changed, 21 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 77e4606263..d8c8a771a2 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -32,8 +32,10 @@ interface ISingleTimerProps { timer: RundownTTimer } -function SingleTimer({ timer }: ISingleTimerProps) { +function SingleTimer({ timer }: Readonly) { const now = getCurrentTime() + const mode = timer.mode + if (!mode) return null const isRunning = !!timer.state && !timer.state.paused @@ -49,19 +51,24 @@ function SingleTimer({ timer }: ISingleTimerProps) { {timerSign} - {parts.map((p, i) => ( - + {(() => { + let cursor = 0 + return parts.map((p, i) => { + const key = `${timer.index}-${cursor}-${p}` + cursor += p.length + 1 + return ( + {i < parts.length - 1 && :} - - ))} + + ) + }) + })()} ) } function calculateDiff(timer: RundownTTimer, now: number): number { - if (!timer.state || timer.state.paused === undefined) { + if (!timer.state) { return 0 } 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 809c544fff..37d13348f3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx @@ -51,6 +51,7 @@ export function TimingDisplay({
+
From d200da61be40978447109b603d22058f98b46f56 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 029/136] SOFIE-261 | change alignment of t-timers in rundown screen --- .../ui/RundownView/RundownHeader/RundownHeaderTimers.tsx | 5 +---- .../ui/RundownView/RundownHeader_old/TimingDisplay.tsx | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index d8c8a771a2..cf741713b7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -13,11 +13,8 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - if (!tTimers?.length) { - return null - } - const activeTimers = tTimers.filter((t) => t.mode) + if (activeTimers.length == 0) return null return (
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 37d13348f3..809c544fff 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx @@ -51,7 +51,6 @@ export function TimingDisplay({
-
From eb6ee1367cf5fec4102935c8584c22cc7fcf676d 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 030/136] 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 ++++ .../DirectorScreen/DirectorScreen.tsx | 9 ++ .../client/ui/ClockView/PresenterScreen.tsx | 6 ++ .../src/client/ui/ClockView/TTimerDisplay.tsx | 55 ++++++++++++ .../RundownHeader/RundownHeaderTimers.tsx | 26 +----- 9 files changed, 326 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 b3b3d2c943..d363480bd4 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 a053bc4586..0c034624ee 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -480,5 +480,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 f7c8db52fd..8ddd23a2c8 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/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx index 6465595d7d..5b9c2c3f97 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx @@ -44,6 +44,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' import { DirectorScreenTop } from './DirectorScreenTop.js' import { useTiming } from '../../RundownView/RundownTiming/withTiming.js' @@ -564,6 +566,8 @@ function DirectorScreenRender({ } } + const activeTTimer = getDefaultTTimer(playlist.tTimers) + return (
@@ -749,6 +753,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 8e9dad2dd0..f0cb0a64e1 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -53,6 +53,8 @@ import { useSetDocumentClass, useSetDocumentDarkTheme } from '../util/useSetDocu import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist.js' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil.js' import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownHeader/CurrentPartOrSegmentRemaining.js' +import { TTimerDisplay } from './TTimerDisplay.js' +import { getDefaultTTimer } from '../../lib/tTimerUtils.js' interface SegmentUi extends DBSegment { items: Array @@ -488,6 +490,7 @@ function PresenterScreenContentDefaultLayout({ const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 + const activeTTimer = getDefaultTTimer(playlist.tTimers) return (
@@ -593,6 +596,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 cf741713b7..ae6d062f71 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 } from '../../../lib/tTimerUtils' import classNames from 'classnames' import { getCurrentTime } from '../../../lib/systemTime' import { Countdown } from './Countdown' @@ -33,15 +34,13 @@ function SingleTimer({ timer }: Readonly) { const now = getCurrentTime() const mode = timer.mode if (!mode) return null - 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 isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning return ( @@ -81,24 +80,3 @@ function SingleTimer({ timer }: Readonly) { ) } - -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 df7224ddccf1f336d3917e0d258b75986561dab2 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 031/136] New top bar UI: visual tweaks --- .../RundownView/RundownHeader/Countdown.scss | 23 ++++++- .../RundownHeader/RundownHeader.scss | 69 +++++++++---------- .../RundownHeader/RundownHeader.tsx | 13 ++-- .../RundownHeader/RundownHeaderTimers.tsx | 4 +- .../RundownView/RundownTiming/TimeOfDay.tsx | 6 +- 5 files changed, 71 insertions(+), 44 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 f04e5eea11..ec5a46ba94 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; @@ -313,23 +328,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 61b83bb20b..51726e2491 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 ae6d062f71..d631f7592a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -41,12 +41,12 @@ function SingleTimer({ timer }: Readonly) { const parts = timeStr.split(':') const timerSign = diff >= 0 ? '+' : '-' - const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning + const isCountingDown = mode.type === 'countdown' && diff < 0 && isRunning return ( ): JSX.Element { const timingDurations = useTiming() return ( - + + + ) } From 50728710490358d1d66caf0e977b631f99eebd8c 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 032/136] 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 ec5a46ba94..9e133e0a64 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; @@ -272,25 +274,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 f2f55b90119d478fe6b10a83589dd2b2a3771f1f Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 16:38:10 +0100 Subject: [PATCH 033/136] 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 9e133e0a64..a87bc7c521 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -30,6 +30,7 @@ &.active { background: $color-header-on-air; + border-bottom: 1px solid #256b91; .rundown-header__timers-segment-remaining, .rundown-header__timers-onair-remaining, @@ -115,6 +116,7 @@ } .rundown-header__clocks-playlist-name { + //@extend .rundown-header__hoverable-label; font-size: 0.65em; display: flex; flex-direction: row; @@ -377,6 +379,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 0f5fb9e99303319f9359251085fb6f7e5bbe9475 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 17:03:06 +0100 Subject: [PATCH 034/136] chore: Created two distinct styles for two types of counters. --- .../RundownView/RundownHeader/Countdown.scss | 5 +- .../RundownView/RundownHeader/Countdown.tsx | 4 +- .../RundownHeader/RundownHeader.scss | 64 ++++++++++++++++++- 3 files changed, 67 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 b51a201464..8fdcfbd41f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -15,9 +15,10 @@ top: -0.6em; /* Visually push the label up to align with cap height */ } - &__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 a87bc7c521..78ce5f0797 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -79,8 +79,7 @@ .timing-clock { color: #40b8fa; font-size: 1.4em; - - letter-spacing: 0 em; + letter-spacing: 0em; transition: color 0.2s; &.time-now { @@ -228,6 +227,15 @@ white-space: nowrap; line-height: 1.25; + .countdown__label { + @extend .rundown-header__hoverable-label; + font-size: 0.65em; + } + + .countdown__counter, + .countdown__timeofday { + color: #fff; + } .rundown-header__clocks-timers__timer__sign { display: inline-block; width: 0.6em; @@ -314,7 +322,57 @@ } .rundown-header__show-timers-countdown { - @extend .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__counter { + margin-left: auto; + 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; + } } .rundown-header__timers-onair-remaining__label { From aa27dd42892f7bf03432100de46f0649f1495878 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 3 Mar 2026 19:58:21 +0100 Subject: [PATCH 035/136] 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 78ce5f0797..9d6f845782 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -162,6 +162,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, @@ -176,13 +177,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, @@ -197,12 +198,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; } } @@ -338,11 +338,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, @@ -357,13 +357,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 b04456c3f1d7a82354f2f8ad0f1049f59404955f 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 036/136] Top bar UI: fix clocks alignment --- .../RundownHeader/RundownHeader.scss | 88 +++++++++++-------- .../RundownHeader/RundownHeader.tsx | 2 +- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 9d6f845782..7289ec63e1 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,46 +213,66 @@ } } } + } - .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, - .countdown__timeofday { - 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; + .countdown__counter { + color: #fff; + } + + .countdown__timeofday { + 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__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__part { - color: #fff; - &.rundown-header__clocks-timers__timer__part--dimmed { - color: #888; - font-weight: 400; - } + .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__separator { - margin: 0 0.05em; - color: #888; + + &.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 51726e2491..ba0b622f6c 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 b373609515449ec44138260fbfa029ea9ce11ec6 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Wed, 4 Mar 2026 09:31:03 +0100 Subject: [PATCH 037/136] 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 7289ec63e1..ce45c68f2b 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 97398f578f73d9fa532e66266e3fdd5eb6aaad4d 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 038/136] 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 ce45c68f2b..067881b1c3 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; @@ -477,8 +486,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 ba0b622f6c..8d7d79d643 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 d631f7592a..d673aaf3d1 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -46,7 +46,9 @@ function SingleTimer({ timer }: Readonly) { return ( Date: Wed, 4 Mar 2026 18:19:34 +0100 Subject: [PATCH 039/136] 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 067881b1c3..021ba7833d 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; } @@ -359,15 +359,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 { @@ -472,7 +476,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 8d7d79d643..0f67e5980b 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 d26388af8440d96fcfb2e09f1640dfecfaf34aaf Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 09:16:38 +0100 Subject: [PATCH 040/136] 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 021ba7833d..aa310295dc 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 044002b488a97412bce2d2145505f0147cb73107 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 041/136] 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 d673aaf3d1..62e09e84ef 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -14,7 +14,7 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - const activeTimers = tTimers.filter((t) => t.mode) + const activeTimers = tTimers.filter((t) => t.mode).slice(0, 2) if (activeTimers.length == 0) return null return ( From 7622896378b02b592ac723a2453f367cf00c0fe4 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 042/136] Top bar UI: unify over/under in t-timers --- .../RundownHeader/RundownHeader.scss | 233 +++++++++--------- .../RundownHeader/RundownHeaderTimers.tsx | 14 +- 2 files changed, 135 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 aa310295dc..c27e5c86d5 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; } } } @@ -319,18 +319,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 62e09e84ef..fe0db85834 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -2,7 +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 } from '../../../lib/tTimerUtils' +import { calculateTTimerDiff, calculateTTimerOverUnder } from '../../../lib/tTimerUtils' import classNames from 'classnames' import { getCurrentTime } from '../../../lib/systemTime' import { Countdown } from './Countdown' @@ -37,6 +37,7 @@ function SingleTimer({ timer }: Readonly) { const isRunning = !!timer.state && !timer.state.paused const diff = calculateTTimerDiff(timer, now) + const overUnder = calculateTTimerOverUnder(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) const parts = timeStr.split(':') @@ -79,6 +80,17 @@ function SingleTimer({ timer }: Readonly) { ) }) })()} + {!!overUnder && ( + 0, + 'rundown-header__clocks-timers__timer__over-under--under': overUnder < 0, + })} + > + {overUnder > 0 ? '+' : '-'} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + + )} ) } From df6f5c155a0c8977e26ebe9eb53fd45ec7b1ab0e Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 10:44:55 +0100 Subject: [PATCH 043/136] 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 4d3fce9256b24ac1571f42f49ea6630d008a7b19 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 044/136] Top bar UI: change layout of t-timers to grid --- .../RundownHeader/RundownHeader.scss | 23 +++++++++++++++---- .../RundownHeader/RundownHeaderTimers.tsx | 4 +++- 2 files changed, 22 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 c27e5c86d5..6c681fd595 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; } .countdown__timeofday { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index fe0db85834..06ab3c8066 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -20,7 +20,9 @@ export const RundownHeaderTimers: React.FC = ({ tTimers }) => { return (
{activeTimers.map((timer) => ( - +
+ +
))}
) From c8183edffff77891931062b1003fa06524e3a896 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 045/136] 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 | 26 +++++++--- 6 files changed, 95 insertions(+), 34 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 6c681fd595..fda0fb49ef 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -324,13 +324,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 06ab3c8066..45340b13ff 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -68,16 +68,26 @@ function SingleTimer({ timer }: Readonly) { return parts.map((p, i) => { const key = `${timer.index}-${cursor}-${p}` cursor += p.length + 1 + const isDimmed = mode.type !== 'timeOfDay' && Math.abs(diff) < [3600000, 60000, 1][i] + return ( - - {p} - - {i < parts.length - 1 && :} + + {p} + + {i < parts.length - 1 && ( + + : + + )} ) }) From 44ee15de19477f3d6087d66f51fd2b329bdb3368 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 14:25:09 +0100 Subject: [PATCH 046/136] 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 fda0fb49ef..a86cdbd0ea 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -318,7 +318,7 @@ text-align: center; font-size: 1.1em; color: #fff; - margin-right: 0.3em; + margin-right: 0.0em; } .rundown-header__clocks-timers__timer__part { @@ -328,7 +328,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; @@ -422,10 +422,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 d6467739ad4cc316ecbed638cfb9b4c0ecdf9db2 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 047/136] Top bar UI: css tweaks --- .../RundownView/RundownHeader/Countdown.scss | 6 +- .../RundownView/RundownHeader/Countdown.tsx | 14 +++-- .../RundownHeader/RundownHeader.scss | 25 +++----- .../RundownHeader/RundownHeaderTimers.tsx | 59 +++++-------------- 4 files changed, 36 insertions(+), 68 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 a86cdbd0ea..15cfaa607a 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; } @@ -318,21 +315,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 { @@ -396,7 +387,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 45340b13ff..bd146e1b37 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -41,9 +41,6 @@ function SingleTimer({ timer }: Readonly) { const diff = calculateTTimerDiff(timer, now) const overUnder = calculateTTimerOverUnder(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) - const parts = timeStr.split(':') - - const timerSign = diff >= 0 ? '+' : '-' const isCountingDown = mode.type === 'countdown' && diff < 0 && isRunning return ( @@ -61,48 +58,22 @@ function SingleTimer({ timer }: Readonly) { 'rundown-header__clocks-timers__timer__isComplete': mode.type === 'countdown' && timer.state !== null && diff <= 0, })} + ms={mode.type === 'timeOfDay' ? undefined : diff} + 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} - {(() => { - let cursor = 0 - return parts.map((p, i) => { - const key = `${timer.index}-${cursor}-${p}` - cursor += p.length + 1 - const isDimmed = 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 0e674ca5b637a1651e114415aed18c0ee3fc2817 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 048/136] 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 bd146e1b37..ba36951c87 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -68,7 +68,7 @@ function SingleTimer({ timer }: Readonly) { })} > {overUnder > 0 ? '+' : '−'} - {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, true, true, false, true)} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, false, true, false, true)}
) : undefined } From 1b9f0e19c7095c50f6ae5a019560996fc6545857 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 049/136] 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 15cfaa607a..97f3e375a0 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -388,11 +388,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 { @@ -475,7 +478,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 8f33ced090e2586fa4933e4539b36988e4cfe198 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 5 Mar 2026 16:04:24 +0100 Subject: [PATCH 050/136] 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 ae61fc192db64ba3791838ab0bcc9a3b5530e0f6 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 051/136] 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 428c63f038..a273b072ed 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -167,12 +167,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 97f3e375a0..593065c66c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -395,10 +395,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 0f67e5980b..f7878f2371 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 { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyle import Navbar from 'react-bootstrap/Navbar' import { RundownContextMenu, RundownHeaderContextMenuTrigger, RundownHamburgerButton } from './RundownContextMenu' 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 052/136] 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 593065c66c..08f3d24888 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -486,6 +486,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 c9fcb7dc7e8c842ffe65efa1d6ba5e961346b804 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:09:43 +0100 Subject: [PATCH 053/136] 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 08f3d24888..977048cd62 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; } @@ -328,19 +328,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 0221812e8442a5a7f7cebe9ec1f93d843531b0e2 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:28:10 +0100 Subject: [PATCH 054/136] 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 977048cd62..31e02e0aee 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -431,6 +431,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; @@ -558,5 +573,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 f7878f2371..e95cdb9c3e 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 917aacc54970fbd7dd440fce9100f2e7d96b18f1 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 6 Mar 2026 16:36:21 +0100 Subject: [PATCH 055/136] 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 31e02e0aee..ee26862bfb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -404,7 +404,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 6837cdc839e8cd534b028be1c54ed135658879c2 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 15:26:10 +0000 Subject: [PATCH 056/136] 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 d363480bd4..b6863b5375 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 c07b4e7f30f2fde278b1926638b9f702bc8cb5ce Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 13:19:36 +0000 Subject: [PATCH 057/136] 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 36c662bed112cf6d061588c8a491262fd2d7ff58 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:45:27 +0000 Subject: [PATCH 058/136] 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 de354d3202..bcbde0b94a 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 @@ -417,6 +423,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 f5b7f0a953..93e1f4d5a2 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 ebe5cdf675..172497909a 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -36,6 +36,7 @@ import { PersistentPlayoutStateStore } from '../blueprints/context/services/Pers import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { PlayoutPartInstanceModelImpl } from './model/implementation/PlayoutPartInstanceModelImpl.js' import { QuickLoopService } from './model/services/QuickLoopService.js' +import { recalculateTTimerEstimates } from './tTimers.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -99,6 +100,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 de867b1b261d331f6fdeeebf6b6d2fc4bbb4c3ce Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:46:30 +0000 Subject: [PATCH 059/136] 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 f6150917ee42ccef7d60d595a6ff5f8dd6b313bb Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:47:51 +0000 Subject: [PATCH 060/136] 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 c0dfed64f6b71b1b337d303a5d58b6172f556c61 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 16:10:48 +0000 Subject: [PATCH 061/136] 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 | 91 ++++++++- .../services/__tests__/TTimersService.test.ts | 188 ++++++++++++------ .../src/ingest/syncChangesToPartInstance.ts | 1 + 9 files changed, 263 insertions(+), 76 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 2be2d1b7f5..1d168e84f8 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -49,7 +49,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 82690fa748..e028b31f1d 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 9f6418b3a5..5335d041bc 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 6251a791d5..80b4b31244 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 1344b6f976..9d92ff4886 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -2,8 +2,10 @@ import type { IPlaylistTTimer, 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 { RundownTTimer, RundownTTimerIndex,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 +16,36 @@ import { restartTTimer, resumeTTimer, validateTTimerIndex, + recalculateTTimerEstimates, } from '../../../playout/tTimers.js' import { getCurrentTime } from '../../../lib/index.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 +61,8 @@ export class TTimersService { export class PlaylistTTimerImpl implements IPlaylistTTimer { readonly #emitChange: (updatedTimer: ReadonlyDeep) => void + readonly #playoutModel: PlayoutModel + readonly #jobContext: JobContext #timer: ReadonlyDeep @@ -96,9 +109,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 +190,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 5e26041c9f4e63a25b09758759b0aec03726f44f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:02 +0000 Subject: [PATCH 062/136] 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 69657d1dbed29a0f8fd5fcb09072276c163d42fd Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:19 +0000 Subject: [PATCH 063/136] 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 477b3787355c528ccc7c36a44123e997586bd4a6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 22:49:19 +0000 Subject: [PATCH 064/136] 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 0baeaa8fdebe9532e087deaa27605aef2f800cca Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 5 Feb 2026 12:05:45 +0000 Subject: [PATCH 065/136] 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 8f5a103509eb5a40ab728bab2fc96240ec29ea80 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 20 Feb 2026 10:51:13 +0000 Subject: [PATCH 066/136] 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 3c21f9d0084c95b56f87a787841c3cade444be79 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 25 Feb 2026 10:37:56 +0000 Subject: [PATCH 067/136] 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 9d92ff4886..5f79b7417f 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -5,7 +5,7 @@ import type { import type { RundownTTimer, RundownTTimerIndex,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 { @@ -212,6 +212,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 060ef95bb0b695d92d0e654fb838763f52b58182 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 18 Feb 2026 17:01:13 +0000 Subject: [PATCH 068/136] 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 b6863b5375..0f84e83aa1 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 22abc9787beafec5e6a5ae78c2787faeff3ce78b Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:21:56 +0000 Subject: [PATCH 069/136] 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 825e3673733302d5b8e85e4f7c5b12236a54f263 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:23:33 +0000 Subject: [PATCH 070/136] 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 0f84e83aa1..6bc6bfa843 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 9a60bdb3c80748aa97c4bb744c6d8fd206a6bf1a Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 13:20:36 +0000 Subject: [PATCH 071/136] 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 6bc6bfa843..e3eb8fae8c 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 05ee3bf7d8b2734a2da0b1d18ae706915aa8f103 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:29:35 +0000 Subject: [PATCH 072/136] 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 2914087e7e938ba6309e2043256e3a7659eb3e4f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:55:45 +0000 Subject: [PATCH 073/136] 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 0ac6ff169799fc619f77b625de8fdf821da26ee7 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:42:53 +0000 Subject: [PATCH 074/136] 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 a20cc05f0d9f82962c97dbdaaef03af120a046fb Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:43:25 +0000 Subject: [PATCH 075/136] 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 172497909a..5757494945 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -533,6 +533,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 03d2937943efdc0b3185d4a3e6b551dbd14dc93c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 9 Mar 2026 17:55:44 +0000 Subject: [PATCH 076/136] 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( From db1ba21f75127f0e3351c7dc75a1c307e3e0adb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 12:14:18 +0100 Subject: [PATCH 077/136] chore: changes wording for Rem. Dur (was Est. Dur) --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 2018192cd9..c543acea2e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -47,7 +47,7 @@ export function RundownHeaderDurations({ ) : null} {!simplified && estDuration != null ? ( - + {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} ) : null} From 7014cd4dd18a8c40bfcb8a1715545316cd382b79 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Wed, 11 Mar 2026 15:32:13 +0100 Subject: [PATCH 078/136] chore: Added missing font variant variable. --- .../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 ee26862bfb..ebf6ce9fff 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -341,6 +341,7 @@ 'wdth' 25, 'wght' 600, 'slnt' 0, + 'GRAD' 0, 'opsz' 14, 'XOPQ' 96, 'XTRA' 468, From 759b77f5e8b2a978a7608214fd2f3d59b22225c2 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 11:26:42 +0100 Subject: [PATCH 079/136] chore: Removed unused style for timeOfDay T-timer counters, as T-timers are never shown as time-of-day. --- .../ui/RundownView/RundownHeader/RundownHeader.scss | 8 -------- 1 file changed, 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 ebf6ce9fff..493b8c50f7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -282,14 +282,6 @@ white-space: nowrap; line-height: 1.25; - &.countdown--timeofday { - .countdown__digit, - .countdown__sep { - font-style: italic; - font-weight: 300; - color: #40b8fa; - } - } .countdown__label { @extend .rundown-header__hoverable-label; margin-left: 0; From 0c432a2bf2b54d7a47e425ac0a73f1edf7d14ae8 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 11:36:06 +0100 Subject: [PATCH 080/136] chore: Show only the playlist name if the playlist contains more than one rundown, otherwise show only the rundown name. --- .../client/ui/RundownView/RundownHeader/RundownHeader.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index e95cdb9c3e..52dd6add17 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -90,8 +90,11 @@ export function RundownHeader({
- {(currentRundown ?? firstRundown)?.name} - {rundownCount > 1 && {playlist.name}} + {rundownCount > 1 ? ( + {playlist.name} + ) : ( + {(currentRundown ?? firstRundown)?.name} + )}
From f61cf24ef898eb6a2edf4739f78ccbe1343c450c Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 12:18:12 +0100 Subject: [PATCH 081/136] chore: Tweaked width of time of day counters so that their total width matches the width of a counter with proceeding - sign. --- .../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 c4d4ffcd26..d9cace106b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -47,7 +47,7 @@ font-size: 1.3em; letter-spacing: 0.02em; font-variation-settings: - 'wdth' 70, + 'wdth' 85, 'wght' 400, 'slnt' -5, 'GRAD' 0, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 493b8c50f7..7575cd8588 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -84,7 +84,7 @@ &.time-now { font-size: 1.8em; font-variation-settings: - 'wdth' 70, + 'wdth' 85, 'wght' 400, 'slnt' -5, 'GRAD' 0, From 34e1dba9fc2b7b7b7e9540c2709efbcc5b5012f3 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 12:19:07 +0100 Subject: [PATCH 082/136] chore: Added label before the counter to/from "Planned Start" in Detailed Mode of the Top Bar. --- .../ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index f764b037fe..a9ea781de1 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -28,7 +28,7 @@ export function RundownHeaderPlannedStart({ (playlist.startedPlayback ? ( ) : ( - + {diff >= 0 && '-'} {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} From 24d290e11113c623ac443b42e4c220453700f0aa Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 13:31:37 +0100 Subject: [PATCH 083/136] chore: Removed erraneous margin of some of the counter colons. --- .../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 d9cace106b..d0335ff432 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -85,7 +85,7 @@ } &__sep { - margin: 0 0.05em; + margin: 0 0em; &--dimmed { opacity: 0.4; From 5e3f90373977c61e2a57fd69f095181076d572c2 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 15:32:48 +0100 Subject: [PATCH 084/136] chore: Made all timer labels center-aligned. --- .../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 d0335ff432..6fdf4e253b 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.51em; /* Visually push the label up to align with cap height */ + top: -0.3em; /* Visually place the label to vertically align */ margin-left: auto; text-align: right; width: 100%; From 6aca4a33fb3869330f262998393054054dc55879 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 13:09:48 +0000 Subject: [PATCH 085/136] remove RundownHeader_old folder left behind by mistake --- .../RundownHeader_old/RundownHeaderTimers.tsx | 97 --- .../RundownHeader_old/RundownHeader_old.tsx | 235 ------ .../RundownReloadResponse.ts | 177 ----- .../RundownHeader_old/TimingDisplay.tsx | 97 --- .../useRundownPlaylistOperations.tsx | 693 ------------------ 5 files changed, 1299 deletions(-) delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx deleted file mode 100644 index 132963d696..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index e235cb792f..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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_old/RundownReloadResponse.ts b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts deleted file mode 100644 index a858db9caf..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { RundownPlaylists, Rundowns } from '../../../collections' -import { - ReloadRundownPlaylistResponse, - TriggerReloadDataResponse, -} from '@sofie-automation/meteor-lib/dist/api/userActions' -import _ from 'underscore' -import { RundownPlaylistCollectionUtil } from '../../../collections/rundownPlaylistUtil' -import * as i18next from 'i18next' -import { UserPermissions } from '../../UserPermissions' -import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' -import { Tracker } from 'meteor/tracker' -import { doUserAction } from '../../../lib/clientUserAction' -import { MeteorCall } from '../../../lib/meteorApi' -import { doModalDialog } from '../../../lib/ModalDialog' - -export function handleRundownPlaylistReloadResponse( - t: i18next.TFunction, - userPermissions: Readonly, - result: ReloadRundownPlaylistResponse -): boolean { - const rundownsInNeedOfHandling = result.rundownsResponses.filter( - (r) => r.response === TriggerReloadDataResponse.MISSING - ) - const firstRundownId = _.first(rundownsInNeedOfHandling)?.rundownId - let allRundownsAffected = false - - if (firstRundownId) { - const firstRundown = Rundowns.findOne(firstRundownId) - const playlist = RundownPlaylists.findOne(firstRundown?.playlistId) - const allRundownIds = playlist ? RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) : [] - if ( - allRundownIds.length > 0 && - _.difference( - allRundownIds, - rundownsInNeedOfHandling.map((r) => r.rundownId) - ).length === 0 - ) { - allRundownsAffected = true - } - } - - const actionsTaken: RundownReloadResponseUserAction[] = [] - function onActionTaken(action: RundownReloadResponseUserAction): void { - actionsTaken.push(action) - if (actionsTaken.length === rundownsInNeedOfHandling.length) { - // the user has taken action on all of the missing rundowns - if (allRundownsAffected && actionsTaken.filter((actionTaken) => actionTaken !== 'removed').length === 0) { - // all rundowns in the playlist were affected and all of them were removed - // we redirect to the Lobby - window.location.assign('/') - } - } - } - - const handled = rundownsInNeedOfHandling.map((r) => - handleRundownReloadResponse(t, userPermissions, r.rundownId, r.response, onActionTaken) - ) - return handled.reduce((previousValue, value) => previousValue || value, false) -} - -export type RundownReloadResponseUserAction = 'removed' | 'unsynced' | 'error' - -export function handleRundownReloadResponse( - t: i18next.TFunction, - userPermissions: Readonly, - rundownId: RundownId, - result: TriggerReloadDataResponse, - clb?: (action: RundownReloadResponseUserAction) => void -): boolean { - let hasDoneSomething = false - - if (result === TriggerReloadDataResponse.MISSING) { - const rundown = Rundowns.findOne(rundownId) - const playlist = RundownPlaylists.findOne(rundown?.playlistId) - - hasDoneSomething = true - const notification = new Notification( - undefined, - NoticeLevel.CRITICAL, - t( - 'Rundown {{rundownName}} in Playlist {{playlistName}} is missing in the data from {{nrcsName}}. You can either leave it in Sofie and mark it as Unsynced or remove the rundown from Sofie. What do you want to do?', - { - nrcsName: getRundownNrcsName(rundown), - rundownName: rundown?.name || t('(Unknown rundown)'), - playlistName: playlist?.name || t('(Unknown playlist)'), - } - ), - 'userAction', - undefined, - true, - [ - // actions: - { - label: t('Leave Unsynced'), - type: 'default', - disabled: !userPermissions.studio, - action: () => { - doUserAction( - t, - 'Missing rundown action', - UserAction.UNSYNC_RUNDOWN, - async (e, ts) => MeteorCall.userAction.unsyncRundown(e, ts, rundownId), - (err) => { - if (!err) { - notificationHandle.stop() - clb?.('unsynced') - } else { - clb?.('error') - } - } - ) - }, - }, - { - label: t('Remove'), - type: 'default', - action: () => { - doModalDialog({ - title: t('Remove rundown'), - message: t( - 'Do you really want to remove just the rundown "{{rundownName}}" in the playlist {{playlistName}} from Sofie? \n\nThis cannot be undone!', - { - rundownName: rundown?.name || 'N/A', - playlistName: playlist?.name || 'N/A', - } - ), - onAccept: () => { - // nothing - doUserAction( - t, - 'Missing rundown action', - UserAction.REMOVE_RUNDOWN, - async (e, ts) => MeteorCall.userAction.removeRundown(e, ts, rundownId), - (err) => { - if (!err) { - notificationHandle.stop() - clb?.('removed') - } else { - clb?.('error') - } - } - ) - }, - }) - }, - }, - ] - ) - const notificationHandle = NotificationCenter.push(notification) - - if (rundown) { - // This allows the semi-modal dialog above to be closed automatically, once the rundown stops existing - // for whatever reason - const comp = Tracker.autorun(() => { - const rundown = Rundowns.findOne(rundownId, { - fields: { - _id: 1, - orphaned: 1, - }, - }) - // we should hide the message - if (!rundown || !rundown.orphaned) { - notificationHandle.stop() - } - }) - notification.on('dropped', () => { - // clean up the reactive computation above when the notification is closed. Will be also executed by - // the notificationHandle.stop() above, so the Tracker.autorun will clean up after itself as well. - comp.stop() - }) - } - } - return hasDoneSomething -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx deleted file mode 100644 index 809c544fff..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBRundownPlaylist, RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' -import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { useTranslation } from 'react-i18next' -import * as RundownResolver from '../../../lib/RundownResolver' -import { AutoNextStatus } from '../RundownTiming/AutoNextStatus' -import { CurrentPartOrSegmentRemaining } from '../RundownHeader/CurrentPartOrSegmentRemaining' -import { NextBreakTiming } from '../RundownTiming/NextBreakTiming' -import { PlaylistEndTiming } from '../RundownTiming/PlaylistEndTiming' -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 - currentRundown: Rundown | undefined - rundownCount: number - layout: RundownLayoutRundownHeader | undefined -} -export function TimingDisplay({ - rundownPlaylist, - currentRundown, - rundownCount, - layout, -}: ITimingDisplayProps): JSX.Element | null { - const { t } = useTranslation() - - const timingDurations = useTiming() - - if (!rundownPlaylist) return null - - const expectedStart = PlaylistTiming.getExpectedStart(rundownPlaylist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(rundownPlaylist.timing) - const expectedDuration = PlaylistTiming.getExpectedDuration(rundownPlaylist.timing) - const showEndTiming = - !timingDurations.rundownsBeforeNextBreak || - !layout?.showNextBreakTiming || - (timingDurations.rundownsBeforeNextBreak.length > 0 && - (!layout?.hideExpectedEndBeforeBreak || (timingDurations.breakIsLastRundown && layout?.lastRundownIsNotBreak))) - const showNextBreakTiming = - rundownPlaylist.startedPlayback && - timingDurations.rundownsBeforeNextBreak?.length && - layout?.showNextBreakTiming && - !(timingDurations.breakIsLastRundown && layout.lastRundownIsNotBreak) - - return ( -
-
- - -
-
- - -
-
-
- {rundownPlaylist.currentPartInfo && ( - - - - {rundownPlaylist.holdState && rundownPlaylist.holdState !== RundownHoldState.COMPLETE ? ( -
{t('Hold')}
- ) : null} -
- )} -
-
- {showNextBreakTiming ? ( - - ) : null} - {showEndTiming ? ( - - ) : null} -
-
-
- ) -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx deleted file mode 100644 index 8f29d6e7ce..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx +++ /dev/null @@ -1,693 +0,0 @@ -import { SerializedUserError, UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' -import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' -import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' -import { doUserAction } from '../../../lib/clientUserAction' -import { MeteorCall } from '../../../lib/meteorApi' -import { doModalDialog } from '../../../lib/ModalDialog' -import { useTranslation } from 'react-i18next' -import React, { useContext, useEffect, useMemo } from 'react' -import { UserPermissions, UserPermissionsContext } from '../../UserPermissions' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { logger } from '../../../lib/logging' -import * as i18next from 'i18next' -import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' -import { Meteor } from 'meteor/meteor' -import { Tracker } from 'meteor/tracker' -import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' -import { handleRundownPlaylistReloadResponse } from './RundownReloadResponse' -import { scrollToPartInstance } from '../../../lib/viewPort' -import { hashSingleUseToken } from '../../../lib/lib' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' -import { getCurrentTime } from '../../../lib/systemTime' -import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' -import { REHEARSAL_MARGIN } from '../WarningDisplay' -import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' - -class RundownPlaylistOperationsService { - constructor( - public studio: UIStudio, - public playlist: DBRundownPlaylist, - public currentRundown: Rundown | undefined, - public userPermissions: UserPermissions, - public onActivate?: (isRehearsal: boolean) => void - ) {} - - public executeTake(t: i18next.TFunction, e: EventLike): void { - if (!this.userPermissions.studio) return - - if (!this.playlist.activationId) { - const onSuccess = () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const handleResult = (err: any) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - // ask to activate - doModalDialog({ - title: t('Failed to execute take'), - message: t( - 'The rundown you are trying to execute a take on is inactive, would you like to activate this rundown?' - ), - acceptOnly: false, - warning: true, - yes: t('Activate "On Air"'), - no: t('Cancel'), - discardAsPrimary: true, - onDiscard: () => { - // Do nothing - }, - actions: [ - { - label: t('Activate "Rehearsal"'), - classNames: 'btn-secondary', - on: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, this.playlist._id, true), - handleResult - ) - }, - }, - ], - onAccept: () => { - // nothing - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - handleResult - ) - }, - }) - } else { - doUserAction(t, e, UserAction.TAKE, async (e, ts) => - MeteorCall.userAction.take(e, ts, this.playlist._id, this.playlist.currentPartInfo?.partInstanceId ?? null) - ) - } - } - - private handleAnotherPlaylistActive( - t: i18next.TFunction, - playlistId: RundownPlaylistId, - rehersal: boolean, - err: SerializedUserError, - clb?: (response: void) => void - ): void { - function handleResult(err: any, response: void) { - if (!err) { - if (typeof clb === 'function') clb(response) - } else { - logger.error(err) - doModalDialog({ - title: t('Failed to activate'), - message: t('Something went wrong, please contact the system administrator if the problem persists.'), - acceptOnly: true, - warning: true, - yes: t('OK'), - onAccept: () => { - // nothing - }, - }) - } - } - - doModalDialog({ - title: t('Another Rundown is Already Active!'), - message: t( - 'The rundown: "{{rundownName}}" will need to be deactivated in order to activate this one.\n\nAre you sure you want to activate this one anyway?', - { - // TODO: this is a bit of a hack, could a better string sent from the server instead? - rundownName: err.userMessage.args?.names ?? '', - } - ), - yes: t('Activate "On Air"'), - no: t('Cancel'), - discardAsPrimary: true, - actions: [ - { - label: t('Activate "Rehearsal"'), - classNames: 'btn-secondary', - on: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, rehersal), - handleResult - ) - }, - }, - ], - warning: true, - onAccept: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, false), - handleResult - ) - }, - }) - } - - public executeHold(t: i18next.TFunction, e: EventLike): void { - if (this.userPermissions.studio && this.playlist.activationId) { - doUserAction(t, e, UserAction.ACTIVATE_HOLD, async (e, ts) => - MeteorCall.userAction.activateHold(e, ts, this.playlist._id, false) - ) - } - } - - public executeClearQuickLoop(t: i18next.TFunction, e: EventLike) { - if (this.userPermissions.studio && this.playlist.activationId) { - doUserAction(t, e, UserAction.CLEAR_QUICK_LOOP, async (e, ts) => - MeteorCall.userAction.clearQuickLoop(e, ts, this.playlist._id) - ) - } - } - - public executeActivate(t: i18next.TFunction, e: EventLike) { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - (!this.playlist.activationId || (this.playlist.activationId && this.playlist.rehearsal)) - ) { - const onSuccess = () => { - this.deferFlushAndRewindSegments() - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const doActivate = () => { - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - (err) => { - if (!err) { - if (typeof this.onActivate === 'function') this.onActivate(false) - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - }) - return false - } - } - } - ) - } - - const doActivateAndReset = () => { - this.rewindSegments() - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) - return false - } - } - } - ) - } - - if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - // The broadcast hasn't started yet - doModalDialog({ - title: 'Activate "On Air"', - message: t('Do you want to activate this Rundown?'), - yes: 'Reset and Activate "On Air"', - no: t('Cancel'), - actions: [ - { - label: 'Activate "On Air"', - classNames: 'btn-secondary', - on: () => { - doActivate() // this one activates without resetting - }, - }, - ], - acceptOnly: false, - onAccept: () => { - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) - return false - } - } - } - ) - }, - }) - } else if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { - // The broadcast has started - doActivate() - } else { - // The broadcast has ended, going into active mode is probably not what you want to do - doModalDialog({ - title: 'Activate "On Air"', - message: t('The planned end time has passed, are you sure you want to activate this Rundown?'), - yes: 'Reset and Activate "On Air"', - no: t('Cancel'), - actions: [ - { - label: 'Activate "On Air"', - classNames: 'btn-secondary', - on: () => { - doActivate() // this one activates without resetting - }, - }, - ], - acceptOnly: false, - onAccept: () => { - doActivateAndReset() - }, - }) - } - } - } - - public executeActivateRehearsal = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - (!this.playlist.activationId || (this.playlist.activationId && !this.playlist.rehearsal)) - ) { - const onSuccess = () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const doActivateRehersal = () => { - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, true), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - ) - } - if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - // The broadcast hasn't started yet - if (!this.playlist.activationId) { - // inactive, do the full preparation: - doUserAction( - t, - e, - UserAction.PREPARE_FOR_BROADCAST, - async (e, ts) => MeteorCall.userAction.prepareForBroadcast(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - ) - } else if (!this.playlist.rehearsal) { - // Active, and not in rehearsal - doModalDialog({ - title: 'Activate "Rehearsal"', - message: t('Are you sure you want to activate Rehearsal Mode?'), - yes: 'Activate "Rehearsal"', - no: t('Cancel'), - onAccept: () => { - doActivateRehersal() - }, - }) - } else { - // Already in rehearsal, do nothing - } - } else { - // The broadcast has started - if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { - // We are in the broadcast - doModalDialog({ - title: 'Activate "Rehearsal"', - message: t('Are you sure you want to activate Rehearsal Mode?'), - yes: 'Activate "Rehearsal"', - no: t('Cancel'), - onAccept: () => { - doActivateRehersal() - }, - }) - } else { - // The broadcast has ended - doActivateRehersal() - } - } - } - } - - public executeDeactivate = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if (this.userPermissions.studio && this.playlist.activationId) { - if (checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - if (this.playlist.rehearsal) { - // We're in rehearsal mode - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - } else { - doModalDialog({ - title: 'Deactivate "On Air"', - message: t('Are you sure you want to deactivate this rundown?\n(This will clear the outputs.)'), - warning: true, - yes: t('Deactivate "On Air"'), - no: t('Cancel'), - onAccept: () => { - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - }, - }) - } - } else { - // Do it right away - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - } - } - } - - public executeActivateAdlibTesting = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - this.studio.settings.allowAdlibTestingSegment && - this.playlist.activationId && - this.currentRundown - ) { - const rundownId = this.currentRundown._id - doUserAction(t, e, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts) => - MeteorCall.userAction.activateAdlibTestingMode(e, ts, this.playlist._id, rundownId) - ) - } - } - - public executeResetRundown = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - const doReset = () => { - this.rewindSegments() // Do a rewind right away - doUserAction( - t, - e, - UserAction.RESET_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetRundownPlaylist(e, ts, this.playlist._id), - () => { - this.deferFlushAndRewindSegments() - } - ) - } - if (this.playlist.activationId && !this.playlist.rehearsal && !this.studio.settings.allowRundownResetOnAir) { - // The rundown is active and not in rehearsal - doModalDialog({ - title: 'Reset Rundown', - message: t('The rundown can not be reset while it is active'), - onAccept: () => { - // nothing - }, - acceptOnly: true, - yes: 'OK', - }) - } else { - doReset() - } - } - - public executeReloadRundownPlaylist = (t: i18next.TFunction, e: EventLike) => { - if (!this.userPermissions.studio) return - - doUserAction( - t, - e, - UserAction.RELOAD_RUNDOWN_PLAYLIST_DATA, - async (e, ts) => MeteorCall.userAction.resyncRundownPlaylist(e, ts, this.playlist._id), - (err, reloadResponse) => { - if (!err && reloadResponse) { - if (!handleRundownPlaylistReloadResponse(t, this.userPermissions, reloadResponse)) { - if (this.playlist && this.playlist.nextPartInfo) { - scrollToPartInstance(this.playlist.nextPartInfo.partInstanceId).catch((error) => { - if (!error.toString().match(/another scroll/)) console.warn(error) - }) - } - } - } - } - ) - } - - public executeTakeRundownSnapshot = (t: i18next.TFunction, e: EventLike) => { - if (!this.userPermissions.studio) return - - const doneMessage = t('A snapshot of the current Running\xa0Order has been created for troubleshooting.') - doUserAction( - t, - e, - UserAction.CREATE_SNAPSHOT_FOR_DEBUG, - async (e, ts) => - MeteorCall.system.generateSingleUseToken().then(async (tokenResponse) => { - if (ClientAPI.isClientResponseError(tokenResponse)) { - throw UserError.fromSerialized(tokenResponse.error) - } else if (!tokenResponse.result) { - throw new Error(`Internal Error: No token.`) - } - return MeteorCall.userAction.storeRundownSnapshot( - e, - ts, - hashSingleUseToken(tokenResponse.result), - this.playlist._id, - 'Taken by user', - false - ) - }), - () => { - NotificationCenter.push( - new Notification( - undefined, - NoticeLevel.NOTIFICATION, - doneMessage, - 'userAction', - undefined, - false, - undefined, - undefined, - 5000 - ) - ) - return false - }, - doneMessage - ) - } - - public executeActivateRundown = (t: i18next.TFunction, e: EventLike) => { - // Called from the ModalDialog, 1 minute before broadcast starts - if (!this.userPermissions.studio) return - - this.rewindSegments() // Do a rewind right away - - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - (err) => { - if (!err) { - if (typeof this.onActivate === 'function') this.onActivate(false) - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - }) - return false - } - } - } - ) - } - - public executeResetAndActivateRundown = (t: i18next.TFunction, e: EventLike) => { - // Called from the ModalDialog, 1 minute before broadcast starts - if (!this.userPermissions.studio) return - - this.rewindSegments() // Do a rewind right away - - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - this.deferFlushAndRewindSegments() - if (typeof this.onActivate === 'function') this.onActivate(false) - } - } - ) - } - - private deferFlushAndRewindSegments = () => { - // Do a rewind later, when the UI has updated - Meteor.defer(() => { - Tracker.flush() - Meteor.setTimeout(() => { - this.rewindSegments() - RundownViewEventBus.emit(RundownViewEvents.GO_TO_TOP) - }, 500) - }) - } - - private rewindSegments = () => { - RundownViewEventBus.emit(RundownViewEvents.REWIND_SEGMENTS) - } -} - -export interface RundownPlaylistOperations { - take: (e: EventLike) => void - hold: (e: EventLike) => void - clearQuickLoop: (e: EventLike) => void - activate: (e: EventLike) => void - activateRehearsal: (e: EventLike) => void - deactivate: (e: EventLike) => void - activateAdlibTesting: (e: EventLike) => void - resetRundown: (e: EventLike) => void - reloadRundownPlaylist: (e: EventLike) => void - takeRundownSnapshot: (e: EventLike) => void - activateRundown: (e: EventLike) => void - resetAndActivateRundown: (e: EventLike) => void -} - -const RundownPlaylistOperationsContext = React.createContext(null) - -export function RundownPlaylistOperationsContextProvider({ - children, - currentRundown, - playlist, - studio, - onActivate, -}: React.PropsWithChildren<{ - studio: UIStudio - playlist: DBRundownPlaylist - currentRundown: Rundown | undefined - onActivate?: (isRehearsal: boolean) => void -}>): React.JSX.Element | null { - const { t } = useTranslation() - - const userPermissions = useContext(UserPermissionsContext) - - const service = useMemo( - () => new RundownPlaylistOperationsService(studio, playlist, currentRundown, userPermissions, onActivate), - [] - ) - - useEffect(() => { - service.studio = studio - service.playlist = playlist - service.currentRundown = currentRundown - service.userPermissions = userPermissions - service.onActivate = onActivate - }, [currentRundown, playlist, studio, userPermissions, onActivate]) - - const apiObject = useMemo( - () => - ({ - take: (e) => service.executeTake(t, e), - hold: (e) => service.executeHold(t, e), - clearQuickLoop: (e) => service.executeClearQuickLoop(t, e), - activate: (e) => service.executeActivate(t, e), - activateRehearsal: (e) => service.executeActivateRehearsal(t, e), - deactivate: (e) => service.executeDeactivate(t, e), - activateAdlibTesting: (e) => service.executeActivateAdlibTesting(t, e), - resetRundown: (e) => service.executeResetRundown(t, e), - reloadRundownPlaylist: (e) => service.executeReloadRundownPlaylist(t, e), - takeRundownSnapshot: (e) => service.executeTakeRundownSnapshot(t, e), - activateRundown: (e) => service.executeActivateRundown(t, e), - resetAndActivateRundown: (e) => service.executeResetAndActivateRundown(t, e), - }) satisfies RundownPlaylistOperations, - [service, t] - ) - - return ( - {children} - ) -} - -export function useRundownPlaylistOperations(): RundownPlaylistOperations { - const context = useContext(RundownPlaylistOperationsContext) - - if (!context) - throw new Error('This component must be a child of a `RundownPlaylistOperationsContextProvider` component.') - - return context -} - -interface RundownTimesInfo { - shouldHaveStarted: boolean - willShortlyStart: boolean - shouldHaveEnded: boolean -} - -type EventLike = - | { - persist(): void - } - | {} - -export function checkRundownTimes(playlistTiming: RundownPlaylistTiming): RundownTimesInfo { - const currentTime = getCurrentTime() - - const shouldHaveEnded = - currentTime > - (PlaylistTiming.getExpectedStart(playlistTiming) || 0) + (PlaylistTiming.getExpectedDuration(playlistTiming) || 0) - - return { - shouldHaveStarted: currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0), - willShortlyStart: - !shouldHaveEnded && currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0) - REHEARSAL_MARGIN, - shouldHaveEnded, - } -} From 73042fb13bdca93143ccb557b32b3558d905a010 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 13:59:51 +0000 Subject: [PATCH 086/136] fix: Correct duration calculations in RundownHeader components by using remainingPlaylistDuration --- .../RundownHeader/RundownHeaderDurations.tsx | 21 ++----------------- .../RundownHeaderExpectedEnd.tsx | 18 +++++----------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index c543acea2e..63cc538125 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -4,7 +4,6 @@ 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, @@ -18,24 +17,8 @@ export function RundownHeaderDurations({ const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) - 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 - ? (timingDurations.asDisplayedPlaylistDuration ?? 0) - : now - playlist.startedPlayback - estDuration = elapsed + remaining - } - } + // Use remainingPlaylistDuration which includes current part's remaining time + const estDuration = timingDurations.remainingPlaylistDuration if (expectedDuration == null && estDuration == null) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index fe90f5b80a..5268ad04c6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -3,7 +3,6 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' -import { getRemainingDurationFromCurrentPart } from './remainingDuration' export function RundownHeaderExpectedEnd({ playlist, @@ -18,18 +17,11 @@ export function RundownHeaderExpectedEnd({ const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) const now = timingDurations.currentTime ?? Date.now() - let estEnd: number | null = null - const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId - if (currentPartInstanceId && timingDurations.partStartsAt && timingDurations.partExpectedDurations) { - const remaining = getRemainingDurationFromCurrentPart( - currentPartInstanceId, - timingDurations.partStartsAt, - timingDurations.partExpectedDurations - ) - if (remaining != null && remaining > 0) { - estEnd = now + remaining - } - } + // Use remainingPlaylistDuration which includes current part's remaining time + const estEnd = + timingDurations.remainingPlaylistDuration != null && timingDurations.remainingPlaylistDuration > 0 + ? now + timingDurations.remainingPlaylistDuration + : null if (!expectedEnd && !estEnd) return null From 1125a22594a6f6cf6b6ecdeffd482baa756a43bf Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 14:00:17 +0000 Subject: [PATCH 087/136] Linting improvements --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 63cc538125..f714a7e025 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration != null ? ( + {expectedDuration ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration != null ? ( + {!simplified && estDuration ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 5268ad04c6..ccbc68ccfd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -8,8 +8,8 @@ export function RundownHeaderExpectedEnd({ playlist, simplified, }: { - playlist: DBRundownPlaylist - simplified?: boolean + readonly playlist: DBRundownPlaylist + readonly simplified?: boolean }): JSX.Element | null { const { t } = useTranslation() const timingDurations = useTiming() From 92c59fbda95280081458472af4450a4bc26f7649 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 15:52:28 +0100 Subject: [PATCH 088/136] chore: Changed the Rehearsal background to striped grey, and added labels for Deactivated and Rehearsal. --- .../RundownHeader/RundownHeader.scss | 24 +++++++++++++++++-- .../RundownHeader/RundownHeader.tsx | 5 ++++ 2 files changed, 27 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 7575cd8588..93447f105e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -43,7 +43,15 @@ } &.rehearsal { - background: $color-header-rehearsal; + background-color: #06090d; + background-image: repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.08) 0, + rgba(255, 255, 255, 0.08) 18px, + transparent 18px, + transparent 36px + ); + border-bottom: 1px solid #256b91; } } @@ -62,6 +70,18 @@ flex: 1; } + .rundown-header__not-on-air-label { + @extend %hoverable-label; + opacity: 1; + color: #fff; + font-size: 0.8em; + letter-spacing: 0.02em; + + margin-left: 0.075em; + margin-right: 0.25em; + white-space: nowrap; + } + .rundown-header__right { display: flex; align-items: center; @@ -188,7 +208,7 @@ .rundown-header__clocks-diff__label { @extend .rundown-header__hoverable-label; - font-size: 0.7em; + font-size: 0.75em; opacity: 0.6; font-variation-settings: 'wdth' 25, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 52dd6add17..a7770f743b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -64,6 +64,11 @@ export function RundownHeader({
+ {playlist.activationId && playlist.rehearsal && ( + {t('REHEARSAL')} + )} + {!playlist.activationId && {t('DEACTIVATED')}} {playlist.currentPartInfo && (
Date: Thu, 12 Mar 2026 16:10:22 +0100 Subject: [PATCH 089/136] fix: more explicit truthy check allow keeping 0 dur timers visible --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- .../RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index f714a7e025..ab981c7f95 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration ? ( + {expectedDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration ? ( + {!simplified && estDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index ccbc68ccfd..077f3129a7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -27,10 +27,10 @@ export function RundownHeaderExpectedEnd({ return (
- {expectedEnd ? ( + {expectedEnd !== undefined ? ( ) : null} - {!simplified && estEnd ? ( + {!simplified && estEnd !== null ? ( ) : null}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index a9ea781de1..af24aa612b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -25,7 +25,7 @@ export function RundownHeaderPlannedStart({
{!simplified && - (playlist.startedPlayback ? ( + (playlist.startedPlayback !== undefined ? ( ) : ( From 4afed0e0723c7ed1d70d224eee5bd96632e0e7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 16:10:44 +0100 Subject: [PATCH 090/136] chore: cleanup --- packages/webui/src/client/lib/rundownTiming.ts | 2 +- .../src/client/ui/RundownView/RundownHeader/RundownHeader.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index a273b072ed..81277ec739 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -474,7 +474,7 @@ export class RundownTimingCalculator { // partExpectedDuration is affected by displayGroups, and if it hasn't played yet then it shouldn't // add any duration to the "remaining" time pool remainingRundownDuration += - calculatePartInstanceExpectedDurationWithTransition(partInstance) || 0 + calculatePartInstanceExpectedDurationWithTransition(partInstance) || 0 // item is onAir right now, and it's is currently shorter than expectedDuration } else if ( lastStartedPlayback && diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index a7770f743b..d0dc8a6997 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -98,7 +98,7 @@ export function RundownHeader({ {rundownCount > 1 ? ( {playlist.name} ) : ( - {(currentRundown ?? firstRundown)?.name} + {currentRundown?.name} )}
From 0192c01bfa3cee3c34abad86fd5f7b099cefddac Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:27:05 +0100 Subject: [PATCH 091/136] WIP: add open state to menu icon in top bar --- .../RundownHeader/RundownContextMenu.tsx | 70 ++++++++----- .../RundownHeader/RundownHeader.scss | 6 ++ .../RundownHeader/RundownHeader.tsx | 98 +++++++++++-------- .../RundownHeaderPlannedStart.tsx | 2 +- 4 files changed, 106 insertions(+), 70 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index 3941357cff..d564976e6c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next' import Escape from '../../../lib/Escape' 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 { ContextMenu, MenuItem, ContextMenuTrigger, hideMenu, showMenu } from '@jstarpl/react-contextmenu' import { contextMenuHoldToDisplayTime, useRundownViewEventBusListener } from '../../../lib/lib' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBars } from '@fortawesome/free-solid-svg-icons' +import { faBars, faTimes } from '@fortawesome/free-solid-svg-icons' import { ActivateRundownPlaylistEvent, DeactivateRundownPlaylistEvent, @@ -25,6 +25,8 @@ interface RundownContextMenuProps { playlist: DBRundownPlaylist studio: UIStudio firstRundown: Rundown | undefined + onShow?: () => void + onHide?: () => void } /** @@ -32,7 +34,13 @@ interface RundownContextMenuProps { * trigger area. It also registers event bus listeners for playlist operations (activate, * deactivate, take, reset, etc.) since these are tightly coupled to the menu actions. */ -export function RundownContextMenu({ playlist, studio, firstRundown }: Readonly): JSX.Element { +export function RundownContextMenu({ + playlist, + studio, + firstRundown, + onShow, + onHide, +}: Readonly): JSX.Element { const { t } = useTranslation() const userPermissions = useContext(UserPermissionsContext) const operations = useRundownPlaylistOperations() @@ -77,7 +85,7 @@ export function RundownContextMenu({ playlist, studio, firstRundown }: Readonly< return ( - +
{playlist && playlist.name}
{userPermissions.studio ? ( @@ -147,33 +155,43 @@ export function RundownHeaderContextMenuTrigger({ children }: Readonly void }>): 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) - } - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (isOpen) { + hideMenu({ id: RUNDOWN_CONTEXT_MENU_ID }) + onClose() + return + } + + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + showMenu({ + position: { x: rect.left, y: rect.bottom + 5 }, + id: RUNDOWN_CONTEXT_MENU_ID, + }) + } + }, + [isOpen] + ) return ( - ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 93447f105e..214dd9da69 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -68,6 +68,12 @@ display: flex; align-items: center; flex: 1; + + .rundown-header__left-context-menu-wrapper { + display: flex; + align-items: center; + height: 100%; + } } .rundown-header__not-on-air-label { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index d0dc8a6997..d1ae385e2e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import ClassNames from 'classnames' @@ -46,10 +46,19 @@ export function RundownHeader({ }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() const [simplified, setSimplified] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const onMenuClose = useCallback(() => setIsMenuOpen(false), [setIsMenuOpen]) return ( <> - + setIsMenuOpen(true)} + onHide={() => setIsMenuOpen(false)} + /> - -
-
- - {playlist.activationId && playlist.rehearsal && ( - {t('REHEARSAL')} - )} - {!playlist.activationId && {t('DEACTIVATED')}} - {playlist.currentPartInfo && ( -
- - - {t('On Air')} - +
+ + +
+ {playlist.activationId && playlist.rehearsal && ( + {t('REHEARSAL')} + )} + {!playlist.activationId && {t('DEACTIVATED')}} + {playlist.currentPartInfo && ( +
+ - - -
- )} - -
+ + {t('On Air')} + + + +
+ )} + +
+ +
+
@@ -98,28 +110,28 @@ export function RundownHeader({ {rundownCount > 1 ? ( {playlist.name} ) : ( - {currentRundown?.name} + {(currentRundown ?? firstRundown)?.name} )}
+
-
- - - - -
+
+ + + +
- +
) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index af24aa612b..e3384f4de1 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -28,7 +28,7 @@ export function RundownHeaderPlannedStart({ (playlist.startedPlayback !== undefined ? ( ) : ( - + {diff >= 0 && '-'} {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} From fce270b9d104df74ba5d85c2dcb21723e741efaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 16:31:09 +0100 Subject: [PATCH 092/136] fix: allow Plan. end to show even at the end of the show --- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 077f3129a7..df93d376c6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -19,7 +19,7 @@ export function RundownHeaderExpectedEnd({ // Use remainingPlaylistDuration which includes current part's remaining time const estEnd = - timingDurations.remainingPlaylistDuration != null && timingDurations.remainingPlaylistDuration > 0 + timingDurations.remainingPlaylistDuration !== undefined ? now + timingDurations.remainingPlaylistDuration : null From a72005567bae8756b09d2a38ad890d76b33651cd Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 15:35:20 +0000 Subject: [PATCH 093/136] Remove no longer needed file --- .../RundownHeader/remainingDuration.ts | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts b/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts deleted file mode 100644 index b54bb6c74f..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 b4d9871b83ec823a5dd044c4abc8598924f4b312 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 15:35:58 +0000 Subject: [PATCH 094/136] Fix overdeletion of _old files --- .../webui/src/client/ui/RundownList/util.ts | 2 +- .../RundownHeader/RundownContextMenu.tsx | 2 +- .../RundownHeader/RundownReloadResponse.ts | 177 +++++ .../useRundownPlaylistOperations.tsx | 693 ++++++++++++++++++ .../client/ui/RundownView/RundownNotifier.tsx | 2 +- .../RundownViewContextProviders.tsx | 2 +- 6 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx diff --git a/packages/webui/src/client/ui/RundownList/util.ts b/packages/webui/src/client/ui/RundownList/util.ts index 7a492e91f3..cec30f2fd8 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_old/RundownReloadResponse.js' +import { handleRundownReloadResponse } from '../RundownView/RundownHeader/RundownReloadResponse.js' import { RundownId, RundownLayoutId, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index d564976e6c..1d2acbd09b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -16,7 +16,7 @@ import { import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { UserPermissionsContext } from '../../UserPermissions' import * as RundownResolver from '../../../lib/RundownResolver' -import { checkRundownTimes, useRundownPlaylistOperations } from '../RundownHeader_old/useRundownPlaylistOperations' +import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations.js' import { reloadRundownPlaylistClick } from '../RundownNotifier' export const RUNDOWN_CONTEXT_MENU_ID = 'rundown-context-menu' diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts new file mode 100644 index 0000000000..a858db9caf --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts @@ -0,0 +1,177 @@ +import { RundownPlaylists, Rundowns } from '../../../collections' +import { + ReloadRundownPlaylistResponse, + TriggerReloadDataResponse, +} from '@sofie-automation/meteor-lib/dist/api/userActions' +import _ from 'underscore' +import { RundownPlaylistCollectionUtil } from '../../../collections/rundownPlaylistUtil' +import * as i18next from 'i18next' +import { UserPermissions } from '../../UserPermissions' +import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' +import { Tracker } from 'meteor/tracker' +import { doUserAction } from '../../../lib/clientUserAction' +import { MeteorCall } from '../../../lib/meteorApi' +import { doModalDialog } from '../../../lib/ModalDialog' + +export function handleRundownPlaylistReloadResponse( + t: i18next.TFunction, + userPermissions: Readonly, + result: ReloadRundownPlaylistResponse +): boolean { + const rundownsInNeedOfHandling = result.rundownsResponses.filter( + (r) => r.response === TriggerReloadDataResponse.MISSING + ) + const firstRundownId = _.first(rundownsInNeedOfHandling)?.rundownId + let allRundownsAffected = false + + if (firstRundownId) { + const firstRundown = Rundowns.findOne(firstRundownId) + const playlist = RundownPlaylists.findOne(firstRundown?.playlistId) + const allRundownIds = playlist ? RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) : [] + if ( + allRundownIds.length > 0 && + _.difference( + allRundownIds, + rundownsInNeedOfHandling.map((r) => r.rundownId) + ).length === 0 + ) { + allRundownsAffected = true + } + } + + const actionsTaken: RundownReloadResponseUserAction[] = [] + function onActionTaken(action: RundownReloadResponseUserAction): void { + actionsTaken.push(action) + if (actionsTaken.length === rundownsInNeedOfHandling.length) { + // the user has taken action on all of the missing rundowns + if (allRundownsAffected && actionsTaken.filter((actionTaken) => actionTaken !== 'removed').length === 0) { + // all rundowns in the playlist were affected and all of them were removed + // we redirect to the Lobby + window.location.assign('/') + } + } + } + + const handled = rundownsInNeedOfHandling.map((r) => + handleRundownReloadResponse(t, userPermissions, r.rundownId, r.response, onActionTaken) + ) + return handled.reduce((previousValue, value) => previousValue || value, false) +} + +export type RundownReloadResponseUserAction = 'removed' | 'unsynced' | 'error' + +export function handleRundownReloadResponse( + t: i18next.TFunction, + userPermissions: Readonly, + rundownId: RundownId, + result: TriggerReloadDataResponse, + clb?: (action: RundownReloadResponseUserAction) => void +): boolean { + let hasDoneSomething = false + + if (result === TriggerReloadDataResponse.MISSING) { + const rundown = Rundowns.findOne(rundownId) + const playlist = RundownPlaylists.findOne(rundown?.playlistId) + + hasDoneSomething = true + const notification = new Notification( + undefined, + NoticeLevel.CRITICAL, + t( + 'Rundown {{rundownName}} in Playlist {{playlistName}} is missing in the data from {{nrcsName}}. You can either leave it in Sofie and mark it as Unsynced or remove the rundown from Sofie. What do you want to do?', + { + nrcsName: getRundownNrcsName(rundown), + rundownName: rundown?.name || t('(Unknown rundown)'), + playlistName: playlist?.name || t('(Unknown playlist)'), + } + ), + 'userAction', + undefined, + true, + [ + // actions: + { + label: t('Leave Unsynced'), + type: 'default', + disabled: !userPermissions.studio, + action: () => { + doUserAction( + t, + 'Missing rundown action', + UserAction.UNSYNC_RUNDOWN, + async (e, ts) => MeteorCall.userAction.unsyncRundown(e, ts, rundownId), + (err) => { + if (!err) { + notificationHandle.stop() + clb?.('unsynced') + } else { + clb?.('error') + } + } + ) + }, + }, + { + label: t('Remove'), + type: 'default', + action: () => { + doModalDialog({ + title: t('Remove rundown'), + message: t( + 'Do you really want to remove just the rundown "{{rundownName}}" in the playlist {{playlistName}} from Sofie? \n\nThis cannot be undone!', + { + rundownName: rundown?.name || 'N/A', + playlistName: playlist?.name || 'N/A', + } + ), + onAccept: () => { + // nothing + doUserAction( + t, + 'Missing rundown action', + UserAction.REMOVE_RUNDOWN, + async (e, ts) => MeteorCall.userAction.removeRundown(e, ts, rundownId), + (err) => { + if (!err) { + notificationHandle.stop() + clb?.('removed') + } else { + clb?.('error') + } + } + ) + }, + }) + }, + }, + ] + ) + const notificationHandle = NotificationCenter.push(notification) + + if (rundown) { + // This allows the semi-modal dialog above to be closed automatically, once the rundown stops existing + // for whatever reason + const comp = Tracker.autorun(() => { + const rundown = Rundowns.findOne(rundownId, { + fields: { + _id: 1, + orphaned: 1, + }, + }) + // we should hide the message + if (!rundown || !rundown.orphaned) { + notificationHandle.stop() + } + }) + notification.on('dropped', () => { + // clean up the reactive computation above when the notification is closed. Will be also executed by + // the notificationHandle.stop() above, so the Tracker.autorun will clean up after itself as well. + comp.stop() + }) + } + } + return hasDoneSomething +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx new file mode 100644 index 0000000000..c4d5a3b64d --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx @@ -0,0 +1,693 @@ +import { SerializedUserError, UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' +import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' +import { doUserAction } from '../../../lib/clientUserAction' +import { MeteorCall } from '../../../lib/meteorApi' +import { doModalDialog } from '../../../lib/ModalDialog' +import { useTranslation } from 'react-i18next' +import React, { useContext, useEffect, useMemo } from 'react' +import { UserPermissions, UserPermissionsContext } from '../../UserPermissions' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { logger } from '../../../lib/logging' +import * as i18next from 'i18next' +import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' +import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' +import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' +import { handleRundownPlaylistReloadResponse } from './RundownReloadResponse.js' +import { scrollToPartInstance } from '../../../lib/viewPort' +import { hashSingleUseToken } from '../../../lib/lib' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { getCurrentTime } from '../../../lib/systemTime' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { REHEARSAL_MARGIN } from '../WarningDisplay' +import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' + +class RundownPlaylistOperationsService { + constructor( + public studio: UIStudio, + public playlist: DBRundownPlaylist, + public currentRundown: Rundown | undefined, + public userPermissions: UserPermissions, + public onActivate?: (isRehearsal: boolean) => void + ) {} + + public executeTake(t: i18next.TFunction, e: EventLike): void { + if (!this.userPermissions.studio) return + + if (!this.playlist.activationId) { + const onSuccess = () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const handleResult = (err: any) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + // ask to activate + doModalDialog({ + title: t('Failed to execute take'), + message: t( + 'The rundown you are trying to execute a take on is inactive, would you like to activate this rundown?' + ), + acceptOnly: false, + warning: true, + yes: t('Activate "On Air"'), + no: t('Cancel'), + discardAsPrimary: true, + onDiscard: () => { + // Do nothing + }, + actions: [ + { + label: t('Activate "Rehearsal"'), + classNames: 'btn-secondary', + on: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, this.playlist._id, true), + handleResult + ) + }, + }, + ], + onAccept: () => { + // nothing + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + handleResult + ) + }, + }) + } else { + doUserAction(t, e, UserAction.TAKE, async (e, ts) => + MeteorCall.userAction.take(e, ts, this.playlist._id, this.playlist.currentPartInfo?.partInstanceId ?? null) + ) + } + } + + private handleAnotherPlaylistActive( + t: i18next.TFunction, + playlistId: RundownPlaylistId, + rehersal: boolean, + err: SerializedUserError, + clb?: (response: void) => void + ): void { + function handleResult(err: any, response: void) { + if (!err) { + if (typeof clb === 'function') clb(response) + } else { + logger.error(err) + doModalDialog({ + title: t('Failed to activate'), + message: t('Something went wrong, please contact the system administrator if the problem persists.'), + acceptOnly: true, + warning: true, + yes: t('OK'), + onAccept: () => { + // nothing + }, + }) + } + } + + doModalDialog({ + title: t('Another Rundown is Already Active!'), + message: t( + 'The rundown: "{{rundownName}}" will need to be deactivated in order to activate this one.\n\nAre you sure you want to activate this one anyway?', + { + // TODO: this is a bit of a hack, could a better string sent from the server instead? + rundownName: err.userMessage.args?.names ?? '', + } + ), + yes: t('Activate "On Air"'), + no: t('Cancel'), + discardAsPrimary: true, + actions: [ + { + label: t('Activate "Rehearsal"'), + classNames: 'btn-secondary', + on: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, rehersal), + handleResult + ) + }, + }, + ], + warning: true, + onAccept: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, false), + handleResult + ) + }, + }) + } + + public executeHold(t: i18next.TFunction, e: EventLike): void { + if (this.userPermissions.studio && this.playlist.activationId) { + doUserAction(t, e, UserAction.ACTIVATE_HOLD, async (e, ts) => + MeteorCall.userAction.activateHold(e, ts, this.playlist._id, false) + ) + } + } + + public executeClearQuickLoop(t: i18next.TFunction, e: EventLike) { + if (this.userPermissions.studio && this.playlist.activationId) { + doUserAction(t, e, UserAction.CLEAR_QUICK_LOOP, async (e, ts) => + MeteorCall.userAction.clearQuickLoop(e, ts, this.playlist._id) + ) + } + } + + public executeActivate(t: i18next.TFunction, e: EventLike) { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + (!this.playlist.activationId || (this.playlist.activationId && this.playlist.rehearsal)) + ) { + const onSuccess = () => { + this.deferFlushAndRewindSegments() + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const doActivate = () => { + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + (err) => { + if (!err) { + if (typeof this.onActivate === 'function') this.onActivate(false) + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + }) + return false + } + } + } + ) + } + + const doActivateAndReset = () => { + this.rewindSegments() + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) + return false + } + } + } + ) + } + + if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + // The broadcast hasn't started yet + doModalDialog({ + title: 'Activate "On Air"', + message: t('Do you want to activate this Rundown?'), + yes: 'Reset and Activate "On Air"', + no: t('Cancel'), + actions: [ + { + label: 'Activate "On Air"', + classNames: 'btn-secondary', + on: () => { + doActivate() // this one activates without resetting + }, + }, + ], + acceptOnly: false, + onAccept: () => { + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) + return false + } + } + } + ) + }, + }) + } else if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { + // The broadcast has started + doActivate() + } else { + // The broadcast has ended, going into active mode is probably not what you want to do + doModalDialog({ + title: 'Activate "On Air"', + message: t('The planned end time has passed, are you sure you want to activate this Rundown?'), + yes: 'Reset and Activate "On Air"', + no: t('Cancel'), + actions: [ + { + label: 'Activate "On Air"', + classNames: 'btn-secondary', + on: () => { + doActivate() // this one activates without resetting + }, + }, + ], + acceptOnly: false, + onAccept: () => { + doActivateAndReset() + }, + }) + } + } + } + + public executeActivateRehearsal = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + (!this.playlist.activationId || (this.playlist.activationId && !this.playlist.rehearsal)) + ) { + const onSuccess = () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const doActivateRehersal = () => { + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, true), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + ) + } + if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + // The broadcast hasn't started yet + if (!this.playlist.activationId) { + // inactive, do the full preparation: + doUserAction( + t, + e, + UserAction.PREPARE_FOR_BROADCAST, + async (e, ts) => MeteorCall.userAction.prepareForBroadcast(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + ) + } else if (!this.playlist.rehearsal) { + // Active, and not in rehearsal + doModalDialog({ + title: 'Activate "Rehearsal"', + message: t('Are you sure you want to activate Rehearsal Mode?'), + yes: 'Activate "Rehearsal"', + no: t('Cancel'), + onAccept: () => { + doActivateRehersal() + }, + }) + } else { + // Already in rehearsal, do nothing + } + } else { + // The broadcast has started + if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { + // We are in the broadcast + doModalDialog({ + title: 'Activate "Rehearsal"', + message: t('Are you sure you want to activate Rehearsal Mode?'), + yes: 'Activate "Rehearsal"', + no: t('Cancel'), + onAccept: () => { + doActivateRehersal() + }, + }) + } else { + // The broadcast has ended + doActivateRehersal() + } + } + } + } + + public executeDeactivate = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if (this.userPermissions.studio && this.playlist.activationId) { + if (checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + if (this.playlist.rehearsal) { + // We're in rehearsal mode + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + } else { + doModalDialog({ + title: 'Deactivate "On Air"', + message: t('Are you sure you want to deactivate this rundown?\n(This will clear the outputs.)'), + warning: true, + yes: t('Deactivate "On Air"'), + no: t('Cancel'), + onAccept: () => { + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + }, + }) + } + } else { + // Do it right away + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + } + } + } + + public executeActivateAdlibTesting = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + this.studio.settings.allowAdlibTestingSegment && + this.playlist.activationId && + this.currentRundown + ) { + const rundownId = this.currentRundown._id + doUserAction(t, e, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts) => + MeteorCall.userAction.activateAdlibTestingMode(e, ts, this.playlist._id, rundownId) + ) + } + } + + public executeResetRundown = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + const doReset = () => { + this.rewindSegments() // Do a rewind right away + doUserAction( + t, + e, + UserAction.RESET_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetRundownPlaylist(e, ts, this.playlist._id), + () => { + this.deferFlushAndRewindSegments() + } + ) + } + if (this.playlist.activationId && !this.playlist.rehearsal && !this.studio.settings.allowRundownResetOnAir) { + // The rundown is active and not in rehearsal + doModalDialog({ + title: 'Reset Rundown', + message: t('The rundown can not be reset while it is active'), + onAccept: () => { + // nothing + }, + acceptOnly: true, + yes: 'OK', + }) + } else { + doReset() + } + } + + public executeReloadRundownPlaylist = (t: i18next.TFunction, e: EventLike) => { + if (!this.userPermissions.studio) return + + doUserAction( + t, + e, + UserAction.RELOAD_RUNDOWN_PLAYLIST_DATA, + async (e, ts) => MeteorCall.userAction.resyncRundownPlaylist(e, ts, this.playlist._id), + (err, reloadResponse) => { + if (!err && reloadResponse) { + if (!handleRundownPlaylistReloadResponse(t, this.userPermissions, reloadResponse)) { + if (this.playlist && this.playlist.nextPartInfo) { + scrollToPartInstance(this.playlist.nextPartInfo.partInstanceId).catch((error) => { + if (!error.toString().match(/another scroll/)) console.warn(error) + }) + } + } + } + } + ) + } + + public executeTakeRundownSnapshot = (t: i18next.TFunction, e: EventLike) => { + if (!this.userPermissions.studio) return + + const doneMessage = t('A snapshot of the current Running\xa0Order has been created for troubleshooting.') + doUserAction( + t, + e, + UserAction.CREATE_SNAPSHOT_FOR_DEBUG, + async (e, ts) => + MeteorCall.system.generateSingleUseToken().then(async (tokenResponse) => { + if (ClientAPI.isClientResponseError(tokenResponse)) { + throw UserError.fromSerialized(tokenResponse.error) + } else if (!tokenResponse.result) { + throw new Error(`Internal Error: No token.`) + } + return MeteorCall.userAction.storeRundownSnapshot( + e, + ts, + hashSingleUseToken(tokenResponse.result), + this.playlist._id, + 'Taken by user', + false + ) + }), + () => { + NotificationCenter.push( + new Notification( + undefined, + NoticeLevel.NOTIFICATION, + doneMessage, + 'userAction', + undefined, + false, + undefined, + undefined, + 5000 + ) + ) + return false + }, + doneMessage + ) + } + + public executeActivateRundown = (t: i18next.TFunction, e: EventLike) => { + // Called from the ModalDialog, 1 minute before broadcast starts + if (!this.userPermissions.studio) return + + this.rewindSegments() // Do a rewind right away + + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + (err) => { + if (!err) { + if (typeof this.onActivate === 'function') this.onActivate(false) + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + }) + return false + } + } + } + ) + } + + public executeResetAndActivateRundown = (t: i18next.TFunction, e: EventLike) => { + // Called from the ModalDialog, 1 minute before broadcast starts + if (!this.userPermissions.studio) return + + this.rewindSegments() // Do a rewind right away + + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + this.deferFlushAndRewindSegments() + if (typeof this.onActivate === 'function') this.onActivate(false) + } + } + ) + } + + private deferFlushAndRewindSegments = () => { + // Do a rewind later, when the UI has updated + Meteor.defer(() => { + Tracker.flush() + Meteor.setTimeout(() => { + this.rewindSegments() + RundownViewEventBus.emit(RundownViewEvents.GO_TO_TOP) + }, 500) + }) + } + + private rewindSegments = () => { + RundownViewEventBus.emit(RundownViewEvents.REWIND_SEGMENTS) + } +} + +export interface RundownPlaylistOperations { + take: (e: EventLike) => void + hold: (e: EventLike) => void + clearQuickLoop: (e: EventLike) => void + activate: (e: EventLike) => void + activateRehearsal: (e: EventLike) => void + deactivate: (e: EventLike) => void + activateAdlibTesting: (e: EventLike) => void + resetRundown: (e: EventLike) => void + reloadRundownPlaylist: (e: EventLike) => void + takeRundownSnapshot: (e: EventLike) => void + activateRundown: (e: EventLike) => void + resetAndActivateRundown: (e: EventLike) => void +} + +const RundownPlaylistOperationsContext = React.createContext(null) + +export function RundownPlaylistOperationsContextProvider({ + children, + currentRundown, + playlist, + studio, + onActivate, +}: React.PropsWithChildren<{ + studio: UIStudio + playlist: DBRundownPlaylist + currentRundown: Rundown | undefined + onActivate?: (isRehearsal: boolean) => void +}>): React.JSX.Element | null { + const { t } = useTranslation() + + const userPermissions = useContext(UserPermissionsContext) + + const service = useMemo( + () => new RundownPlaylistOperationsService(studio, playlist, currentRundown, userPermissions, onActivate), + [] + ) + + useEffect(() => { + service.studio = studio + service.playlist = playlist + service.currentRundown = currentRundown + service.userPermissions = userPermissions + service.onActivate = onActivate + }, [currentRundown, playlist, studio, userPermissions, onActivate]) + + const apiObject = useMemo( + () => + ({ + take: (e) => service.executeTake(t, e), + hold: (e) => service.executeHold(t, e), + clearQuickLoop: (e) => service.executeClearQuickLoop(t, e), + activate: (e) => service.executeActivate(t, e), + activateRehearsal: (e) => service.executeActivateRehearsal(t, e), + deactivate: (e) => service.executeDeactivate(t, e), + activateAdlibTesting: (e) => service.executeActivateAdlibTesting(t, e), + resetRundown: (e) => service.executeResetRundown(t, e), + reloadRundownPlaylist: (e) => service.executeReloadRundownPlaylist(t, e), + takeRundownSnapshot: (e) => service.executeTakeRundownSnapshot(t, e), + activateRundown: (e) => service.executeActivateRundown(t, e), + resetAndActivateRundown: (e) => service.executeResetAndActivateRundown(t, e), + }) satisfies RundownPlaylistOperations, + [service, t] + ) + + return ( + {children} + ) +} + +export function useRundownPlaylistOperations(): RundownPlaylistOperations { + const context = useContext(RundownPlaylistOperationsContext) + + if (!context) + throw new Error('This component must be a child of a `RundownPlaylistOperationsContextProvider` component.') + + return context +} + +interface RundownTimesInfo { + shouldHaveStarted: boolean + willShortlyStart: boolean + shouldHaveEnded: boolean +} + +type EventLike = + | { + persist(): void + } + | {} + +export function checkRundownTimes(playlistTiming: RundownPlaylistTiming): RundownTimesInfo { + const currentTime = getCurrentTime() + + const shouldHaveEnded = + currentTime > + (PlaylistTiming.getExpectedStart(playlistTiming) || 0) + (PlaylistTiming.getExpectedDuration(playlistTiming) || 0) + + return { + shouldHaveStarted: currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0), + willShortlyStart: + !shouldHaveEnded && currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0) - REHEARSAL_MARGIN, + shouldHaveEnded, + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index ccaa737756..906a22a3eb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -24,7 +24,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_old/RundownReloadResponse.js' +import { handleRundownReloadResponse } from './RundownHeader/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/RundownViewContextProviders.tsx b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx index 552f54afeb..4dea007a6d 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_old/useRundownPlaylistOperations' +import { RundownPlaylistOperationsContextProvider } from './RundownHeader/useRundownPlaylistOperations.js' import { PreviewPopUpContextProvider } from '../PreviewPopUp/PreviewPopUpContext' import { SelectedElementProvider } from './SelectedElementsContext' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' From 5244cdc48b666ce21c8320aa66ce32f61262a443 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:46:31 +0100 Subject: [PATCH 095/136] Top bar: fix hamburger menu not closing in some cases --- .../RundownView/RundownHeader/RundownContextMenu.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index 1d2acbd09b..45bb25eedb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -162,7 +162,7 @@ export function RundownHamburgerButton({ const { t } = useTranslation() const buttonRef = useRef(null) - const handleClick = useCallback( + const handleToggle = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -181,14 +181,19 @@ export function RundownHamburgerButton({ }) } }, - [isOpen] + [isOpen, onClose] ) return ( ) } From 08450fb1d26fefc9b2965a6bb50f6ee3bd13a805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 13:45:19 +0100 Subject: [PATCH 108/136] fix: Correct prefix for Start In when passing the planned start time --- .../ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 923570f319..de7d132bce 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -29,7 +29,7 @@ export function RundownHeaderPlannedStart({ ) : ( - {diff >= 0 && '-'} + {diff >= 0 && '+'} {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} ))} From 0a5497cc1b7ffb5ba70cd28888fe1d3ae456c7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 13:55:22 +0100 Subject: [PATCH 109/136] fix: more robust falsy checks that allow 0 values --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 881d32f788..e949f45111 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -20,7 +20,7 @@ export function RundownHeaderDurations({ // Use remainingPlaylistDuration which includes current part's remaining time const estDuration = timingDurations.remainingPlaylistDuration - if (expectedDuration == null && estDuration == null) return null + if (expectedDuration == undefined && estDuration == undefined) return null return (
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index df93d376c6..52af4a76e7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -23,7 +23,7 @@ export function RundownHeaderExpectedEnd({ ? now + timingDurations.remainingPlaylistDuration : null - if (!expectedEnd && !estEnd) return null + if (expectedEnd === undefined && estEnd === null) return null return (
From 533a9c0b1e978cb1bc69bca9c32abe3f2e75ff3e Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 13 Mar 2026 14:11:31 +0100 Subject: [PATCH 110/136] chore: Tweaks to the menu wording and menu styling. Added visible menu dividers. --- .../webui/src/client/styles/contextMenu.scss | 19 ++++++++++++++---- .../RundownHeader/RundownContextMenu.tsx | 20 +++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/webui/src/client/styles/contextMenu.scss b/packages/webui/src/client/styles/contextMenu.scss index c3f21ff1e7..2bd3730c65 100644 --- a/packages/webui/src/client/styles/contextMenu.scss +++ b/packages/webui/src/client/styles/contextMenu.scss @@ -3,7 +3,7 @@ nav.react-contextmenu { font-size: 1.0875rem; font-weight: 400; line-height: 1.5; - letter-spacing: 0.5px; + letter-spacing: -0.01em; z-index: 900; user-select: none; @@ -22,7 +22,6 @@ nav.react-contextmenu { .react-contextmenu-item, .react-contextmenu-label { margin: 0; - padding: 4px 13px 7px 13px; display: block; border: none; background: none; @@ -37,14 +36,16 @@ nav.react-contextmenu { .react-contextmenu-label { color: #49c0fb; background: #3e4041; + padding-left: 8px; cursor: default; } .react-contextmenu-item { + padding: 2px 13px 4px 13px; color: #494949; font-weight: 300; - padding-left: 25px; - padding-right: 25px; + padding-left: 18px; + padding-right: 30px; cursor: pointer; display: flex; @@ -60,6 +61,16 @@ nav.react-contextmenu { &.react-contextmenu-item--disabled { opacity: 0.5; + cursor: default; + } + + &.react-contextmenu-item--divider { + cursor: default; + padding: 0; + margin: 0 15px; + width: auto; + border-bottom: 1px solid #ddd; + height: 0; } > svg, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index f5389a3b24..e7958a1fc7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -100,16 +100,21 @@ export function RundownContextMenu({ {t('Activate (Rehearsal)')} ) ) : ( - {t('Activate (On-Air)')} + {t('Activate On Air')} )} {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( - {t('Activate (On-Air)')} + {t('Activate On Air')} )} - {playlist.activationId ? {t('Deactivate')} : null} + {playlist.activationId ? {t('Deactivate Studio')} : null} {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( {t('AdLib Testing')} ) : null} - {playlist.activationId ? {t('Take')} : null} + {playlist.activationId ? ( + <> + + {t('Take')} + + ) : null} {studio.settings.allowHold && playlist.activationId ? ( {t('Hold')} ) : null} @@ -117,7 +122,10 @@ export function RundownContextMenu({ {t('Clear QuickLoop')} ) : null} {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( - {t('Reset Rundown')} + <> + + {t('Reset Rundown')} + ) : null} {t('Reload {{nrcsName}} Data', { @@ -126,7 +134,7 @@ export function RundownContextMenu({ {t('Store Snapshot')} - history.push('/')}>{t('Close')} + history.push('/')}>{t('Close Rundown')} ) : ( From d265fac2f45f3ecd2c383f4af6107c615fd4bbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 14:29:03 +0100 Subject: [PATCH 111/136] fix: swaps the timers in the simple view mode --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- .../RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index e949f45111..b49e21f09b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration ? ( + {!simplified && expectedDuration ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration !== undefined ? ( + {estDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 52af4a76e7..4ce2497d83 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -27,10 +27,10 @@ export function RundownHeaderExpectedEnd({ return (
- {expectedEnd !== undefined ? ( + {!simplified && expectedEnd !== undefined ? ( ) : null} - {!simplified && estEnd !== null ? ( + {estEnd !== null ? ( ) : null}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index de7d132bce..48002e0282 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -23,8 +23,10 @@ export function RundownHeaderPlannedStart({ return (
+ {!simplified && expectedStart !== undefined ? ( - {!simplified && + ) : null} + { (playlist.startedPlayback !== undefined ? ( ) : ( From 128f28175f179f228cefc1d54b80f99f976ea2ad Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 13 Mar 2026 14:35:51 +0100 Subject: [PATCH 112/136] chore: Made sure the menu icon only changed state and was clickable when the menu was initiated from the icon. --- .../RundownHeader/RundownContextMenu.tsx | 12 +++++++++--- .../RundownView/RundownHeader/RundownHeader.scss | 7 ++++++- .../RundownView/RundownHeader/RundownHeader.tsx | 15 ++++++++++++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index e7958a1fc7..3ab85520cc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -169,8 +169,10 @@ export function RundownHeaderContextMenuTrigger({ children }: Readonly void }>): JSX.Element { + onOpen, +}: Readonly<{ isOpen?: boolean; disabled?: boolean; onClose: () => void; onOpen?: () => void }>): JSX.Element { const { t } = useTranslation() const buttonRef = useRef(null) @@ -179,6 +181,8 @@ export function RundownHamburgerButton({ e.preventDefault() e.stopPropagation() + if (disabled) return + if (isOpen) { hideMenu({ id: RUNDOWN_CONTEXT_MENU_ID }) onClose() @@ -191,15 +195,17 @@ export function RundownHamburgerButton({ position: { x: rect.left, y: rect.bottom + 5 }, id: RUNDOWN_CONTEXT_MENU_ID, }) + if (onOpen) onOpen() } }, - [isOpen, onClose] + [isOpen, disabled, onClose, onOpen] ) return ( - - - +
+ + + + +
-
+ ) From 96382360ed58f98324c86f381ff4f796b2fbc711 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Mon, 16 Mar 2026 09:07:23 +0100 Subject: [PATCH 116/136] chore: Added visual hover indication on the menu and close buttons. --- .../RundownHeader/RundownHeader.scss | 49 ++++++++++++++++++- 1 file changed, 47 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 4f1c7d0c41..1f285bb322 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -383,11 +383,36 @@ justify-content: center; height: 100%; font-size: 1.2em; - transition: color 0.2s; + transition: + color 0.2s; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover:not(:disabled):not(.disabled) { + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible:not(:disabled):not(.disabled) { + svg, + i { + filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); + } + } &:disabled, &.disabled { cursor: default; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } } } @@ -567,7 +592,27 @@ color: #40b8fa; opacity: 0; flex-shrink: 0; - transition: opacity 0.2s; + transition: + opacity 0.2s; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover { + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible { + svg, + i { + filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); + } + } } &:hover { From 5f039b77812b9a7230fbdeba5734f757f148d1e4 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Mon, 16 Mar 2026 15:43:37 +0100 Subject: [PATCH 117/136] chore: Added a Bootstrap CSS customization to get around the faulty size rendering of the entire GUI only when the URL parameter 'zoom' is set to '100' or no localStorage variable 'uiZoomLevel' exists, that occurs when the user has a local, non-default font size rendering. --- packages/webui/src/client/styles/bootstrap-customize.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webui/src/client/styles/bootstrap-customize.scss b/packages/webui/src/client/styles/bootstrap-customize.scss index 2c53e0bf9b..011f2e273d 100644 --- a/packages/webui/src/client/styles/bootstrap-customize.scss +++ b/packages/webui/src/client/styles/bootstrap-customize.scss @@ -6,6 +6,7 @@ } :root { + --bs-body-font-size: 16px; -webkit-font-smoothing: antialiased; --color-dark-1: #{$dark-1}; --color-dark-2: #{$dark-2}; From a2308dbf7162e59e1042049970ab2e9ea71586d0 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 16 Mar 2026 15:59:21 +0000 Subject: [PATCH 118/136] Lint fixes And fix the yarn lint:fix command --- package.json | 2 +- .../src/context/onSetAsNextContext.ts | 3 ++- .../src/context/rundownContext.ts | 5 +---- .../context/services/TTimersService.ts | 6 +++++- packages/openapi/run_server_tests.mjs | 2 +- .../RundownHeader/RundownContextMenu.tsx | 4 +++- .../RundownHeaderExpectedEnd.tsx | 4 +--- .../RundownHeaderPlannedStart.tsx | 19 +++++++++---------- 8 files changed, 23 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index f011d29dd3..f929601411 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "unit:meteor": "cd meteor && yarn unit", "meteor:run": "cd meteor && yarn start", "lint": "run lint:meteor && run lint:packages", - "lint:fix": "run lint:meteor --fix && run lint:packages -- --fix", + "lint:fix": "run lint:meteor --fix && run lint:packages --fix", "unit": "run unit:meteor && run unit:packages", "validate:release": "yarn install && run install-and-build && run validate:versions && run validate:release:packages && run validate:release:meteor", "validate:release:meteor": "cd meteor && yarn validate:prod-dependencies && yarn license-validate && yarn lint && yarn test", diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 6a209b11af..114ef0f47c 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -19,7 +19,8 @@ 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, ITriggerIngestChangeContext, ITTimersContext { +export interface IOnSetAsNextContext + extends IShowStyleUserContext, IEventContext, ITriggerIngestChangeContext, ITTimersContext { /** Information about the current loop, if there is one */ readonly quickLoopInfo: BlueprintQuickLookInfo | null diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index cf3a30e332..cb57cbd569 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -15,10 +15,7 @@ export interface IRundownContext extends IShowStyleContext { export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} export interface IRundownActivationContext - extends IRundownContext, - IExecuteTSRActionsContext, - IDataStoreMethods, - ITTimersContext { + 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/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 5f79b7417f..f3cab65238 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -2,7 +2,11 @@ import type { IPlaylistTTimer, IPlaylistTTimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' -import type { RundownTTimer, RundownTTimerIndex,TimerState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { + RundownTTimer, + RundownTTimerIndex, + 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, unprotectString } from '@sofie-automation/corelib/dist/protectedString' diff --git a/packages/openapi/run_server_tests.mjs b/packages/openapi/run_server_tests.mjs index 80c3dacd83..994d80c461 100644 --- a/packages/openapi/run_server_tests.mjs +++ b/packages/openapi/run_server_tests.mjs @@ -8,7 +8,7 @@ import { exec } from 'child_process' import { exit } from 'process' import { join } from 'path' import { createServer } from 'http' -// eslint-disable-next-line n/no-missing-import + import { expressAppConfig } from './server/node_modules/oas3-tools/dist/index.js' const testTimeout = 120000 diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index 3ab85520cc..7f7800f9dc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -105,7 +105,9 @@ export function RundownContextMenu({ {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( {t('Activate On Air')} )} - {playlist.activationId ? {t('Deactivate Studio')} : null} + {playlist.activationId ? ( + {t('Deactivate Studio')} + ) : null} {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( {t('AdLib Testing')} ) : null} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 4ce2497d83..8ab2570982 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -19,9 +19,7 @@ export function RundownHeaderExpectedEnd({ // Use remainingPlaylistDuration which includes current part's remaining time const estEnd = - timingDurations.remainingPlaylistDuration !== undefined - ? now + timingDurations.remainingPlaylistDuration - : null + timingDurations.remainingPlaylistDuration !== undefined ? now + timingDurations.remainingPlaylistDuration : null if (expectedEnd === undefined && estEnd === null) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 48002e0282..9d0324413f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -24,17 +24,16 @@ export function RundownHeaderPlannedStart({ return (
{!simplified && expectedStart !== undefined ? ( - + ) : null} - { - (playlist.startedPlayback !== undefined ? ( - - ) : ( - - {diff >= 0 && '+'} - {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} - - ))} + {playlist.startedPlayback !== undefined ? ( + + ) : ( + + {diff >= 0 && '+'} + {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} + + )}
) } From 088b9585d7653cb9d007d7207ebc6e1eb52293b0 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Mar 2026 11:02:03 +0000 Subject: [PATCH 119/136] Remove mock timer --- packages/webui/src/client/lib/tTimerUtils.ts | 34 ++------------------ 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index 8b5a0938ea..216e04a4c9 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -46,36 +46,6 @@ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): num return estimateDuration - duration } -// 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 +export function getDefaultTTimer(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): RundownTTimer | undefined { + return tTimers.find((t) => t.mode) } From 2299440ef174f242d181601f874dab8740258f32 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 17 Mar 2026 15:43:27 +0100 Subject: [PATCH 120/136] chore: Vertically aligned the Over/Under and Clock. --- .../src/client/ui/RundownView/RundownHeader/Countdown.scss | 2 +- .../client/ui/RundownView/RundownHeader/RundownHeader.scss | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index 4a0270766f..24b03a8930 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -3,7 +3,7 @@ .countdown { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 0.3em; transition: color 0.2s; diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 1f285bb322..a0fce01c1a 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -155,11 +155,11 @@ max-width: 40em; color: #fff; max-height: 0; - padding-top: 0.55em; + padding-top: 0; overflow: hidden; transition: max-height 0.2s ease, - padding 0.2s ease; + padding-top 0.2s ease; .rundown-name, .playlist-name { @@ -638,6 +638,7 @@ .rundown-header__clocks-clock-group { .rundown-header__clocks-playlist-name { max-height: 2em; + padding-top: 0em; } } } From 620eb0025f94021f9c7dfa19f0a92c82c30f0b6b Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Mar 2026 12:41:00 +0000 Subject: [PATCH 121/136] Improve empty t-timers object in test --- meteor/server/__tests__/cronjobs.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 2d45733ccc..b5f6ce1d78 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -622,7 +622,11 @@ describe('cronjobs', () => { type: PlaylistTimingType.None, }, activationId: protectString(''), - tTimers: [] as any, + tTimers: [ + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], }) return { From 3c7d1bef5c07287f657cbab7859034372e96f74c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Mar 2026 14:07:54 +0000 Subject: [PATCH 122/136] Simplify validating index and add more tests --- packages/job-worker/src/playout/__tests__/tTimers.test.ts | 5 +++++ packages/job-worker/src/playout/tTimers.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts index bea1a2c92b..d323e939ab 100644 --- a/packages/job-worker/src/playout/__tests__/tTimers.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -43,6 +43,11 @@ describe('tTimers utils', () => { it('should reject NaN', () => { expect(() => validateTTimerIndex(NaN)).toThrow('T-timer index out of range: NaN') }) + + it('should reject fractional indices', () => { + expect(() => validateTTimerIndex(1.5)).toThrow('T-timer index out of range: 1.5') + expect(() => validateTTimerIndex(2.1)).toThrow('T-timer index out of range: 2.1') + }) }) describe('pauseTTimer', () => { diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index bb005e52b7..83af3a093e 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -14,7 +14,7 @@ import { PlayoutModel } from './model/PlayoutModel.js' import { getOrderedPartsAfterPlayhead } from './lookahead/util.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}`) + if (![1, 2, 3].includes(index)) throw new Error(`T-timer index out of range: ${index}`) } /** From 518d406b76af49bd7c23e95557a5c4c2d50f4588 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Mar 2026 14:44:06 +0000 Subject: [PATCH 123/136] Change T-Timer estimate to projection --- .../src/context/tTimersContext.ts | 40 +++++------ .../corelib/src/dataModel/RundownPlaylist.ts | 12 ++-- packages/corelib/src/worker/studio.ts | 8 +-- .../context/services/TTimersService.ts | 30 ++++---- .../services/__tests__/TTimersService.test.ts | 72 +++++++++---------- packages/job-worker/src/ingest/commit.ts | 10 +-- .../src/playout/__tests__/tTimersJobs.test.ts | 12 ++-- packages/job-worker/src/playout/setNext.ts | 10 +-- packages/job-worker/src/playout/tTimers.ts | 18 ++--- .../job-worker/src/playout/tTimersJobs.ts | 10 +-- .../job-worker/src/workers/studio/child.ts | 2 +- .../job-worker/src/workers/studio/jobs.ts | 4 +- packages/webui/src/client/lib/tTimerUtils.ts | 10 +-- 13 files changed, 119 insertions(+), 119 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 28e03b8ad6..7b00d9258a 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -73,49 +73,49 @@ export interface IPlaylistTTimer { 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. + * Clear any projection (manual or anchor-based) for this timer + * This removes both manual projections set via setProjectedTime/setProjectedDuration + * and automatic projections based on anchor parts set via setProjectedAnchorPart. */ - clearEstimate(): void + clearProjected(): void /** - * Set the anchor part for automatic estimate calculation + * Set the anchor part for automatic projection 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. + * based on remaining part durations, and updates the projection accordingly. + * Clears any manual projection set via setProjectedTime/setProjectedDuration. * @param partId The ID of the part to use as timing anchor */ - setEstimateAnchorPart(partId: string): void + setProjectedAnchorPart(partId: string): void /** - * Set the anchor part for automatic estimate calculation, looked up by its externalId. + * Set the anchor part for automatic projection 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. + * Clears any manual projection set via setProjectedTime/setProjectedDuration. * @param externalId The externalId of the part to use as timing anchor */ - setEstimateAnchorPartByExternalId(externalId: string): void + setProjectedAnchorPartByExternalId(externalId: string): void /** - * Manually set the estimate as an absolute timestamp + * Manually set the projection 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). + * @param paused If true, we're currently delayed/pushing (projection won't update with time passing). + * If false (default), we're progressing normally (projection counts down in real-time). */ - setEstimateTime(time: number, paused?: boolean): void + setProjectedTime(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". + * Manually set the projection as a relative duration from now + * Use this when you want to express the projection 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). + * @param paused If true, we're currently delayed/pushing (projection won't update with time passing). + * If false (default), we're progressing normally (projection counts down in real-time). */ - setEstimateDuration(duration: number, paused?: boolean): void + setProjectedDuration(duration: number, paused?: boolean): void } export type IPlaylistTTimerState = diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e3eb8fae8c..06cf6d3ff5 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -205,23 +205,23 @@ export interface RundownTTimer { */ state: TimerState | null - /** The estimated time when we expect to reach the anchor part, for calculating over/under diff. + /** The projected 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. - * The over/under diff is calculated as the difference between this estimate and the timer's target (state.zeroTime). + * The over/under diff is calculated as the difference between this projection and the timer's target (state.zeroTime). * - * Running means we are progressing towards the anchor (estimate moves with real time) + * Running means we are progressing towards the anchor (projection 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 + projectedState?: TimerState /** 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. + * When set, the server calculates projectedState based on when we expect to reach this part. + * If not set, projectedState is not calculated automatically but can still be set manually by a blueprint. */ anchorPartId?: PartId diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index bcbde0b94a..961c7b7dfd 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -127,10 +127,10 @@ 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 + * Recalculate T-Timer projections based on current playlist state + * Called after setNext, takes, and ingest changes to update timing anchor projections */ - RecalculateTTimerEstimates = 'recalculateTTimerEstimates', + RecalculateTTimerProjections = 'recalculateTTimerProjections', /** * Update the timeline with a regenerated Studio Baseline @@ -423,7 +423,7 @@ export type StudioJobFunc = { [StudioJobs.OnPlayoutPlaybackChanged]: (data: OnPlayoutPlaybackChangedProps) => void [StudioJobs.OnTimelineTriggerTime]: (data: OnTimelineTriggerTimeProps) => void - [StudioJobs.RecalculateTTimerEstimates]: () => void + [StudioJobs.RecalculateTTimerProjections]: () => void [StudioJobs.UpdateStudioBaseline]: () => string | false [StudioJobs.CleanupEmptyPlaylists]: () => void diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index f3cab65238..ab0a67452d 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -20,7 +20,7 @@ import { restartTTimer, resumeTTimer, validateTTimerIndex, - recalculateTTimerEstimates, + recalculateTTimerProjections, } from '../../../playout/tTimers.js' import { getCurrentTime } from '../../../lib/index.js' import type { JobContext } from '../../../jobs/index.js' @@ -195,56 +195,56 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { return true } - clearEstimate(): void { + clearProjected(): void { this.#timer = { ...this.#timer, anchorPartId: undefined, - estimateState: undefined, + projectedState: undefined, } this.#emitChange(this.#timer) } - setEstimateAnchorPart(partId: string): void { + setProjectedAnchorPart(partId: string): void { this.#timer = { ...this.#timer, anchorPartId: protectString(partId), - estimateState: undefined, // Clear manual estimate + projectedState: undefined, // Clear manual projection } this.#emitChange(this.#timer) - // Recalculate estimates immediately since we already have the playout model - recalculateTTimerEstimates(this.#jobContext, this.#playoutModel) + // Recalculate projections immediately since we already have the playout model + recalculateTTimerProjections(this.#jobContext, this.#playoutModel) } - setEstimateAnchorPartByExternalId(externalId: string): void { + setProjectedAnchorPartByExternalId(externalId: string): void { const part = this.#playoutModel.getAllOrderedParts().find((p) => p.externalId === externalId) if (!part) return - this.setEstimateAnchorPart(unprotectString(part._id)) + this.setProjectedAnchorPart(unprotectString(part._id)) } - setEstimateTime(time: number, paused: boolean = false): void { - const estimateState: TimerState = paused + setProjectedTime(time: number, paused: boolean = false): void { + const projectedState: TimerState = paused ? literal({ paused: true, duration: time - getCurrentTime() }) : literal({ paused: false, zeroTime: time }) this.#timer = { ...this.#timer, anchorPartId: undefined, // Clear automatic anchor - estimateState, + projectedState, } this.#emitChange(this.#timer) } - setEstimateDuration(duration: number, paused: boolean = false): void { - const estimateState: TimerState = paused + setProjectedDuration(duration: number, paused: boolean = false): void { + const projectedState: TimerState = paused ? literal({ paused: true, duration }) : literal({ paused: false, zeroTime: getCurrentTime() + duration }) this.#timer = { ...this.#timer, anchorPartId: undefined, // Clear automatic anchor - estimateState, + projectedState, } 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 8922d386cc..72236e2d51 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 @@ -843,17 +843,17 @@ describe('PlaylistTTimerImpl', () => { }) }) - describe('clearEstimate', () => { - it('should clear both anchorPartId and estimateState', () => { + describe('clearProjected', () => { + it('should clear both anchorPartId and projectedState', () => { const tTimers = createEmptyTTimers() tTimers[0].anchorPartId = 'part1' as any - tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + tTimers[0].projectedState = { 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() + timer.clearProjected() expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -861,18 +861,18 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: undefined, + projectedState: undefined, }) }) - it('should work when estimates are already cleared', () => { + it('should work when projections 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() + timer.clearProjected() expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -880,21 +880,21 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: undefined, + projectedState: undefined, }) }) }) - describe('setEstimateAnchorPart', () => { - it('should set anchorPartId and clear estimateState', () => { + describe('setProjectedAnchorPart', () => { + it('should set anchorPartId and clear projectedState', () => { const tTimers = createEmptyTTimers() - tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + tTimers[0].projectedState = { 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') + timer.setProjectedAnchorPart('part123') expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -902,7 +902,7 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: 'part123', - estimateState: undefined, + projectedState: undefined, }) }) @@ -914,22 +914,22 @@ describe('PlaylistTTimerImpl', () => { const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) // Should not throw - expect(() => timer.setEstimateAnchorPart('part456')).not.toThrow() + expect(() => timer.setProjectedAnchorPart('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)', () => { + describe('setProjectedTime', () => { + it('should set projectedState 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) + timer.setProjectedTime(50000, false) expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -937,18 +937,18 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: { paused: false, zeroTime: 50000 }, + projectedState: { paused: false, zeroTime: 50000 }, }) }) - it('should set estimateState with absolute time (paused)', () => { + it('should set projectedState 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) + timer.setProjectedTime(50000, true) expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -956,11 +956,11 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) + projectedState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) }) }) - it('should clear anchorPartId when setting manual estimate', () => { + it('should clear anchorPartId when setting manual projection', () => { const tTimers = createEmptyTTimers() tTimers[0].anchorPartId = 'part1' as any const updateFn = jest.fn() @@ -968,7 +968,7 @@ describe('PlaylistTTimerImpl', () => { const mockJobContext = createMockJobContext() const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - timer.setEstimateTime(50000) + timer.setProjectedTime(50000) expect(updateFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -984,25 +984,25 @@ describe('PlaylistTTimerImpl', () => { const mockJobContext = createMockJobContext() const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - timer.setEstimateTime(50000) + timer.setProjectedTime(50000) expect(updateFn).toHaveBeenCalledWith( expect.objectContaining({ - estimateState: { paused: false, zeroTime: 50000 }, + projectedState: { paused: false, zeroTime: 50000 }, }) ) }) }) - describe('setEstimateDuration', () => { - it('should set estimateState with relative duration (not paused)', () => { + describe('setProjectedDuration', () => { + it('should set projectedState 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) + timer.setProjectedDuration(30000, false) expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -1010,18 +1010,18 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) + projectedState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) }) }) - it('should set estimateState with relative duration (paused)', () => { + it('should set projectedState 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) + timer.setProjectedDuration(30000, true) expect(updateFn).toHaveBeenCalledWith({ index: 1, @@ -1029,11 +1029,11 @@ describe('PlaylistTTimerImpl', () => { mode: null, state: null, anchorPartId: undefined, - estimateState: { paused: true, duration: 30000 }, + projectedState: { paused: true, duration: 30000 }, }) }) - it('should clear anchorPartId when setting manual estimate', () => { + it('should clear anchorPartId when setting manual projection', () => { const tTimers = createEmptyTTimers() tTimers[0].anchorPartId = 'part1' as any const updateFn = jest.fn() @@ -1041,7 +1041,7 @@ describe('PlaylistTTimerImpl', () => { const mockJobContext = createMockJobContext() const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - timer.setEstimateDuration(30000) + timer.setProjectedDuration(30000) expect(updateFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -1057,11 +1057,11 @@ describe('PlaylistTTimerImpl', () => { const mockJobContext = createMockJobContext() const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - timer.setEstimateDuration(30000) + timer.setProjectedDuration(30000) expect(updateFn).toHaveBeenCalledWith( expect.objectContaining({ - estimateState: { paused: false, zeroTime: 40000 }, + projectedState: { paused: false, zeroTime: 40000 }, }) ) }) diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 93e1f4d5a2..18c981dc4b 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -29,7 +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 { recalculateTTimerProjections } from '../playout/tTimers.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { getTranslatedMessage, ServerTranslatedMesssages } from '../notes.js' import _ from 'underscore' @@ -242,8 +242,8 @@ export async function CommitIngestOperation( // 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) + // Recalculate T-Timer projections after ingest changes + recalculateTTimerProjections(context, playoutModel) playoutModel.deferAfterSave(() => { // Run in the background, we don't want to hold onto the lock to do this @@ -617,8 +617,8 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( const shouldUpdateTimeline = await ensureNextPartIsValid(context, playoutModel) - // Recalculate T-Timer estimates after playlist changes - recalculateTTimerEstimates(context, playoutModel) + // Recalculate T-Timer projections after playlist changes + recalculateTTimerProjections(context, playoutModel) if (playoutModel.playlist.activationId || shouldUpdateTimeline) { triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) diff --git a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts index e6623a952b..6704e8255e 100644 --- a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -1,5 +1,5 @@ import { setupDefaultJobEnvironment, MockJobContext } from '../../__mocks__/context.js' -import { handleRecalculateTTimerEstimates } from '../tTimersJobs.js' +import { handleRecalculateTTimerProjections } 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' @@ -12,7 +12,7 @@ describe('tTimersJobs', () => { context = setupDefaultJobEnvironment() }) - describe('handleRecalculateTTimerEstimates', () => { + describe('handleRecalculateTTimerProjections', () => { it('should handle studio with active playlists', async () => { // Create an active playlist const playlistId = protectString('playlist1') @@ -59,7 +59,7 @@ describe('tTimersJobs', () => { ) // Should complete without errors - await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + await expect(handleRecalculateTTimerProjections(context)).resolves.toBeUndefined() }) it('should handle studio with no active playlists', async () => { @@ -108,7 +108,7 @@ describe('tTimersJobs', () => { ) // Should complete without errors (just does nothing) - await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + await expect(handleRecalculateTTimerProjections(context)).resolves.toBeUndefined() }) it('should handle multiple active playlists', async () => { @@ -199,13 +199,13 @@ describe('tTimersJobs', () => { ) // Should complete without errors, processing both playlists - await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + await expect(handleRecalculateTTimerProjections(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() + await expect(handleRecalculateTTimerProjections(context)).resolves.toBeUndefined() }) }) }) diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 5757494945..2fb38067ae 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -36,7 +36,7 @@ import { PersistentPlayoutStateStore } from '../blueprints/context/services/Pers import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { PlayoutPartInstanceModelImpl } from './model/implementation/PlayoutPartInstanceModelImpl.js' import { QuickLoopService } from './model/services/QuickLoopService.js' -import { recalculateTTimerEstimates } from './tTimers.js' +import { recalculateTTimerProjections } from './tTimers.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -100,8 +100,8 @@ export async function setNextPart( await cleanupOrphanedItems(context, playoutModel) - // Recalculate T-Timer estimates based on the new next part - recalculateTTimerEstimates(context, playoutModel) + // Recalculate T-Timer projections based on the new next part + recalculateTTimerProjections(context, playoutModel) if (span) span.end() } @@ -534,8 +534,8 @@ export async function queueNextSegment( playoutModel.setQueuedSegment(null) } - // Recalculate timer estimates as the queued segment affects what comes after next - recalculateTTimerEstimates(context, playoutModel) + // Recalculate timer projections as the queued segment affects what comes after next + recalculateTTimerProjections(context, playoutModel) span?.end() return { queuedSegmentId: queuedSegment?.segment?._id ?? null } diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 83af3a093e..dc6d9524a0 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -174,7 +174,7 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe } /** - * Recalculate T-Timer estimates based on timing anchors using segment budget timing. + * Recalculate T-Timer projections based on timing anchors using segment budget timing. * * Uses a single-pass algorithm with two accumulators: * - totalAccumulator: Accumulated time across completed segments @@ -189,8 +189,8 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe * @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') +export function recalculateTTimerProjections(context: JobContext, playoutModel: PlayoutModel): void { + const span = context.startSpan('recalculateTTimerProjections') const playlist = playoutModel.playlist @@ -219,10 +219,10 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, Infinity, true) if (playablePartsSlice.length === 0 && !currentPartInstance) { - // No parts to iterate through, clear estimates + // No parts to iterate through, clear projections for (const timer of tTimers) { if (timer.anchorPartId) { - playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + playoutModel.updateTTimer({ ...timer, projectedState: undefined }) } } if (span) span.end() @@ -314,7 +314,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl for (const timerIndex of timersForThisPart) { const timer = tTimers[timerIndex - 1] - const estimateState: TimerState = isPushing + const projectedState: TimerState = isPushing ? literal({ paused: true, duration: anchorTime, @@ -326,7 +326,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl pauseTime: now + currentPartRemainingTime, // When current part ends and pushing begins }) - playoutModel.updateTTimer({ ...timer, estimateState }) + playoutModel.updateTTimer({ ...timer, projectedState }) } timerAnchors.delete(part._id) @@ -337,11 +337,11 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl segmentAccumulator += partDuration } - // Clear estimates for unresolved anchors + // Clear projections for unresolved anchors for (const [, timerIndices] of timerAnchors.entries()) { for (const timerIndex of timerIndices) { const timer = tTimers[timerIndex - 1] - playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + playoutModel.updateTTimer({ ...timer, projectedState: undefined }) } } diff --git a/packages/job-worker/src/playout/tTimersJobs.ts b/packages/job-worker/src/playout/tTimersJobs.ts index b1fede7642..a639ea1db0 100644 --- a/packages/job-worker/src/playout/tTimersJobs.ts +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -1,13 +1,13 @@ import { JobContext } from '../jobs/index.js' -import { recalculateTTimerEstimates } from './tTimers.js' +import { recalculateTTimerProjections } 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 + * Handle RecalculateTTimerProjections job + * This is called after setNext, takes, and ingest changes to update T-Timer projections * Since this job doesn't take a playlistId parameter, it finds the active playlist in the studio */ -export async function handleRecalculateTTimerEstimates(context: JobContext): Promise { +export async function handleRecalculateTTimerProjections(context: JobContext): Promise { // Find active playlists in this studio (projection to just get IDs) const activePlaylistIds = await context.directCollections.RundownPlaylists.findFetch( { @@ -37,7 +37,7 @@ export async function handleRecalculateTTimerEstimates(context: JobContext): Pro } await runWithPlayoutModel(context, playlist, lock, null, async (playoutModel) => { - recalculateTTimerEstimates(context, playoutModel) + recalculateTTimerProjections(context, playoutModel) }) }) } diff --git a/packages/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index 138bfd10d0..e01783a4ef 100644 --- a/packages/job-worker/src/workers/studio/child.ts +++ b/packages/job-worker/src/workers/studio/child.ts @@ -80,7 +80,7 @@ export class StudioWorkerChild { // Queue initial T-Timer recalculation to set up timers after startup this.#queueJob( getStudioQueueName(this.#studioId), - StudioJobs.RecalculateTTimerEstimates, + StudioJobs.RecalculateTTimerProjections, undefined, undefined ).catch((err) => { diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index 7b66526a4d..89928fd3b9 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -49,7 +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' +import { handleRecalculateTTimerProjections } from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -88,7 +88,7 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.OnPlayoutPlaybackChanged]: handleOnPlayoutPlaybackChanged, [StudioJobs.OnTimelineTriggerTime]: handleTimelineTriggerTime, - [StudioJobs.RecalculateTTimerEstimates]: handleRecalculateTTimerEstimates, + [StudioJobs.RecalculateTTimerProjections]: handleRecalculateTTimerProjections, [StudioJobs.UpdateStudioBaseline]: handleUpdateStudioBaseline, [StudioJobs.CleanupEmptyPlaylists]: handleRemoveEmptyPlaylists, diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index 216e04a4c9..637c8d75e5 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -28,22 +28,22 @@ export function calculateTTimerDiff(timer: RundownTTimer, now: number): number { /** * Calculate the over/under difference between the timer's current value - * and its estimate. + * and its projected time. * * 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. + * Returns undefined if no projection is available. */ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): number | undefined { - if (!timer.state || !timer.estimateState) { + if (!timer.state || !timer.projectedState) { return undefined } const duration = timerStateToDuration(timer.state, now) - const estimateDuration = timerStateToDuration(timer.estimateState, now) + const projectedDuration = timerStateToDuration(timer.projectedState, now) - return estimateDuration - duration + return projectedDuration - duration } export function getDefaultTTimer(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): RundownTTimer | undefined { From d2418d347bdf7c7a31298180bb8baa83c58775ed Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 17 Mar 2026 15:56:31 +0100 Subject: [PATCH 124/136] chore: Clarified hover styling of Menu and Close icons. Tweaked the Rundown/Playlist label styling. --- .../ui/RundownView/RundownHeader/RundownHeader.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index a0fce01c1a..2b6dee82de 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -133,7 +133,7 @@ } .rundown-header__clocks-playlist-name { - font-size: 0.7em; + font-size: 0.75em; font-variation-settings: 'wdth' 25, 'wght' 500, @@ -392,6 +392,8 @@ } &:hover:not(:disabled):not(.disabled) { + color: #ffffff; + svg, i { filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); @@ -399,6 +401,8 @@ } &:focus-visible:not(:disabled):not(.disabled) { + color: #ffffff; + svg, i { filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); @@ -601,6 +605,8 @@ } &:hover { + color: #ffffff; + svg, i { filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); @@ -608,6 +614,8 @@ } &:focus-visible { + color: #ffffff; + svg, i { filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); From 4965cc8ce1c95a1c14d60ccdae6785d2ecd4f68e Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Tue, 17 Mar 2026 16:11:26 +0100 Subject: [PATCH 125/136] chore: Add gap between the On Air group and T-timers group of the Top Bar. --- .../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 2b6dee82de..c64ff8da36 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -75,6 +75,7 @@ .rundown-header__left-context-menu-wrapper { display: flex; align-items: center; + gap: 2.5em; height: 100%; } } From e1cbdf723348a0324034ac2b6a5d56db76fd735c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 18 Mar 2026 17:53:13 +0100 Subject: [PATCH 126/136] fix: show planned duration for duration based shows --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index b49e21f09b..90f52d07f3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -1,5 +1,4 @@ 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' @@ -15,7 +14,10 @@ export function RundownHeaderDurations({ const { t } = useTranslation() const timingDurations = useTiming() - const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + // const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, + // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task + const expectedDuration = playlist.timing.expectedDuration // Use remainingPlaylistDuration which includes current part's remaining time const estDuration = timingDurations.remainingPlaylistDuration From 6be417baed59cf2ea5b0298d2aa4e2b83cd542f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 18 Mar 2026 18:13:59 +0100 Subject: [PATCH 127/136] fix: pipe duration through, but in a conservative non-perfect way to avoid regressions in other views now --- .../client/ui/RundownView/RundownHeader/RundownHeader.tsx | 7 ++++++- .../RundownView/RundownHeader/RundownHeaderDurations.tsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index ebe5e5ca27..9713542838 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -53,8 +53,13 @@ export function RundownHeader({ const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + + // const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, + // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task + // same issue in RundownHeaderDuration.tsx + const expectedDuration = playlist.timing.expectedDuration const hasSimple = !!(expectedStart || expectedDuration || expectedEnd) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 90f52d07f3..aedb2dde26 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -17,6 +17,7 @@ export function RundownHeaderDurations({ // const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task + // same issue in RundownHeader.tsx const expectedDuration = playlist.timing.expectedDuration // Use remainingPlaylistDuration which includes current part's remaining time From 1f5129623ed688b507ac0223ea77c61e0bce2f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 18 Mar 2026 18:14:17 +0100 Subject: [PATCH 128/136] fix: bottom align solo timers in advanced view --- .../src/client/ui/RundownView/RundownHeader/RundownHeader.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index c64ff8da36..815825578f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -472,7 +472,7 @@ .rundown-header__show-timers { display: flex; - align-items: flex-start; + align-items: flex-end; gap: 1em; cursor: pointer; background: none; From e513f4d650eee4dd65a25dba252801db6c69c600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 18 Mar 2026 18:25:06 +0100 Subject: [PATCH 129/136] fix: allow showing diff in duration based show before start --- .../ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx index 3fc8e6eb78..61790953e4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -18,7 +18,7 @@ export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDis if (overUnderClock === undefined) return null // Hide diff in untimed mode before first timing take - if (PlaylistTiming.isPlaylistTimingNone(playlist.timing) && !playlist.startedPlayback) { + if (PlaylistTiming.isPlaylistTimingNone(playlist.timing) && playlist.timing.expectedDuration === undefined && !playlist.startedPlayback) { return null } From 8edcb6feff381caa67d846778de6c560f52224ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Mon, 23 Mar 2026 10:04:10 +0100 Subject: [PATCH 130/136] fix: correctly show planned start, start in and started --- .../RundownHeader/RundownHeaderPlannedStart.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 9d0324413f..92dbc88e2c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -16,22 +16,22 @@ export function RundownHeaderPlannedStart({ const timingDurations = useTiming() const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - if (expectedStart == null) return null const now = timingDurations.currentTime ?? Date.now() - const diff = now - expectedStart + const startsIn = now - (expectedStart ?? 0) return (
- {!simplified && expectedStart !== undefined ? ( + {!simplified && expectedStart !== undefined && ( - ) : null} - {playlist.startedPlayback !== undefined ? ( + )} + {playlist.startedPlayback !== undefined && ( - ) : ( + )} + {playlist.startedPlayback === undefined && expectedStart !== undefined && ( - {diff >= 0 && '+'} - {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} + {startsIn >= 0 && '+'} + {RundownUtils.formatDiffToTimecode(Math.abs(startsIn), false, false, true, true, true)} )}
From ae842767fa9815c426c0ab4fa79c5fa1640ace21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Mon, 23 Mar 2026 10:04:38 +0100 Subject: [PATCH 131/136] fix: correctly show est end before planned start time --- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 8ab2570982..8b44c60416 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -14,12 +14,13 @@ export function RundownHeaderExpectedEnd({ const { t } = useTranslation() const timingDurations = useTiming() + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) const now = timingDurations.currentTime ?? Date.now() // Use remainingPlaylistDuration which includes current part's remaining time const estEnd = - timingDurations.remainingPlaylistDuration !== undefined ? now + timingDurations.remainingPlaylistDuration : null + timingDurations.remainingPlaylistDuration !== undefined ? Math.max(now, expectedStart ?? now) + timingDurations.remainingPlaylistDuration : null if (expectedEnd === undefined && estEnd === null) return null From 2babf3391647d747f6b20a117c25f622d6045cc8 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 26 Mar 2026 15:55:03 +0000 Subject: [PATCH 132/136] Fix linting --- .../client/ui/RundownView/RundownHeader/RundownHeader.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- .../RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 6 ++++-- .../RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 5 +---- .../RundownHeader/RundownHeaderTimingDisplay.tsx | 6 +++++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 9713542838..2972c8cfbd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -54,12 +54,12 @@ export function RundownHeader({ const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - + // const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task // same issue in RundownHeaderDuration.tsx - const expectedDuration = playlist.timing.expectedDuration + const expectedDuration = playlist.timing.expectedDuration const hasSimple = !!(expectedStart || expectedDuration || expectedEnd) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index aedb2dde26..d052a131d3 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -18,7 +18,7 @@ export function RundownHeaderDurations({ // @todo: this _should_ use PlaylistTiming.getExpectedDuration as show above, // but I don't dare changing its behaviour to return for PlaylistTimingType.None within the scope of this task // same issue in RundownHeader.tsx - const expectedDuration = playlist.timing.expectedDuration + const expectedDuration = playlist.timing.expectedDuration // Use remainingPlaylistDuration which includes current part's remaining time const estDuration = timingDurations.remainingPlaylistDuration diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 8b44c60416..b895e1e13e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -14,13 +14,15 @@ export function RundownHeaderExpectedEnd({ const { t } = useTranslation() const timingDurations = useTiming() - const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) const now = timingDurations.currentTime ?? Date.now() // Use remainingPlaylistDuration which includes current part's remaining time const estEnd = - timingDurations.remainingPlaylistDuration !== undefined ? Math.max(now, expectedStart ?? now) + timingDurations.remainingPlaylistDuration : null + timingDurations.remainingPlaylistDuration !== undefined + ? Math.max(now, expectedStart ?? now) + timingDurations.remainingPlaylistDuration + : null if (expectedEnd === undefined && estEnd === null) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 92dbc88e2c..148e5c2ee6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -16,7 +16,6 @@ export function RundownHeaderPlannedStart({ const timingDurations = useTiming() const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const now = timingDurations.currentTime ?? Date.now() const startsIn = now - (expectedStart ?? 0) @@ -25,9 +24,7 @@ export function RundownHeaderPlannedStart({ {!simplified && expectedStart !== undefined && ( )} - {playlist.startedPlayback !== undefined && ( - - )} + {playlist.startedPlayback !== undefined && } {playlist.startedPlayback === undefined && expectedStart !== undefined && ( {startsIn >= 0 && '+'} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx index 61790953e4..2b9ca4ee74 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -18,7 +18,11 @@ export function RundownHeaderTimingDisplay({ playlist }: IRundownHeaderTimingDis if (overUnderClock === undefined) return null // Hide diff in untimed mode before first timing take - if (PlaylistTiming.isPlaylistTimingNone(playlist.timing) && playlist.timing.expectedDuration === undefined && !playlist.startedPlayback) { + if ( + PlaylistTiming.isPlaylistTimingNone(playlist.timing) && + playlist.timing.expectedDuration === undefined && + !playlist.startedPlayback + ) { return null } From 0958c2fafd02327f043ebfcca700bf09a4979e1c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 26 Mar 2026 12:38:52 +0000 Subject: [PATCH 133/136] Move T-Timer types to blueprints integration so they can be shared Instead of having them in corelib --- .../src/context/index.ts | 1 + .../src/context/tTimersContext.ts | 70 +++++++++++++++++ .../corelib/src/dataModel/RundownPlaylist.ts | 78 ++----------------- .../context/services/TTimersService.ts | 7 +- packages/job-worker/src/playout/tTimers.ts | 8 +- 5 files changed, 82 insertions(+), 82 deletions(-) diff --git a/packages/blueprints-integration/src/context/index.ts b/packages/blueprints-integration/src/context/index.ts index a1cba0ab9f..28e9a4ed2d 100644 --- a/packages/blueprints-integration/src/context/index.ts +++ b/packages/blueprints-integration/src/context/index.ts @@ -11,3 +11,4 @@ export * from './rundownContext.js' export * from './showStyleContext.js' export * from './studioContext.js' export * from './syncIngestChangesContext.js' +export * from './tTimersContext.js' diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 7b00d9258a..19206d91b2 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -1,5 +1,75 @@ export type IPlaylistTTimerIndex = 1 | 2 | 3 +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay + +export interface RundownTTimerModeFreeRun { + readonly type: 'freeRun' +} +export interface RundownTTimerModeCountdown { + readonly type: 'countdown' + /** + * The original duration of the countdown in milliseconds, so that we know what value to reset to + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean +} +export interface RundownTTimerModeTimeOfDay { + readonly type: 'timeOfDay' + + /** + * 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 +} + +/** + * 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). + * + * 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 = + | { + /** Whether the timer is paused */ + 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 interface ITTimersContext { /** * Get a T-timer by its index diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 06cf6d3ff5..0641dd44b7 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -1,4 +1,10 @@ -import { Time, TimelinePersistentState, RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' +import { + Time, + TimelinePersistentState, + RundownPlaylistTiming, + RundownTTimerMode, + TimerState, +} from '@sofie-automation/blueprints-integration' import { PartId, PieceInstanceInfiniteId, @@ -94,76 +100,6 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } -export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay - -export interface RundownTTimerModeFreeRun { - readonly type: 'freeRun' -} -export interface RundownTTimerModeCountdown { - readonly type: 'countdown' - /** - * The original duration of the countdown in milliseconds, so that we know what value to reset to - */ - readonly duration: number - - /** - * If the countdown should stop at zero, or continue into negative values - */ - readonly stopAtZero: boolean -} -export interface RundownTTimerModeTimeOfDay { - readonly type: 'timeOfDay' - - /** - * 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 -} - -/** - * 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). - * - * 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 = - | { - /** Whether the timer is paused */ - 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 - } - /** * Calculate the current duration for a timer state. * Handles paused, auto-pause (pauseTime), and running states. diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index ab0a67452d..834ac3371a 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -1,12 +1,9 @@ import type { IPlaylistTTimer, IPlaylistTTimerState, -} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' -import type { - RundownTTimer, - RundownTTimerIndex, TimerState, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimer, RundownTTimerIndex } 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, unprotectString } from '@sofie-automation/corelib/dist/protectedString' diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index dc6d9524a0..09e9dd0057 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,9 +1,5 @@ -import type { - RundownTTimerIndex, - RundownTTimerMode, - RundownTTimer, - TimerState, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimerIndex, RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimerMode, TimerState } from '@sofie-automation/blueprints-integration' import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' From 3d29f1d1cdb2d15f798b543b0d57ad8e25e1dd4c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 23 Mar 2026 18:41:53 +0000 Subject: [PATCH 134/136] feat: align T-Timer blueprint API with DB structure Expose mode and state properties separately in IPlaylistTTimer, matching the RundownTTimer database model structure. This removes the computed IPlaylistTTimerState abstraction layer and allows blueprints to access timer.mode and timer.state directly, consistent with how the WebUI already works. Changes: - Add mode: RundownTTimerMode | null to IPlaylistTTimer - Add state: TimerState | null to IPlaylistTTimer - Remove IPlaylistTTimerState union and related interfaces - Simplify PlaylistTTimerImpl getters to return DB properties directly - Remove unnecessary tests --- .../src/context/tTimersContext.ts | 55 ++------ .../context/services/TTimersService.ts | 44 +------ .../services/__tests__/TTimersService.test.ts | 121 ------------------ 3 files changed, 16 insertions(+), 204 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 19206d91b2..a93b9ef786 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -90,11 +90,19 @@ export interface IPlaylistTTimer { /** The label of the T-timer */ readonly label: string + /** + * The current mode of the T-timer + * Null if the T-timer is not initialized + * This defines how the timer behaves + */ + readonly mode: RundownTTimerMode | null + /** * The current state of the T-timer * Null if the T-timer is not initialized + * This contains the timing information needed to calculate the current time of the timer */ - readonly state: IPlaylistTTimerState | null + readonly state: TimerState | null /** Set the label of the T-timer */ setLabel(label: string): void @@ -187,48 +195,3 @@ export interface IPlaylistTTimer { */ setProjectedDuration(duration: number, paused?: boolean): void } - -export type IPlaylistTTimerState = - | IPlaylistTTimerStateCountdown - | IPlaylistTTimerStateFreeRun - | IPlaylistTTimerStateTimeOfDay - -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 -} - -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/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 834ac3371a..24d14ce865 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -1,11 +1,11 @@ import type { IPlaylistTTimer, - IPlaylistTTimerState, + RundownTTimerMode, TimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } 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 { literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' @@ -73,41 +73,11 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { get label(): string { return this.#timer.label } - get state(): IPlaylistTTimerState | null { - const rawMode = this.#timer.mode - const rawState = this.#timer.state - - if (!rawMode || !rawState) return null - - const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() - - switch (rawMode.type) { - case 'countdown': - return { - mode: 'countdown', - currentTime, - duration: rawMode.duration, - paused: rawState.paused, - stopAtZero: rawMode.stopAtZero, - } - case 'freeRun': - return { - mode: 'freeRun', - currentTime, - paused: rawState.paused, - } - case 'timeOfDay': - return { - mode: 'timeOfDay', - currentTime, - targetTime: rawState.paused ? 0 : rawState.zeroTime, - targetRaw: rawMode.targetRaw, - stopAtZero: rawMode.stopAtZero, - } - default: - assertNever(rawMode) - return null - } + get mode(): RundownTTimerMode | null { + return this.#timer.mode + } + get state(): TimerState | null { + return this.#timer.state } constructor( 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 72236e2d51..d7f5237eb7 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 @@ -193,127 +193,6 @@ describe('PlaylistTTimerImpl', () => { expect(timer.state).toBeNull() }) - - it('should return running freeRun state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun' } - tTimers[0].state = { paused: false, zeroTime: 15000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - 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' } - tTimers[0].state = { paused: true, duration: 3000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - 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', - duration: 60000, - stopAtZero: true, - } - tTimers[0].state = { paused: false, zeroTime: 15000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - 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', - duration: 60000, - stopAtZero: false, - } - tTimers[0].state = { paused: true, duration: 2000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'countdown', - currentTime: 2000, // 7000 - 5000 - duration: 60000, - paused: true, // pauseTime is set = paused - stopAtZero: false, - }) - }) - - it('should return timeOfDay state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { - type: 'timeOfDay', - targetRaw: '15:30', - stopAtZero: true, - } - tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - 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', - targetRaw: targetTimestamp, - stopAtZero: false, - } - tTimers[0].state = { paused: false, zeroTime: targetTimestamp } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'timeOfDay', - currentTime: targetTimestamp - 10000, // targetTime - getCurrentTime() - targetTime: targetTimestamp, - targetRaw: targetTimestamp, - stopAtZero: false, - }) - }) }) describe('setLabel', () => { From ddcafc8ab6619bc110ec94bc9575ac5fd95f0d40 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 25 Mar 2026 16:49:49 +0000 Subject: [PATCH 135/136] feat(blueprints): Add T-Timer duration/timing methods and expose rundown timing Add comprehensive T-Timer manipulation methods to IPlaylistTTimer interface: - setDuration(duration) - Reset timer to a specific duration - setDuration(options) - Independently update original and/or current duration - original: Duration timer resets to on restart() - current: Current countdown value - Preserves elapsed time when only original is provided Add T-Timer query methods: - getCurrentDuration() - Get current timer value in milliseconds - getZeroTime() - Get absolute timestamp of timer's zero point - getProjectedDuration() - Get projected countdown value (for over/under calculation) - getProjectedZeroTime() - Get projected zero time timestamp Add shared utility functions in corelib: - timerStateToDuration() - Calculate current duration from TimerState (already existed) - timerStateToZeroTime() - Calculate zero time from TimerState (new) - Both shared between backend and frontend for consistent calculations Expose timing information to blueprints: - Add timing field to IBlueprintSegmentRundown interface - Exposes RundownPlaylistTiming via context.rundown.timing - Removes need for accessing private _rundown property Implementation: - PlaylistTTimerImpl implements all new methods using shared utilities - Update convertRundownToBlueprintSegmentRundown() to include timing - All methods properly handle paused/running states and edge cases Related to BBC-SOFIE-454 --- .../src/context/tTimersContext.ts | 57 ++++++++++ .../src/documents/rundown.ts | 3 + .../corelib/src/dataModel/RundownPlaylist.ts | 23 ++++ .../job-worker/src/blueprints/context/lib.ts | 1 + .../context/services/TTimersService.ts | 104 ++++++++++++++++++ 5 files changed, 188 insertions(+) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index a93b9ef786..aba8e72221 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -150,6 +150,29 @@ export interface IPlaylistTTimer { */ restart(): boolean + /** + * Set the duration of a countdown timer + * This resets both the original duration (what restart() resets to) and the current countdown value. + * @param duration New duration in milliseconds + * @throws If timer is not in countdown mode or not initialized + */ + setDuration(duration: number): void + + /** + * Update the original duration (reset-to value) and/or current duration of a countdown timer + * This allows you to independently update: + * - `original`: The duration the timer resets to when restart() is called + * - `current`: The current countdown value (what's displayed now) + * + * If only `original` is provided, the current duration is recalculated to preserve elapsed time. + * If only `current` is provided, just the current countdown is updated. + * If both are provided, both values are updated independently. + * + * @param options Object with optional `original` and/or `current` duration in milliseconds + * @throws If timer is not in countdown mode or not initialized + */ + setDuration(options: { original?: number; current?: number }): void + /** * Clear any projection (manual or anchor-based) for this timer * This removes both manual projections set via setProjectedTime/setProjectedDuration @@ -194,4 +217,38 @@ export interface IPlaylistTTimer { * If false (default), we're progressing normally (projection counts down in real-time). */ setProjectedDuration(duration: number, paused?: boolean): void + + /** + * Get the current duration of the timer in milliseconds + * For countdown timers, this returns how much time is remaining (can be negative if past zero) + * For timeOfDay timers, this returns time until/since the target time + * For freeRun timers, this returns how much time has elapsed + * @returns Current duration in milliseconds, or null if timer is not initialized + */ + getDuration(): number | null + + /** + * Get the zero time (reference point) for the timer + * - For countdown/timeOfDay timers: the absolute timestamp when the timer reaches zero + * - For freeRun timers: the absolute timestamp when the timer started (what it counts from) + * For paused timers, calculates when zero would be if resumed now. + * @returns Unix timestamp in milliseconds, or null if timer is not initialized + */ + getZeroTime(): number | null + + /** + * Get the projected duration in milliseconds + * This returns the projected timer value when we expect to reach the anchor part. + * Used to calculate over/under (how far ahead or behind schedule we are). + * @returns Projected duration in milliseconds, or null if no projection is set + */ + getProjectedDuration(): number | null + + /** + * Get the projected zero time (reference point) + * This returns when we project the timer will reach zero based on scheduled durations. + * For paused projections (when pushing/delayed), calculates when zero would be if resumed now. + * @returns Unix timestamp in milliseconds, or null if no projection is set + */ + getProjectedZeroTime(): number | null } diff --git a/packages/blueprints-integration/src/documents/rundown.ts b/packages/blueprints-integration/src/documents/rundown.ts index 9daa30383a..f8c0840573 100644 --- a/packages/blueprints-integration/src/documents/rundown.ts +++ b/packages/blueprints-integration/src/documents/rundown.ts @@ -55,6 +55,9 @@ export interface IBlueprintRundownDBData { export interface IBlueprintSegmentRundown { externalId: string + /** Rundown timing information */ + timing: RundownPlaylistTiming + /** Arbitraty data storage for internal use in the blueprints */ privateData?: TPrivateData /** Arbitraty data relevant for other systems, made available to them through APIs */ diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 0641dd44b7..6522b9bdba 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -121,6 +121,29 @@ export function timerStateToDuration(state: TimerState, now: number): number { } } +/** + * Get the zero time (reference timestamp) for a timer state. + * - For countdown/timeOfDay timers: when the timer reaches zero + * - For freeRun timers: when the timer started (what it counts from) + * For paused timers, calculates when zero would be if resumed now. + * + * @param state The timer state + * @param now Current timestamp in milliseconds + * @returns The zero time timestamp in milliseconds + */ +export function timerStateToZeroTime(state: TimerState, now: number): number { + if (state.paused) { + // Calculate when zero would be if we resumed now + return now + state.duration + } else if (state.pauseTime && now >= state.pauseTime) { + // Auto-pause at overrun (current part ended) + return state.zeroTime - state.pauseTime + now + } else { + // Already have the zero time + return state.zeroTime + } +} + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index f16ee424c0..513ca6bd12 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -443,6 +443,7 @@ export function convertRundownToBlueprintSegmentRundown( ): IBlueprintSegmentRundown { const obj: Complete = { externalId: rundown.externalId, + timing: rundown.timing, privateData: skipClone ? rundown.privateData : clone(rundown.privateData), publicData: skipClone ? rundown.publicData : clone(rundown.publicData), } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 24d14ce865..94933cf5cd 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -4,6 +4,7 @@ import type { TimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { timerStateToDuration, timerStateToZeroTime } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -162,6 +163,77 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { return true } + setDuration(durationOrOptions: number | { original?: number; current?: number }): void { + // Handle overloaded signatures + if (typeof durationOrOptions === 'number') { + // Simple case: reset timer to this duration + return this.setDuration({ original: durationOrOptions, current: durationOrOptions }) + } + + // Options case: independently update original and/or current + const options = durationOrOptions + + if (options.original !== undefined && options.original <= 0) { + throw new Error('Original duration must be greater than zero') + } + if (options.current !== undefined && options.current <= 0) { + throw new Error('Current duration must be greater than zero') + } + + if (!this.#timer.mode || this.#timer.mode.type !== 'countdown') { + throw new Error('Timer must be in countdown mode to update duration') + } + + if (!this.#timer.state) { + throw new Error('Timer is not initialized') + } + + if (!options.original && !options.current) { + throw new Error('At least one of original or current duration must be provided') + } + + const now = getCurrentTime() + const state = this.#timer.state + + // Calculate current elapsed time using built-in function (handles pauseTime correctly) + const remaining = timerStateToDuration(state, now) + const elapsed = this.#timer.mode.duration - remaining + + let newOriginalDuration: number + let newCurrentRemaining: number + + if (options.original !== undefined && options.current !== undefined) { + // Both specified: use both values independently + newOriginalDuration = options.original + newCurrentRemaining = options.current + } else if (options.original !== undefined) { + // Only original specified: preserve elapsed time + newOriginalDuration = options.original + newCurrentRemaining = Math.max(0, newOriginalDuration - elapsed) + } else if (options.current !== undefined) { + // Only current specified: keep original unchanged + newOriginalDuration = this.#timer.mode.duration + newCurrentRemaining = options.current + } else { + // This should be unreachable due to earlier check + throw new Error('Invalid duration update options') + } + + // Update both mode and state + this.#timer = { + ...this.#timer, + mode: { + ...this.#timer.mode, + duration: newOriginalDuration, + }, + state: state.paused + ? { paused: true, duration: newCurrentRemaining } + : { paused: false, zeroTime: now + newCurrentRemaining }, + } + + this.#emitChange(this.#timer) + } + clearProjected(): void { this.#timer = { ...this.#timer, @@ -215,4 +287,36 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } this.#emitChange(this.#timer) } + + getDuration(): number | null { + if (!this.#timer.state) { + return null + } + + return timerStateToDuration(this.#timer.state, getCurrentTime()) + } + + getZeroTime(): number | null { + if (!this.#timer.state) { + return null + } + + return timerStateToZeroTime(this.#timer.state, getCurrentTime()) + } + + getProjectedDuration(): number | null { + if (!this.#timer.projectedState) { + return null + } + + return timerStateToDuration(this.#timer.projectedState, getCurrentTime()) + } + + getProjectedZeroTime(): number | null { + if (!this.#timer.projectedState) { + return null + } + + return timerStateToZeroTime(this.#timer.projectedState, getCurrentTime()) + } } From 93069ea8030d31d022c56138ab32b666c24c6a75 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 26 Mar 2026 20:15:19 +0000 Subject: [PATCH 136/136] feat: expose startedPlayback to blueprint contexts via getter properties Add startedPlayback property to IRundownContext interface and implement as getters in RundownActivationContext and SyncIngestUpdateToPartInstanceContext. This provides blueprints access to playlist.startedPlayback timing data. Part of BBC-SOFIE-454 --- .../blueprints-integration/src/context/rundownContext.ts | 4 ++++ .../src/blueprints/context/RundownActivationContext.ts | 5 +++++ .../context/SyncIngestUpdateToPartInstanceContext.ts | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index cb57cbd569..702d885bee 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -5,11 +5,15 @@ import type { IShowStyleContext } from './showStyleContext.js' import type { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import type { IDataStoreMethods } from './adlibActionContext.js' import { ITTimersContext } from './tTimersContext.js' +import type { Time } from '../common.js' export interface IRundownContext extends IShowStyleContext { readonly rundownId: string readonly playlistId: string readonly rundown: Readonly + + /** Actual time of playback starting for the playlist (undefined if not started) */ + readonly startedPlayback?: Time } export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index 5335d041bc..71cd3bab1e 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -4,6 +4,7 @@ import { IRundownActivationContext, IRundownActivationContextState, TSR, + Time, } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceId } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { ReadonlyDeep } from 'type-fest' @@ -58,6 +59,10 @@ export class RundownActivationContext extends RundownEventContext implements IRu return this._currentState } + get startedPlayback(): Time | undefined { + return this._playoutModel.playlist.startedPlayback + } + async listPlayoutDevices(): Promise { return listPlayoutDevices(this._context, this._playoutModel) } diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 61e2dcb486..6e44cb8771 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -17,6 +17,7 @@ import { IBlueprintPartInstance, SomeContent, WithTimeline, + Time, } from '@sofie-automation/blueprints-integration' import { postProcessPieces, postProcessTimelineObjects } from '../postProcess.js' import { @@ -61,6 +62,10 @@ export class SyncIngestUpdateToPartInstanceContext return Array.from(this.#changedTTimers.values()) } + public get startedPlayback(): Time | undefined { + return this.#playoutModel.playlist.startedPlayback + } + constructor( context: JobContext, playoutModel: PlayoutModel,