diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index bf9e8fd75e..0bc1d260dc 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], } } export function defaultRundown( diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 6b7cc68514..b5f6ce1d78 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -622,6 +622,11 @@ describe('cronjobs', () => { type: PlaylistTimingType.None, }, activationId: protectString(''), + tTimers: [ + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], }) 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..31712163a5 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], + }, + }, + { multi: true } + ) + }, + }, + // Add your migration here ]) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 7b66ee3c2a..d44156de6c 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1358,6 +1358,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" mongodb: "npm:^6.21.0" @@ -2188,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: @@ -2983,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: @@ -2993,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" @@ -3089,6 +3109,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" @@ -6003,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: @@ -6775,8 +6811,8 @@ __metadata: linkType: hard "koa@npm:^2.13.4": - version: 2.16.4 - resolution: "koa@npm:2.16.4" + version: 2.15.3 + resolution: "koa@npm:2.15.3" dependencies: accepts: "npm:^1.3.5" cache-content-type: "npm:^1.0.0" @@ -6801,7 +6837,7 @@ __metadata: statuses: "npm:^1.5.0" type-is: "npm:^1.6.16" vary: "npm:^1.1.2" - checksum: 10/f49e76c2cb7db4facbf215eef964c1eb3f0012c2f64490dfd9b349727e11c7f429f4bf16a47f725e41325415ffebefab0ca6ece3b1187518b42f979e4dbf6e01 + checksum: 10/b2c2771a4ee5268f9d039ce025b9c3798a0baba8c3cf3895a6fc2d286363e0cd2c98c02a5b87f14100baa2bc17d854eed6ed80f9bd41afda1d056f803b206514 languageName: node linkType: hard @@ -6984,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 @@ -8532,11 +8568,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 @@ -9058,7 +9094,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 +9103,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" @@ -9113,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: @@ -10624,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: 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/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/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/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/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 009d7052ad..114ef0f47c 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -6,19 +6,21 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IShowStyleUserContext, } from '../index.js' 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 @@ -56,7 +58,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/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/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/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index 402da1fa39..702d885bee 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -4,16 +4,22 @@ 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' +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 {} -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/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/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts new file mode 100644 index 0000000000..aba8e72221 --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,254 @@ +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 + * 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 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: TimerState | 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 + + /** + * 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 + * and automatic projections based on anchor parts set via setProjectedAnchorPart. + */ + clearProjected(): void + + /** + * 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 projection accordingly. + * Clears any manual projection set via setProjectedTime/setProjectedDuration. + * @param partId The ID of the part to use as timing anchor + */ + setProjectedAnchorPart(partId: string): void + + /** + * 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 projection set via setProjectedTime/setProjectedDuration. + * @param externalId The externalId of the part to use as timing anchor + */ + setProjectedAnchorPartByExternalId(externalId: string): void + + /** + * 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 (projection won't update with time passing). + * If false (default), we're progressing normally (projection counts down in real-time). + */ + setProjectedTime(time: number, paused?: boolean): void + + /** + * 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 (projection won't update with time passing). + * 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 d8bc66f809..6522b9bdba 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,6 +100,97 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +/** + * 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 + } +} + +/** + * 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 { + readonly index: RundownTTimerIndex + + /** A label for the timer */ + label: string + + /** 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 + + /** 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 projection and the timer's target (state.zeroTime). + * + * 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. + */ + 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 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 + + /* + * 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 +279,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/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index de354d3202..961c7b7dfd 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 projections based on current playlist state + * Called after setNext, takes, and ingest changes to update timing anchor projections + */ + RecalculateTTimerProjections = 'recalculateTTimerProjections', + /** * 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.RecalculateTTimerProjections]: () => void + [StudioJobs.UpdateStudioBaseline]: () => string | false [StudioJobs.CleanupEmptyPlaylists]: () => void 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..c881344721 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], } } export function defaultRundown( 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 543d37d5df..1d168e84f8 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' @@ -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 = TTimersService.withPlayoutModel(playoutModel, context) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -78,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) } @@ -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..e028b31f1d 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' @@ -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 = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { @@ -80,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) } @@ -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..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' @@ -13,10 +14,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 +48,8 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._playoutModel = options.playoutModel this._previousState = options.previousState this._currentState = options.currentState + + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel, this._context) } get previousState(): IRundownActivationContextState { @@ -52,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) } @@ -75,4 +86,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/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index d8289be7d9..6e44cb8771 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' @@ -16,6 +17,7 @@ import { IBlueprintPartInstance, SomeContent, WithTimeline, + Time, } from '@sofie-automation/blueprints-integration' import { postProcessPieces, postProcessTimelineObjects } from '../postProcess.js' import { @@ -32,24 +34,45 @@ 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 #playoutModel: PlayoutModel + 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()) + } + + public get startedPlayback(): Time | undefined { + return this.#playoutModel.playlist.startedPlayback } constructor( - private readonly _context: JobContext, + context: JobContext, + playoutModel: PlayoutModel, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, + playlist: ReadonlyDeep, rundown: ReadonlyDeep, partInstance: PlayoutPartInstanceModel, proposedPieceInstances: ReadonlyDeep, @@ -58,32 +81,49 @@ 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.#playoutModel = playoutModel + this.#partInstance = partInstance + + this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#tTimersService = new TTimersService( + playlist.tTimers, + (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }, + this.#playoutModel, + this.#context + ) + } - this._proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + 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 +132,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 +143,7 @@ export class SyncIngestUpdateToPartInstanceContext ...proposedPieceInstance, piece: piece, } - this.partInstance.mergeOrInsertPieceInstance(newPieceInstance) + this.#partInstance.mergeOrInsertPieceInstance(newPieceInstance) return convertPieceInstanceToBlueprints(newPieceInstance) } @@ -111,19 +151,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 +174,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 +207,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 +225,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 0510481b19..80b4b31244 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' @@ -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 = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { @@ -130,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) } @@ -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/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/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 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..94933cf5cd --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,322 @@ +import type { + IPlaylistTTimer, + RundownTTimerMode, + 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' +import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' +import { ReadonlyDeep } from 'type-fest' +import { + createCountdownTTimer, + createFreeRunTTimer, + createTimeOfDayTTimer, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, + recalculateTTimerProjections, +} 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, + playoutModel: PlayoutModel, + jobContext: JobContext + ) { + this.timers = [ + 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, jobContext: JobContext): TTimersService { + return new TTimersService( + playoutModel.playlist.tTimers, + (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }, + playoutModel, + jobContext + ) + } + + 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 #emitChange: (updatedTimer: ReadonlyDeep) => void + readonly #playoutModel: PlayoutModel + readonly #jobContext: JobContext + + #timer: ReadonlyDeep + + get index(): RundownTTimerIndex { + return this.#timer.index + } + get label(): string { + return this.#timer.label + } + get mode(): RundownTTimerMode | null { + return this.#timer.mode + } + get state(): TimerState | null { + return this.#timer.state + } + + 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 { + this.#timer = { + ...this.#timer, + label: label, + } + this.#emitChange(this.#timer) + } + clearTimer(): void { + this.#timer = { + ...this.#timer, + mode: null, + state: null, + } + this.#emitChange(this.#timer) + } + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { + 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.#timer = { + ...this.#timer, + ...createTimeOfDayTTimer(targetTime, { + stopAtZero: options?.stopAtZero ?? true, + }), + } + this.#emitChange(this.#timer) + } + startFreeRun(options?: { startPaused?: boolean }): void { + this.#timer = { + ...this.#timer, + ...createFreeRunTTimer({ + startPaused: options?.startPaused ?? false, + }), + } + this.#emitChange(this.#timer) + } + pause(): boolean { + const newTimer = pauseTTimer(this.#timer) + if (!newTimer) return false + + this.#timer = newTimer + this.#emitChange(newTimer) + return true + } + resume(): boolean { + const newTimer = resumeTTimer(this.#timer) + if (!newTimer) return false + + this.#timer = newTimer + this.#emitChange(newTimer) + return true + } + restart(): boolean { + const newTimer = restartTTimer(this.#timer) + if (!newTimer) return false + + this.#timer = newTimer + this.#emitChange(newTimer) + 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, + anchorPartId: undefined, + projectedState: undefined, + } + this.#emitChange(this.#timer) + } + + setProjectedAnchorPart(partId: string): void { + this.#timer = { + ...this.#timer, + anchorPartId: protectString(partId), + projectedState: undefined, // Clear manual projection + } + this.#emitChange(this.#timer) + + // Recalculate projections immediately since we already have the playout model + recalculateTTimerProjections(this.#jobContext, this.#playoutModel) + } + + setProjectedAnchorPartByExternalId(externalId: string): void { + const part = this.#playoutModel.getAllOrderedParts().find((p) => p.externalId === externalId) + if (!part) return + + this.setProjectedAnchorPart(unprotectString(part._id)) + } + + 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 + projectedState, + } + this.#emitChange(this.#timer) + } + + 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 + projectedState, + } + 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()) + } +} 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..d7f5237eb7 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,948 @@ +/* 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' +import type { JobContext } from '../../../../jobs/index.js' + +function createMockJobContext(): MockProxy { + return mock() +} + +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, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, + ] +} + +describe('TTimersService', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('constructor', () => { + 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, mockPlayoutModel, mockJobContext) + + expect(service.timers).toHaveLength(3) + expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[1]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[2]).toBeInstanceOf(PlaylistTTimerImpl) + }) + }) + + it('from playout model', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const mockJobContext = createMockJobContext() + + const service = TTimersService.withPlayoutModel(mockPlayoutModel, mockJobContext) + 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 timers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() + + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) + + const timer = service.getTimer(1) + + expect(timer).toBe(service.timers[0]) + }) + + 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, mockPlayoutModel, mockJobContext) + + const timer = service.getTimer(2) + + expect(timer).toBe(service.timers[1]) + }) + + 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, mockPlayoutModel, mockJobContext) + + const timer = service.getTimer(3) + + expect(timer).toBe(service.timers[2]) + }) + + 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, 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') + }) + }) + + describe('clearAllTimers', () => { + it('should call clearTimer on all timers', () => { + const tTimers = createEmptyTTimers() + 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 updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + + const service = new TTimersService(tTimers, updateFn, mockPlayoutModel, mockJobContext) + + service.clearAllTimers() + + // updateTTimer should have been called 3 times (once for each timer) + 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 })) + }) + }) +}) + +describe('PlaylistTTimerImpl', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('getters', () => { + it('should return the correct index', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) + + expect(timer.index).toBe(2) + }) + + it('should return the correct label', () => { + const tTimers = createEmptyTTimers() + tTimers[1].label = 'Custom Label' + const updateFn = jest.fn() + 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 mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + expect(timer.state).toBeNull() + }) + }) + + describe('setLabel', () => { + it('should update the label', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setLabel('New Label') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'New Label', + mode: null, + state: null, + }) + }) + }) + + describe('clearTimer', () => { + it('should clear the timer mode', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearTimer() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }) + }) + }) + + describe('startCountdown', () => { + it('should start a running countdown with default options', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startCountdown(60000) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, + }) + }) + + it('should start a paused countdown', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + duration: 30000, + stopAtZero: false, + }, + state: { paused: true, duration: 30000 }, + }) + }) + }) + + describe('startFreeRun', () => { + it('should start a running free-run timer', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startFreeRun() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 10000 }, + }) + }) + + it('should start a paused free-run timer', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startFreeRun({ startPaused: true }) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 0 }, + }) + }) + }) + + describe('startTimeOfDay', () => { + it('should start a timeOfDay timer with time string', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startTimeOfDay('15:30') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, + }) + }) + + it('should start a timeOfDay timer with numeric timestamp', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + const targetTimestamp = 1737331200000 + + timer.startTimeOfDay(targetTimestamp) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetRaw: targetTimestamp, + stopAtZero: true, + }, + state: { + paused: false, + zeroTime: targetTimestamp, + }, + }) + }) + + it('should start a timeOfDay timer with stopAtZero false', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startTimeOfDay('18:00', { stopAtZero: false }) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetRaw: '18:00', + stopAtZero: false, + }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), + }) + }) + + it('should start a timeOfDay timer with 12-hour format', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.startTimeOfDay('5:30pm') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetRaw: '5:30pm', + stopAtZero: true, + }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), + }) + }) + + it('should throw for invalid time string', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + 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 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') + }) + }) + + describe('pause', () => { + it('should pause a running freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.pause() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, + }) + }) + + it('should pause a running countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 70000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.pause() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 60000 }, + }) + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.pause() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + + it('should return false for timeOfDay timer (does not support pause)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 20000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.pause() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + }) + + describe('resume', () => { + it('should resume a paused freeRun timer', () => { + 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) + + const result = timer.resume() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + }, + 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' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.resume() + + // Returns true because timer supports resume, but it's already running + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.resume() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + + it('should return false for timeOfDay timer (does not support resume)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 20000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.resume() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + }) + + describe('restart', () => { + it('should restart a countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 40000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // reset to now + duration + }) + }) + + it('should restart a paused countdown timer (stays paused)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + duration: 60000, + stopAtZero: false, + } + tTimers[0].state = { paused: true, duration: 15000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + 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' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + + it('should restart a timeOfDay timer with valid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, + }) + }) + + it('should return false for timeOfDay timer with invalid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: 'invalid-time-string', + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 5000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + const result = timer.restart() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + }) + + describe('clearProjected', () => { + it('should clear both anchorPartId and projectedState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + 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.clearProjected() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: undefined, + }) + }) + + 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.clearProjected() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: undefined, + }) + }) + }) + + describe('setProjectedAnchorPart', () => { + it('should set anchorPartId and clear projectedState', () => { + const tTimers = createEmptyTTimers() + 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.setProjectedAnchorPart('part123') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: 'part123', + projectedState: 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.setProjectedAnchorPart('part456')).not.toThrow() + + // Job queue should not be called (recalculate is called directly) + expect(mockJobContext.queueStudioJob).not.toHaveBeenCalled() + }) + }) + + 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.setProjectedTime(50000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: { paused: false, zeroTime: 50000 }, + }) + }) + + 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.setProjectedTime(50000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) + }) + }) + + it('should clear anchorPartId when setting manual projection', () => { + 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.setProjectedTime(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.setProjectedTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + projectedState: { paused: false, zeroTime: 50000 }, + }) + ) + }) + }) + + 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.setProjectedDuration(30000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) + }) + }) + + 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.setProjectedDuration(30000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + projectedState: { paused: true, duration: 30000 }, + }) + }) + + it('should clear anchorPartId when setting manual projection', () => { + 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.setProjectedDuration(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.setProjectedDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + projectedState: { paused: false, zeroTime: 40000 }, + }) + ) + }) + }) +}) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 7631c647c5..54b97fb011 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], }) const rundown = (await context.mockCollections.Rundowns.findOne(rundownId)) as DBRundown 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/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index b663ad3501..6fd99f4862 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) @@ -105,6 +118,9 @@ describe('SyncChangesToPartInstancesWorker', () => { { findPart: jest.fn(() => undefined), getGlobalPieces: jest.fn(() => []), + getAllOrderedParts: jest.fn(() => []), + getOrderedSegments: jest.fn(() => []), + findAdlibPiece: jest.fn(() => undefined), }, mockOptions ) @@ -315,6 +331,11 @@ describe('SyncChangesToPartInstancesWorker', () => { modified: 0, timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: 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..cc40fff715 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], }) await context.mockCollections.Rundowns.insertOne({ diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index f5b7f0a953..18c981dc4b 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 { recalculateTTimerProjections } 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 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 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 projections after playlist changes + recalculateTTimerProjections(context, playoutModel) + if (playoutModel.playlist.activationId || shouldUpdateTimeline) { triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) } 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..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 @@ -15,6 +15,26 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -307,6 +327,26 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -591,6 +631,26 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -896,6 +956,26 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -1191,6 +1271,26 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -1484,6 +1584,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -1745,6 +1865,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -2051,6 +2191,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -2365,6 +2525,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -2662,6 +2842,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -2959,6 +3159,26 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -3255,6 +3475,26 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -3544,6 +3784,26 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -3865,6 +4125,26 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, @@ -4162,6 +4442,26 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": null, + }, + ], "timing": { "type": "none", }, diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index 6f8352751c..41de01b1bf 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 { @@ -129,12 +130,14 @@ 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}`, }, this.#context.studio, this.#showStyle, + this.#playoutModel.playlist, instanceToSync.playoutRundownModel.rundown, existingPartInstance, proposedPieceInstances, @@ -152,6 +155,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)}`) @@ -189,7 +197,7 @@ export class SyncChangesToPartInstancesWorker { } } - collectNewIngestDataToSync( + private collectNewIngestDataToSync( partId: PartId, instanceToSync: PartInstanceToSync, proposedPieceInstances: PieceInstance[] @@ -204,7 +212,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: @@ -480,3 +499,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 +} 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..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 @@ -77,6 +77,26 @@ exports[`Playout API Basic rundown control 4`] = ` "resetTime": 0, "rundownIdsInOrder": [], "studioId": "mockStudio0", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 2, + "label": "", + "mode": null, + "state": null, + }, + { + "index": 3, + "label": "", + "mode": null, + "state": 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..d323e939ab --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,663 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useFakeCurrentTime, useRealCurrentTime } from '../../__mocks__/time.js' +import { + validateTTimerIndex, + pauseTTimer, + resumeTTimer, + restartTTimer, + createCountdownTTimer, + createFreeRunTTimer, + calculateNextTimeOfDayTarget, + createTimeOfDayTTimer, +} from '../tTimers.js' +import type { RundownTTimer } 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') + }) + + 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', () => { + it('should pause a running countdown timer', () => { + 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({ + 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: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // Started 5 seconds ago + } + + const result = pauseTTimer(timer) + + expect(result).toEqual({ + 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: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // already paused + } + + const result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + it('should return unchanged freeRun timer if already paused', () => { + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 5000 }, // already paused + } + + const result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + 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: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // 30 seconds remaining + } + + const result = resumeTTimer(timer) + + expect(result).toEqual({ + 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: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // 5 seconds elapsed + } + + const result = resumeTTimer(timer) + + expect(result).toEqual({ + 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: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // already running + } + + const result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + it('should return freeRun timer unchanged if already running', () => { + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // already running + } + + const result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + 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: 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({ + 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: 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({ + 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: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, + } + + expect(restartTTimer(timer)).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() + }) + }) + + describe('createCountdownTTimer', () => { + it('should create a running countdown timer', () => { + const result = createCountdownTTimer(60000, { + stopAtZero: true, + startPaused: false, + }) + + expect(result).toEqual({ + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) + }) + }) + + it('should create a paused countdown timer', () => { + const result = createCountdownTTimer(30000, { + stopAtZero: false, + startPaused: true, + }) + + expect(result).toEqual({ + mode: { + type: 'countdown', + duration: 30000, + stopAtZero: false, + }, + state: { paused: true, duration: 30000 }, + }) + }) + + 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({ + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 10000 }, // now + }) + }) + + it('should create a paused freeRun timer', () => { + const result = createFreeRunTTimer({ startPaused: true }) + + expect(result).toEqual({ + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 0 }, + }) + }) + }) + + 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({ + mode: { + type: 'timeOfDay', + stopAtZero: true, + targetRaw: '15:30', + }, + state: { + paused: false, + zeroTime: expect.any(Number), // Parsed target time + }, + }) + }) + + it('should create a timeOfDay timer with numeric timestamp', () => { + const timestamp = 1737331200000 + const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) + + expect(result).toEqual({ + mode: { + type: 'timeOfDay', + targetRaw: timestamp, + stopAtZero: false, + }, + state: { + paused: false, + zeroTime: timestamp, + }, + }) + }) + + 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: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, + } + + const result = restartTTimer(timer) + + expect(result).not.toBeNull() + expect(result?.mode).toEqual(timer.mode) + expect(result?.state).toEqual({ + paused: false, + zeroTime: expect.any(Number), // new target time + }) + 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: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 'invalid', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + + it('should return null for timeOfDay timer with unix timestamp', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 1737300000000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + }) +}) 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..6704e8255e --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -0,0 +1,211 @@ +import { setupDefaultJobEnvironment, MockJobContext } from '../../__mocks__/context.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' +import { literal } from '@sofie-automation/corelib/dist/lib' + +describe('tTimersJobs', () => { + let context: MockJobContext + + beforeEach(() => { + context = setupDefaultJobEnvironment() + }) + + describe('handleRecalculateTTimerProjections', () => { + 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(handleRecalculateTTimerProjections(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(handleRecalculateTTimerProjections(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(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(handleRecalculateTTimerProjections(context)).resolves.toBeUndefined() + }) + }) +}) 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 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/setNext.ts b/packages/job-worker/src/playout/setNext.ts index ebe5cdf675..2fb38067ae 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 { recalculateTTimerProjections } 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 projections based on the new next part + recalculateTTimerProjections(context, playoutModel) + if (span) span.end() } @@ -529,6 +533,10 @@ export async function queueNextSegment( } else { playoutModel.setQueuedSegment(null) } + + // 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 new file mode 100644 index 0000000000..09e9dd0057 --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,345 @@ +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' +import * as chrono from 'chrono-node' +import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { JobContext } from '../jobs/index.js' +import { PlayoutModel } from './model/PlayoutModel.js' +import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' + +export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { + if (![1, 2, 3].includes(index)) 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): 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, + state: { paused: true, duration: timer.state.zeroTime - 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): 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 + } + + return { + ...timer, + state: { paused: false, zeroTime: timer.state.duration + getCurrentTime() }, + } + } 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): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown') { + return { + ...timer, + state: timer.state.paused + ? { paused: true, duration: timer.mode.duration } + : { paused: false, zeroTime: getCurrentTime() + timer.mode.duration }, + } + } 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, + state: { paused: false, zeroTime: nextTime }, + } + } else { + return null + } +} + +/** + * 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 mode and state + */ +export function createCountdownTTimer( + duration: number, + options: { + stopAtZero: boolean + startPaused: boolean + } +): { mode: ReadonlyDeep; state: ReadonlyDeep } { + if (duration <= 0) throw new Error('Duration must be greater than zero') + + return { + mode: { + type: 'countdown', + duration, + stopAtZero: !!options.stopAtZero, + }, + state: options.startPaused + ? { paused: true, duration: duration } + : { paused: false, zeroTime: getCurrentTime() + duration }, + } +} + +export function createTimeOfDayTTimer( + targetTime: string | number, + options: { + stopAtZero: boolean + } +): { mode: ReadonlyDeep; state: ReadonlyDeep } { + const nextTime = calculateNextTimeOfDayTarget(targetTime) + if (nextTime === null) throw new Error('Unable to parse target time for timeOfDay T-timer') + + return { + mode: { + type: 'timeOfDay', + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + }, + state: { paused: false, zeroTime: nextTime }, + } +} + +/** + * Create a new free-running T-timer mode and initial state + * @param options Options for the free-run + * @returns The created T-timer mode and state + */ +export function createFreeRunTTimer(options: { startPaused: boolean }): { + mode: ReadonlyDeep + state: ReadonlyDeep +} { + const now = getCurrentTime() + return { + mode: { + type: 'freeRun', + }, + state: options.startPaused ? { paused: true, duration: 0 } : { paused: false, zeroTime: now }, + } +} + +/** + * 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 +} + +/** + * 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 + * - 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 + */ +export function recalculateTTimerProjections(context: JobContext, playoutModel: PlayoutModel): void { + const span = context.startSpan('recalculateTTimerProjections') + + 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 undefined + } + + const currentPartInstance = playoutModel.currentPartInstance?.partInstance + + // Get ordered parts after playhead (excludes previous, current, and next) + // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, Infinity, true) + + if (playablePartsSlice.length === 0 && !currentPartInstance) { + // No parts to iterate through, clear projections + for (const timer of tTimers) { + if (timer.anchorPartId) { + playoutModel.updateTTimer({ ...timer, projectedState: undefined }) + } + } + if (span) span.end() + return + } + + const now = getCurrentTime() + + // Initialize accumulators + let totalAccumulator = 0 + let segmentAccumulator = 0 + let isPushing = false + let currentSegmentId: SegmentId | undefined = undefined + + // Handle current part/segment + if (currentPartInstance) { + 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 + } + } + } + + // 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 + 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 + const timersForThisPart = timerAnchors.get(part._id) + if (timersForThisPart) { + const anchorTime = totalAccumulator + segmentAccumulator + + for (const timerIndex of timersForThisPart) { + const timer = tTimers[timerIndex - 1] + + const projectedState: TimerState = isPushing + ? 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, projectedState }) + } + + timerAnchors.delete(part._id) + } + + // Accumulate this part's duration + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + segmentAccumulator += partDuration + } + + // Clear projections for unresolved anchors + for (const [, timerIndices] of timerAnchors.entries()) { + for (const timerIndex of timerIndices) { + const timer = tTimers[timerIndex - 1] + playoutModel.updateTTimer({ ...timer, projectedState: undefined }) + } + } + + 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..a639ea1db0 --- /dev/null +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -0,0 +1,44 @@ +import { JobContext } from '../jobs/index.js' +import { recalculateTTimerProjections } from './tTimers.js' +import { runWithPlayoutModel, runWithPlaylistLock } from './lock.js' + +/** + * 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 handleRecalculateTTimerProjections(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) => { + recalculateTTimerProjections(context, playoutModel) + }) + }) + } +} diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a..33faf33e29 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], ...clone(existingPlaylist), @@ -332,6 +337,11 @@ function defaultPlaylistForRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + tTimers: [ + { 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/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index 57974fbb73..e01783a4ef 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.RecalculateTTimerProjections, + 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') diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index be5d81787d..89928fd3b9 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 { handleRecalculateTTimerProjections } from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -87,6 +88,8 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.OnPlayoutPlaybackChanged]: handleOnPlayoutPlaybackChanged, [StudioJobs.OnTimelineTriggerTime]: handleTimelineTriggerTime, + [StudioJobs.RecalculateTTimerProjections]: handleRecalculateTTimerProjections, + [StudioJobs.UpdateStudioBaseline]: handleUpdateStudioBaseline, [StudioJobs.CleanupEmptyPlaylists]: handleRemoveEmptyPlaylists, 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/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/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index f27e2de36a..3df301315a 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: 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..a7cabd427e 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, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, + ], }) } 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/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts new file mode 100644 index 0000000000..637c8d75e5 --- /dev/null +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -0,0 +1,51 @@ +import { RundownTTimer, timerStateToDuration } 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 currentDuration = timerStateToDuration(timer.state, now) + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentDuration + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentDuration < 0) { + return 0 + } + + return currentDuration +} + +/** + * Calculate the over/under difference between the timer's current value + * 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 projection is available. + */ +export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): number | undefined { + if (!timer.state || !timer.projectedState) { + return undefined + } + + const duration = timerStateToDuration(timer.state, now) + const projectedDuration = timerStateToDuration(timer.projectedState, now) + + return projectedDuration - duration +} + +export function getDefaultTTimer(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): RundownTTimer | undefined { + return tTimers.find((t) => t.mode) +} 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/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}; 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/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/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 b647eabfa6..e95ec2c58e 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; } @@ -209,11 +209,16 @@ body.no-overflow { bottom: 0; right: 0; - 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%); + background: linear-gradient( + 45deg, + $color-status-fatal 33%, + transparent 33%, + transparent 66%, + $color-status-fatal 66% + ), // Top border + linear-gradient(45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), // Bottom border + linear-gradient(45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), // Left border + linear-gradient(45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%); // Right border background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; background-size: 30px 8px, @@ -240,7 +245,7 @@ body.no-overflow { } } -.rundown-header { +.rundown-header_OLD { padding: 0; .header-row { @@ -266,7 +271,16 @@ body.no-overflow { .timing__header__left { text-align: left; + display: flex; } + + .timing__header__center { + position: relative; + display: flex; + justify-content: center; + align-items: center; + } + .timing__header__right { display: grid; grid-template-columns: auto auto; @@ -474,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; } @@ -1100,8 +1114,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 +1396,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 +1578,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 +3584,81 @@ svg.icon { } @import 'rundownOverview'; + +.rundown-header_OLD .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; + + .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 { + font-family: + 'Roboto', + Helvetica Neue, + Arial, + sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: #fff; + font-size: 1.1em; + } + + .timing__header_t-timers__timer__sign { + 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: #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; + } + + .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/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..5b9c2c3f97 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx @@ -38,12 +38,14 @@ 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' 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 95b09a26b0..f0cb0a64e1 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -52,7 +52,9 @@ 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' +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.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 (
+ {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 (typeof children === 'string') { + return + } + return children +} + +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)} + {postfix} + + + ) +} 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..3772d964c1 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/CurrentPartOrSegmentRemaining.tsx @@ -0,0 +1,196 @@ +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' + +import { Countdown } from './Countdown.js' + +const SPEAK_ADVANCE = 500 + +interface IPartRemainingProps { + currentPartInstanceId: PartInstanceId | null + label?: string + 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 + } + } +} + +function usePartRemaining(props: IPartRemainingProps) { + 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) { + if (timingDurations.remainingBudgetOnCurrentSegment === undefined) return null + displayTimecode = timingDurations.remainingBudgetOnCurrentSegment + } + + 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 ( + 0 ? props.heavyClassName : undefined)} + > + {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/HeaderFreezeFrameIcon.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx new file mode 100644 index 0000000000..7a6328d03e --- /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 }): JSX.Element | null { + 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/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx new file mode 100644 index 0000000000..7f7800f9dc --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -0,0 +1,222 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { useHistory } from 'react-router-dom' +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, hideMenu, showMenu } from '@jstarpl/react-contextmenu' +import { contextMenuHoldToDisplayTime, useRundownViewEventBusListener } from '../../../lib/lib' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBars, faTimes } from '@fortawesome/free-solid-svg-icons' +import { + ActivateRundownPlaylistEvent, + DeactivateRundownPlaylistEvent, + IEventContext, + RundownViewEvents, +} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' +import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { UserPermissionsContext } from '../../UserPermissions' +import * as RundownResolver from '../../../lib/RundownResolver' +import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations.js' +import { reloadRundownPlaylistClick } from '../RundownNotifier' + +export const RUNDOWN_CONTEXT_MENU_ID = 'rundown-context-menu' + +interface RundownContextMenuProps { + playlist: DBRundownPlaylist + studio: UIStudio + firstRundown: Rundown | undefined + onShow?: () => void + onHide?: () => void +} + +/** + * The RundownContextMenu component renders both the context menu definition and the right-click + * 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, + onShow, + onHide, +}: Readonly): JSX.Element { + const { t } = useTranslation() + const history = useHistory() + 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 Studio')} + ) : 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')} + + history.push('/')}>{t('Close Rundown')} + + ) : ( + + {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({ + isOpen, + disabled, + onClose, + onOpen, +}: Readonly<{ isOpen?: boolean; disabled?: boolean; onClose: () => void; onOpen?: () => void }>): JSX.Element { + const { t } = useTranslation() + const buttonRef = useRef(null) + + const handleToggle = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (disabled) return + + 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, + }) + if (onOpen) onOpen() + } + }, + [isOpen, disabled, onClose, onOpen] + ) + + 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..815825578f --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -0,0 +1,654 @@ +@import '../../../styles/colorScheme'; +@import './shared'; +@import './Countdown'; + +.rundown-header { + height: 64px; + min-height: 64px; + padding: 0; + width: 100%; + border-bottom: 1px solid #333; + transition: + background-color 0.5s, + border-bottom-color 0.5s; + font-family: 'Roboto Flex', 'Roboto', sans-serif; + font-feature-settings: + 'liga' 0, + 'tnum'; + font-variant-numeric: tabular-nums; + user-select: none; + cursor: default; + + .rundown-header__trigger { + height: 100%; + width: 100%; + display: block; + } + + // State-based background colors + &.not-active { + background-color: $color-header-inactive; + } + + &.active { + background-color: $color-header-on-air; + border-bottom: 1px solid #256b91; + + .rundown-header__timers-segment-remaining, + .rundown-header__timers-onair-remaining, + .rundown-header__show-timers-countdown { + color: #fff; + } + + .rundown-header__clocks-timers__timer__label { + color: rgba(255, 255, 255, 0.9); + } + + &.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; + } + } + + .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 1 0px; + min-width: 0; + + .rundown-header__left-context-menu-wrapper { + display: flex; + align-items: center; + gap: 2.5em; + height: 100%; + } + } + + .rundown-header__right { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1 1 0px; + min-width: 0; + gap: 1em; + } + + .rundown-header__clocks { + display: flex; + align-items: center; + justify-content: center; + flex: none; + min-width: 0; + .timing-clock { + color: #40b8fa; + font-size: 1.4em; + letter-spacing: 0em; + transition: color 0.2s; + + &.time-now { + font-size: 1.8em; + font-variation-settings: + 'wdth' 85, + '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__clocks-clock-group { + display: flex; + flex-direction: column; + 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.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; + display: flex; + flex-direction: row; + justify-content: center; + gap: 0.4em; + max-width: 40em; + color: #fff; + max-height: 0; + padding-top: 0; + overflow: hidden; + transition: + max-height 0.2s ease, + padding-top 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-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; + } + } + + .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; + } + } + + .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.75em; + //opacity: 0.6; + color: #999; + 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--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-timers { + margin-left: auto; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + justify-content: end; + column-gap: 0.3em; + row-gap: 0.1em; + + .rundown-header__clocks-timers__row { + display: contents; + } + + .rundown-header__clocks-timers__timer { + display: contents; + white-space: nowrap; + line-height: 1.25; + + .countdown__label { + @extend .rundown-header__hoverable-label; + margin-left: 0; + text-align: right; + white-space: nowrap; + } + + .countdown__counter { + color: #fff; + margin-left: 0; + display: flex; + align-items: center; + gap: 0; + } + + .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: 0em; + } + + .rundown-header__clocks-timers__timer__over-under { + display: inline-block; + 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.25em; + margin-top: 0em; + font-variant-numeric: tabular-nums; + font-variation-settings: + 'wdth' 25, + 'wght' 600, + '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-timers__timer__over-under--over { + background-color: $general-late-color; + color: #000; + } + + &.rundown-header__clocks-timers__timer__over-under--under { + background-color: #ff0; + color: #000; + } + } + } + } + + .rundown-header__menu-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; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover:not(:disabled):not(.disabled) { + color: #ffffff; + + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible:not(:disabled):not(.disabled) { + color: #ffffff; + + 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)); + } + } + } + + .rundown-header__onair { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 0.1em; + } + + // Common label style for header labels that react to hover + .rundown-header__hoverable-label { + @extend %hoverable-label; + } + + .rundown-header__timers-segment-remaining, + .rundown-header__timers-onair-remaining { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.3em; + color: #fff; + transition: color 0.2s; + + &__label { + @extend .rundown-header__hoverable-label; + //opacity: 1; + color: #fff; + position: relative; + top: -0.16em; /* Match alignment from Countdown.scss */ + } + + .countdown__counter { + color: $general-countdown-to-next-color; + } + + .overtime, + .overtime .countdown__counter { + color: $general-late-color; + } + } + + // Stacked Plan. Start / Plan. End / Est. End in right section + .rundown-header__show-timers-endtimes { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 0.1em; + min-width: 7em; + } + + .rundown-header__show-timers { + display: flex; + align-items: flex-end; + gap: 1em; + cursor: pointer; + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + + &:hover { + text-shadow: 0 0 5px rgba(255, 255, 255, 1); + } + + &:focus-visible { + text-shadow: 0 0 6px rgba(255, 255, 255, 1); + } + + &.rundown-header__show-timers--disabled { + cursor: default; + + &:hover, + &:focus-visible { + text-shadow: none; + } + } + } + + .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__counter { + 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; + } + + .countdown__timeofday { + 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; + } + } + + .rundown-header__timers-onair-remaining__label { + 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; + 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 { + margin-left: 0.3em; + vertical-align: middle; + height: 0.9em; + width: auto; + } + } + + .rundown-header__close-btn { + display: flex; + align-items: center; + margin-right: 0.75em; + cursor: pointer; + color: #40b8fa; + opacity: 0; + flex-shrink: 0; + transition: + opacity 0.2s; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover { + color: #ffffff; + + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible { + color: #ffffff; + + svg, + i { + filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); + } + } + } + + &:hover { + .rundown-header__menu-btn:not(:disabled) { + color: #40b8fa; + } + + .rundown-header__hoverable-label, + .countdown__label { + opacity: 1; + color: #fff; + } + + .rundown-header__timers-onair-remaining__label { + //opacity: 1; + color: #fff; + } + + .rundown-header__close-btn { + opacity: 1; + } + + .rundown-header__clocks-clock-group { + .rundown-header__clocks-playlist-name { + max-height: 2em; + padding-top: 0em; + } + } + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 00466fab68..2972c8cfbd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -1,40 +1,29 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react' +import { useCallback, 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 { 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 { RundownContextMenu, RundownHeaderContextMenuTrigger, RundownHamburgerButton } from './RundownContextMenu' +import { TimeOfDay } from '../RundownTiming/TimeOfDay' +import { RundownHeaderPartRemaining, RundownHeaderSegmentBudget } from '../RundownHeader/CurrentPartOrSegmentRemaining' +import { RundownHeaderTimers } from './RundownHeaderTimers' + +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { useTiming } from '../RundownTiming/withTiming' +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 { playlist: DBRundownPlaylist @@ -44,6 +33,7 @@ interface IRundownHeaderProps { studio: UIStudio rundownIds: RundownId[] firstRundown: Rundown | undefined + rundownCount: number onActivate?: (isRehearsal: boolean) => void inActiveRundownView?: boolean layout: RundownLayoutRundownHeader | undefined @@ -51,110 +41,61 @@ interface IRundownHeaderProps { export function RundownHeader({ playlist, - showStyleBase, - showStyleVariant, - currentRundown, studio, - rundownIds, firstRundown, - inActiveRundownView, - layout, + currentRundown, + rundownCount, }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() + const timingDurations = useTiming() + const [simplified, setSimplified] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) - const userPermissions = useContext(UserPermissionsContext) + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) - const [selectedPiece, setSelectedPiece] = useState(undefined) - const [shouldQueueAdlibs, setShouldQueueAdlibs] = useState(false) + // 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 operations = useRundownPlaylistOperations() + const hasSimple = !!(expectedStart || expectedDuration || expectedEnd) - 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) + // Fallback duration for untimed playlists + const fallbackDuration = PlaylistTiming.isPlaylistTimingNone(playlist.timing) + ? Object.values(timingDurations.partExpectedDurations || {}).reduce((a, b) => a + b, 0) + : undefined - useEffect(() => { - reloadRundownPlaylistClick.set(operations.reloadRundownPlaylist) - }, [operations.reloadRundownPlaylist]) + const hasAdvanced = !!( + playlist.startedPlayback || + expectedStart || + timingDurations.remainingPlaylistDuration || + fallbackDuration + ) - const canClearQuickLoop = - !!studio.settings.enableQuickLoop && - !RundownResolver.isLoopLocked(playlist) && - RundownResolver.isAnyLoopMarkerDefined(playlist) + const canToggle = simplified ? hasAdvanced : hasSimple + const toggleSimplified = useCallback(() => { + if (canToggle) { + setSimplified((s) => !s) + } + }, [canToggle]) - const rundownTimesInfo = checkRundownTimes(playlist.timing) + const onMenuClose = useCallback(() => setIsMenuOpen(false), [setIsMenuOpen]) 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')} - - )} -
-
+ setIsContextMenuOpen(true)} + onHide={() => { + setIsMenuOpen(false) + setIsContextMenuOpen(false) + }} + /> - - - noResetOnActivate ? operations.activateRundown(e) : operations.resetAndActivateRundown(e) - } - /> -
-
-
- -
- + +
+
+ setIsMenuOpen(true)} + onClose={onMenuClose} + /> +
+ {playlist.currentPartInfo && ( +
+ + + {t('On Air')} + + + +
+ )} +
- {layout && RundownLayoutsAPI.isDashboardLayout(layout) ? ( - - ) : ( - <> - - - - )} -
-
- - - + +
+
+
+ + +
+
+ {rundownCount > 1 ? ( + {playlist.name} + ) : ( + {(currentRundown ?? firstRundown)?.name} + )} +
+ +
+ + + + +
- + ) 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..d052a131d3 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -0,0 +1,42 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTranslation } from 'react-i18next' +import { Countdown } from './Countdown' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' + +export function RundownHeaderDurations({ + playlist, + simplified, +}: { + readonly playlist: DBRundownPlaylist + readonly simplified?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + const timingDurations = useTiming() + + // 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 + const estDuration = timingDurations.remainingPlaylistDuration + + if (expectedDuration == undefined && estDuration == undefined) return null + + return ( +
+ {!simplified && expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} + + ) : null} + {estDuration !== undefined ? ( + + {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} + + ) : 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..b895e1e13e --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -0,0 +1,39 @@ +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, + simplified, +}: { + readonly playlist: DBRundownPlaylist + readonly simplified?: boolean +}): JSX.Element | null { + 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 + ? Math.max(now, expectedStart ?? now) + timingDurations.remainingPlaylistDuration + : null + + if (expectedEnd === undefined && estEnd === null) return null + + return ( +
+ {!simplified && expectedEnd !== undefined ? ( + + ) : 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 new file mode 100644 index 0000000000..148e5c2ee6 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -0,0 +1,36 @@ +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 RundownHeaderPlannedStart({ + playlist, + simplified, +}: { + playlist: DBRundownPlaylist + simplified?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + const timingDurations = useTiming() + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + + const now = timingDurations.currentTime ?? Date.now() + const startsIn = now - (expectedStart ?? 0) + + return ( +
+ {!simplified && expectedStart !== undefined && ( + + )} + {playlist.startedPlayback !== undefined && } + {playlist.startedPlayback === undefined && expectedStart !== undefined && ( + + {startsIn >= 0 && '+'} + {RundownUtils.formatDiffToTimecode(Math.abs(startsIn), false, false, true, true, true)} + + )} +
+ ) +} 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..8a699b8995 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' +import { calculateTTimerDiff, calculateTTimerOverUnder } from '../../../lib/tTimerUtils' +import classNames from 'classnames' +import { getCurrentTime } from '../../../lib/systemTime' +import { Countdown } from './Countdown' + +interface IProps { + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] +} + +export const RundownHeaderTimers: React.FC = ({ tTimers }) => { + useTiming() + + const activeTimers = tTimers.filter((t) => t.mode).slice(0, 2) + if (activeTimers.length == 0) return null + + return ( +
+ {activeTimers.map((timer) => ( +
+ +
+ ))} +
+ ) +} + +interface ISingleTimerProps { + timer: RundownTTimer +} + +function SingleTimer({ timer }: Readonly) { + const now = getCurrentTime() + const mode = timer.mode + if (!mode) return null + 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 isCountingDown = mode.type === 'countdown' && diff < 0 && isRunning + + return ( + 0, + 'rundown-header__clocks-timers__timer__over-under--under': overUnder < 0, + })} + > + {overUnder > 0 ? '+' : '−'} + {RundownUtils.formatDiffToTimecode(Math.abs(overUnder), false, false, true, false, true)} + + ) : undefined + } + > + {timeStr} + + ) +} 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..2b9ca4ee74 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimingDisplay.tsx @@ -0,0 +1,47 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { useTranslation } from 'react-i18next' +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 { t } = useTranslation() + const timingDurations = useTiming() + + const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) + + 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 + ) { + return null + } + + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(overUnderClock), false, false, true, true, true) + const isUnder = overUnderClock <= 0 + + return ( +
+ + {isUnder ? t('Under') : t('Over')} + + {isUnder ? '−' : '+'} + {timeStr} + + +
+ ) +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx deleted file mode 100644 index 53c9134642..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ /dev/null @@ -1,95 +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 '../RundownTiming/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' - -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/_shared.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss new file mode 100644 index 0000000000..6ae886e67b --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/_shared.scss @@ -0,0 +1,25 @@ +// 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: 1; + color: #888; + transition: color 0.2s; +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx index 8f29d6e7ce..c4d5a3b64d 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx @@ -15,7 +15,7 @@ import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/noti 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 { handleRundownPlaylistReloadResponse } from './RundownReloadResponse.js' import { scrollToPartInstance } from '../../../lib/viewPort' import { hashSingleUseToken } from '../../../lib/lib' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index 59f43cf757..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/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/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/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/RundownView/RundownTiming/TimeOfDay.tsx b/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx index 47f205ffd7..5fcdeb82f2 100644 --- a/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownTiming/TimeOfDay.tsx @@ -1,12 +1,15 @@ import { useTiming } from './withTiming.js' import Moment from 'react-moment' +import classNames from 'classnames' -export function TimeOfDay(): JSX.Element { +export function TimeOfDay({ className }: Readonly<{ className?: string }>): JSX.Element { const timingDurations = useTiming() return ( - - + + + + ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx index a5eb33429e..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/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' 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/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} 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' diff --git a/packages/yarn.lock b/packages/yarn.lock index 4f46f8ae52..6d704d11fb 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" @@ -5632,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: @@ -5671,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" @@ -7139,6 +7236,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 +9102,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 +9111,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 +10500,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 +10511,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 +11650,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 +11779,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 +15771,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 +16624,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 +17804,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: @@ -22357,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: @@ -22367,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" @@ -29064,6 +29234,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 +31053,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: