diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index fa8a8934edb..9b605fd2caf 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 c61e36bdcb7..9133eb44390 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -618,6 +618,7 @@ describe('cronjobs', () => { type: PlaylistTimingType.None, }, activationId: protectString(''), + tTimers: [] as any, }) return { diff --git a/meteor/server/api/__tests__/externalMessageQueue.test.ts b/meteor/server/api/__tests__/externalMessageQueue.test.ts index 801220a8f85..1b5fb53f938 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 3c819cf20a7..594c44049ca 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 30a74d769e1..b7c1b39de9a 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -1,7 +1,7 @@ import { addMigrationSteps } from './databaseMigration' import { CURRENT_SYSTEM_VERSION } from './currentSystemVersion' import { MongoInternals } from 'meteor/mongo' -import { Studios } from '../collections' +import { RundownPlaylists, Studios } from '../collections' import { ExpectedPackages } from '../collections' import * as PackagesPreR53 from '@sofie-automation/corelib/dist/dataModel/Old/ExpectedPackagesR52' import { @@ -195,4 +195,28 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, + { + id: 'Add T-timers to RundownPlaylist', + canBeRunAutomatically: true, + validate: async () => { + const playlistCount = await RundownPlaylists.countDocuments({ tTimers: { $exists: false } }) + if (playlistCount > 1) return `There are ${playlistCount} RundownPlaylists without T-timers` + return false + }, + migrate: async () => { + await RundownPlaylists.mutableCollection.updateAsync( + { tTimers: { $exists: false } } as any, + { + $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 } + ) + }, + }, ]) diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 651ec538050..944f4c7378e 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1231,6 +1231,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" mongodb: "npm:^6.12.0" @@ -2940,6 +2941,13 @@ __metadata: languageName: node linkType: hard +"chrono-node@npm:^2.9.0": + version: 2.9.0 + resolution: "chrono-node@npm:2.9.0" + checksum: 10/a30bbaa67f9a127e711db6e694ee4c89292d8f533dbfdc3d7cb34f479728e02e377f682e75ad84dd4b6a16016c248a5e85fb453943b96f93f5993f5ccddc6d08 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.8.0 resolution: "ci-info@npm:3.8.0" diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index 43182638f38..bb14050e8e3 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -290,6 +290,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 6f9931eeea7..90adea51b58 100644 --- a/packages/blueprints-integration/src/context/adlibActionContext.ts +++ b/packages/blueprints-integration/src/context/adlibActionContext.ts @@ -5,6 +5,7 @@ import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import { IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece } from '../index.js' import { IRouteSetMethods } from './routeSetContext.js' +import { ITTimersContext } from './tTimersContext.js' /** Actions */ export interface IDataStoreMethods { @@ -26,7 +27,8 @@ export interface IActionExecutionContext IDataStoreMethods, IPartAndPieceActionContext, IExecuteTSRActionsContext, - IRouteSetMethods { + IRouteSetMethods, + ITTimersContext { /** Fetch the showstyle config for the specified part */ // getNextShowStyleConfig(): Readonly<{ [key: string]: ConfigItemValue }> diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 9e729ce4029..c32b06d8e3c 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -6,18 +6,19 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IShowStyleUserContext, } from '../index.js' import { BlueprintQuickLookInfo } from './quickLoopInfo.js' import { ReadonlyDeep } from 'type-fest' +import type { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the part currently on air, and 'next' is the partInstance being set as Next * This is similar to `IPartAndPieceActionContext`, but has more limits on what is allowed to be changed. */ -export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext { +export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext, ITTimersContext { /** Information about the current loop, if there is one */ readonly quickLoopInfo: BlueprintQuickLookInfo | null @@ -55,7 +56,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 461f64bfa1d..bd02aa22cd0 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 } from './executeTsrActionContext.js' +import { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the partInstance we're leaving, and 'next' is the partInstance we're taking @@ -9,7 +10,8 @@ export interface IOnTakeContext extends IPartAndPieceActionContext, IShowStyleUserContext, IEventContext, - IExecuteTSRActionsContext { + IExecuteTSRActionsContext, + ITTimersContext { /** Inform core that a take out of the taken partinstance should be blocked until the specified time */ blockTakeUntil(time: Time | null): Promise /** diff --git a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts index 6f10958eebc..a5a2b9c998c 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 402da1fa396..cf3a30e332c 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -4,6 +4,7 @@ import type { IPackageInfoContext } from './packageInfoContext.js' import type { IShowStyleContext } from './showStyleContext.js' import type { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import type { IDataStoreMethods } from './adlibActionContext.js' +import { ITTimersContext } from './tTimersContext.js' export interface IRundownContext extends IShowStyleContext { readonly rundownId: string @@ -13,7 +14,11 @@ export interface IRundownContext extends IShowStyleContext { export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} -export interface IRundownActivationContext extends IRundownContext, IExecuteTSRActionsContext, IDataStoreMethods { +export interface IRundownActivationContext + extends IRundownContext, + IExecuteTSRActionsContext, + IDataStoreMethods, + ITTimersContext { /** Info about the RundownPlaylist state before the Activation / Deactivation event */ readonly previousState: IRundownActivationContextState readonly currentState: IRundownActivationContextState diff --git a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts index e6917d443b6..668e5bfd3e1 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 00000000000..8747f450a2c --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,119 @@ +export type IPlaylistTTimerIndex = 1 | 2 | 3 + +export interface ITTimersContext { + /** + * Get a T-timer by its index + * Note: Index is 1-based (1, 2, 3) + * @param index Number of the timer to retrieve + */ + getTimer(index: IPlaylistTTimerIndex): IPlaylistTTimer + + /** + * Clear all T-timers + */ + clearAllTimers(): void +} + +export interface IPlaylistTTimer { + readonly index: IPlaylistTTimerIndex + + /** The label of the T-timer */ + readonly label: string + + /** + * The current state of the T-timer + * Null if the T-timer is not initialized + */ + readonly state: IPlaylistTTimerState | null + + /** Set the label of the T-timer */ + setLabel(label: string): void + + /** Clear the T-timer back to an uninitialized state */ + clearTimer(): void + + /** + * Start a countdown timer + * @param duration Duration of the countdown in milliseconds + * @param options Options for the countdown + */ + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void + + /** + * Start a timeOfDay timer, counting towards the target time + * This will throw if it is unable to parse the target time + * @param targetTime The target time, as a string (e.g. "14:30", "2023-12-31T23:59:59Z") or a timestamp number + */ + startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void + + /** + * Start a free-running timer + */ + startFreeRun(options?: { startPaused?: boolean }): void + + /** + * If the current mode supports being paused, pause the timer + * Note: This is supported by the countdown and freerun modes + * @returns True if the timer was paused, false if it could not be paused + */ + pause(): boolean + + /** + * If the current mode supports being paused, resume the timer + * This is the opposite of `pause()` + * @returns True if the timer was resumed, false if it could not be resumed + */ + resume(): boolean + + /** + * If the timer can be restarted, restore it to its initial/restarted state + * Note: This is supported by the countdown and timeOfDay modes + * @returns True if the timer was restarted, false if it could not be restarted + */ + restart(): boolean +} + +export type IPlaylistTTimerState = + | IPlaylistTTimerStateCountdown + | IPlaylistTTimerStateFreeRun + | IPlaylistTTimerStateTimeOfDay + +export interface IPlaylistTTimerStateCountdown { + /** The mode of the T-timer */ + readonly mode: 'countdown' + /** The current time of the countdown, in milliseconds */ + readonly currentTime: number + /** The total duration of the countdown, in milliseconds */ + readonly duration: number + /** Whether the timer is currently paused */ + readonly paused: boolean + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} +export interface IPlaylistTTimerStateFreeRun { + /** The mode of the T-timer */ + readonly mode: 'freeRun' + /** The current time of the freerun, in milliseconds */ + readonly currentTime: number + /** Whether the timer is currently paused */ + readonly paused: boolean +} + +export interface IPlaylistTTimerStateTimeOfDay { + /** The mode of the T-timer */ + readonly mode: 'timeOfDay' + /** The current remaining time of the timer, in milliseconds */ + readonly currentTime: number + /** The target timestamp of the timer, in milliseconds */ + readonly targetTime: number + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e2850bc49bb..93c4bb769c9 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,6 +94,84 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay + +export interface RundownTTimerModeFreeRun { + readonly type: 'freeRun' +} +export interface RundownTTimerModeCountdown { + readonly type: 'countdown' + /** + * The original duration of the countdown in milliseconds, so that we know what value to reset to + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean +} +export interface RundownTTimerModeTimeOfDay { + readonly type: 'timeOfDay' + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean +} + +/** + * Timing state for a timer, optimized for efficient client rendering. + * When running, the client calculates current time from zeroTime. + * When paused, the duration is frozen and sent directly. + */ +export type TimerState = + | { + /** Whether the timer is paused */ + paused: false + /** The absolute timestamp (ms) when the timer reaches/reached zero */ + zeroTime: number + } + | { + /** Whether the timer is paused */ + paused: true + /** The frozen duration value in milliseconds */ + duration: number + } + +export type RundownTTimerIndex = 1 | 2 | 3 + +export interface RundownTTimer { + 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 + + /* + * Future ideas: + * allowUiControl: boolean + * display: { ... } // some kind of options for how to display in the ui + */ +} + export interface DBRundownPlaylist { _id: RundownPlaylistId /** External ID (source) of the playlist */ @@ -176,6 +254,12 @@ export interface DBRundownPlaylist { trackedAbSessions?: ABSessionInfo[] /** AB playback sessions assigned in the last timeline generation */ assignedAbSessions?: Record + + /** + * T-timers for the Playlist. + * This is a fixed size pool with 3 being chosen as a likely good amount, that can be used for any purpose. + */ + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] } // Information about a 'selected' PartInstance for the Playlist diff --git a/packages/job-worker/package.json b/packages/job-worker/package.json index 9241feb122c..34a1df65a34 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -41,6 +41,7 @@ "@sofie-automation/corelib": "1.53.0-in-development", "@sofie-automation/shared-lib": "1.53.0-in-development", "amqplib": "^0.10.5", + "chrono-node": "^2.9.0", "deepmerge": "^4.3.1", "elastic-apm-node": "^4.11.0", "mongodb": "^6.12.0", diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 88869a4da84..0a7478f109b 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 5cdf53ed788..7bb1aaf9861 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 06319381fdb..8ea794c883d 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 1dcd4e99a10..b61faf8c176 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 92476b94be6..0e2f5309460 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' @@ -28,11 +28,16 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { selectNewPartWithOffsets } from '../../playout/moveNextPart.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class OnSetAsNextContext extends ShowStyleUserContext implements IOnSetAsNextContext, IEventContext, IPartAndPieceInstanceActionContext { + readonly #tTimersService: TTimersService + public pendingMoveNextPart: { selectedPart: ReadonlyDeep | null } | undefined = undefined constructor( @@ -45,6 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) + this.#tTimersService = TTimersService.withPlayoutModel(playoutModel) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -79,7 +85,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 ddca6bdcfc8..f403d337239 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 } 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) } 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) } @@ -184,4 +190,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 a1c6849245f..3f0b47cc1df 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -13,10 +13,14 @@ import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { RundownEventContext } from './RundownEventContext.js' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { setTimelineDatastoreValue, removeTimelineDatastoreValue } from '../../playout/datastore.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class RundownActivationContext extends RundownEventContext implements IRundownActivationContext { private readonly _playoutModel: PlayoutModel private readonly _context: JobContext + readonly #tTimersService: TTimersService private readonly _previousState: IRundownActivationContextState private readonly _currentState: IRundownActivationContextState @@ -43,6 +47,8 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._playoutModel = options.playoutModel this._previousState = options.previousState this._currentState = options.currentState + + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel) } get previousState(): IRundownActivationContextState { @@ -74,4 +80,11 @@ export class RundownActivationContext extends RundownEventContext implements IRu await removeTimelineDatastoreValue(this._context, key) }) } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index d8289be7d99..3bbec8cdaad 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -32,24 +32,39 @@ import { } from '@sofie-automation/corelib/dist/dataModel/Piece' import { EXPECTED_INGEST_TO_PLAYOUT_TIME } from '@sofie-automation/shared-lib/dist/core/constants' import { getCurrentTime } from '../../lib/index.js' +import { TTimersService } from './services/TTimersService.js' +import type { + DBRundownPlaylist, + RundownTTimer, + RundownTTimerIndex, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' export class SyncIngestUpdateToPartInstanceContext extends RundownUserContext implements ISyncIngestUpdateToPartInstanceContext { - private readonly _proposedPieceInstances: Map> + readonly #context: JobContext + readonly #proposedPieceInstances: Map> + readonly #tTimersService: TTimersService + readonly #changedTTimers = new Map() - private partInstance: PlayoutPartInstanceModel | null + #partInstance: PlayoutPartInstanceModel | null public get hasRemovedPartInstance(): boolean { - return !this.partInstance + return !this.#partInstance + } + + public get changedTTimers(): RundownTTimer[] { + return Array.from(this.#changedTTimers.values()) } constructor( - private readonly _context: JobContext, + context: JobContext, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, + playlist: ReadonlyDeep, rundown: ReadonlyDeep, partInstance: PlayoutPartInstanceModel, proposedPieceInstances: ReadonlyDeep, @@ -58,32 +73,43 @@ export class SyncIngestUpdateToPartInstanceContext super( contextInfo, studio, - _context.getStudioBlueprintConfig(), + context.getStudioBlueprintConfig(), showStyleCompound, - _context.getShowStyleBlueprintConfig(showStyleCompound), + context.getShowStyleBlueprintConfig(showStyleCompound), rundown ) - this.partInstance = partInstance + this.#context = context + this.#partInstance = partInstance - this._proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }) + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() } syncPieceInstance( pieceInstanceId: string, modifiedPiece?: Omit ): IBlueprintPieceInstance { - const proposedPieceInstance = this._proposedPieceInstances.get(protectString(pieceInstanceId)) + const proposedPieceInstance = this.#proposedPieceInstances.get(protectString(pieceInstanceId)) if (!proposedPieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // filter the submission to the allowed ones const piece = modifiedPiece ? postProcessPieces( - this._context, + this.#context, [ { ...modifiedPiece, @@ -92,9 +118,9 @@ export class SyncIngestUpdateToPartInstanceContext }, ], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] : proposedPieceInstance.piece @@ -103,7 +129,7 @@ export class SyncIngestUpdateToPartInstanceContext ...proposedPieceInstance, piece: piece, } - this.partInstance.mergeOrInsertPieceInstance(newPieceInstance) + this.#partInstance.mergeOrInsertPieceInstance(newPieceInstance) return convertPieceInstanceToBlueprints(newPieceInstance) } @@ -111,19 +137,19 @@ export class SyncIngestUpdateToPartInstanceContext insertPieceInstance(piece0: IBlueprintPiece): IBlueprintPieceInstance { const trimmedPiece: IBlueprintPiece = _.pick(piece0, IBlueprintPieceObjectsSampleKeys) - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const piece = postProcessPieces( - this._context, + this.#context, [trimmedPiece], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] - const newPieceInstance = this.partInstance.insertPlannedPiece(piece) + const newPieceInstance = this.#partInstance.insertPlannedPiece(piece) return convertPieceInstanceToBlueprints(newPieceInstance.pieceInstance) } @@ -134,13 +160,13 @@ export class SyncIngestUpdateToPartInstanceContext throw new Error(`Cannot update PieceInstance "${pieceInstanceId}". Some valid properties must be defined`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) - const pieceInstance = this.partInstance.getPieceInstance(protectString(pieceInstanceId)) + const pieceInstance = this.#partInstance.getPieceInstance(protectString(pieceInstanceId)) if (!pieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (pieceInstance.pieceInstance.partInstanceId !== this.partInstance.partInstance._id) { + if (pieceInstance.pieceInstance.partInstanceId !== this.#partInstance.partInstance._id) { throw new Error(`PieceInstance "${pieceInstanceId}" does not belong to the current PartInstance`) } @@ -167,13 +193,13 @@ export class SyncIngestUpdateToPartInstanceContext return convertPieceInstanceToBlueprints(pieceInstance.pieceInstance) } updatePartInstance(updatePart: Partial): IBlueprintPartInstance { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // for autoNext, the new expectedDuration cannot be shorter than the time a part has been on-air for - const expectedDuration = updatePart.expectedDuration ?? this.partInstance.partInstance.part.expectedDuration - const autoNext = updatePart.autoNext ?? this.partInstance.partInstance.part.autoNext + const expectedDuration = updatePart.expectedDuration ?? this.#partInstance.partInstance.part.expectedDuration + const autoNext = updatePart.autoNext ?? this.#partInstance.partInstance.part.autoNext if (expectedDuration && autoNext) { - const onAir = this.partInstance.partInstance.timings?.reportedStartedPlayback + const onAir = this.#partInstance.partInstance.timings?.reportedStartedPlayback const minTime = Date.now() - (onAir ?? 0) + EXPECTED_INGEST_TO_PLAYOUT_TIME if (onAir && minTime > expectedDuration) { updatePart.expectedDuration = minTime @@ -185,31 +211,31 @@ export class SyncIngestUpdateToPartInstanceContext this.showStyleCompound.blueprintId ) - if (!this.partInstance.updatePartProps(playoutUpdatePart)) { + if (!this.#partInstance.updatePartProps(playoutUpdatePart)) { throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) } - return convertPartInstanceToBlueprints(this.partInstance.partInstance) + return convertPartInstanceToBlueprints(this.#partInstance.partInstance) } removePartInstance(): void { if (this.playStatus !== 'next') throw new Error(`Only the 'next' PartInstance can be removed`) - this.partInstance = null + this.#partInstance = null } removePieceInstances(...pieceInstanceIds: string[]): string[] { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const rawPieceInstanceIdSet = new Set(protectStringArray(pieceInstanceIds)) - const pieceInstances = this.partInstance.pieceInstances.filter((p) => + const pieceInstances = this.#partInstance.pieceInstances.filter((p) => rawPieceInstanceIdSet.has(p.pieceInstance._id) ) const pieceInstanceIdsToRemove = pieceInstances.map((p) => p.pieceInstance._id) for (const id of pieceInstanceIdsToRemove) { - this.partInstance.removePieceInstance(id) + this.#partInstance.removePieceInstance(id) } return unprotectStringArray(pieceInstanceIdsToRemove) diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 0359871eb2a..8c41cc7d7d0 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 } 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) } 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) } @@ -280,4 +286,11 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 68c807d764d..f58115b375b 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 00000000000..b1eeafd49c6 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,171 @@ +import type { + IPlaylistTTimer, + IPlaylistTTimerState, +} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { assertNever } from '@sofie-automation/corelib/dist/lib' +import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' +import { ReadonlyDeep } from 'type-fest' +import { + createCountdownTTimer, + createFreeRunTTimer, + createTimeOfDayTTimer, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, +} from '../../../playout/tTimers.js' +import { getCurrentTime } from '../../../lib/time.js' + +export class TTimersService { + readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] + + constructor( + timers: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void + ) { + this.timers = [ + new PlaylistTTimerImpl(timers[0], emitChange), + new PlaylistTTimerImpl(timers[1], emitChange), + new PlaylistTTimerImpl(timers[2], emitChange), + ] + } + + static withPlayoutModel(playoutModel: PlayoutModel): TTimersService { + return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }) + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + validateTTimerIndex(index) + return this.timers[index - 1] + } + clearAllTimers(): void { + for (const timer of this.timers) { + timer.clearTimer() + } + } +} + +export class PlaylistTTimerImpl implements IPlaylistTTimer { + readonly #emitChange: (updatedTimer: ReadonlyDeep) => void + + #timer: ReadonlyDeep + + get index(): RundownTTimerIndex { + return this.#timer.index + } + get label(): string { + return this.#timer.label + } + get state(): IPlaylistTTimerState | null { + const rawMode = this.#timer.mode + const rawState = this.#timer.state + + if (!rawMode || !rawState) return null + + const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() + + switch (rawMode.type) { + case 'countdown': + return { + mode: 'countdown', + currentTime, + duration: rawMode.duration, + paused: rawState.paused, + stopAtZero: rawMode.stopAtZero, + } + case 'freeRun': + return { + mode: 'freeRun', + currentTime, + paused: rawState.paused, + } + case 'timeOfDay': + return { + mode: 'timeOfDay', + currentTime, + targetTime: rawState.paused ? 0 : rawState.zeroTime, + targetRaw: rawMode.targetRaw, + stopAtZero: rawMode.stopAtZero, + } + default: + assertNever(rawMode) + return null + } + } + + constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + this.#timer = timer + this.#emitChange = emitChange + } + + 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 + } +} 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 00000000000..2fe7a21b299 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,773 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { useFakeCurrentTime, useRealCurrentTime } from '../../../../__mocks__/time.js' +import { TTimersService, PlaylistTTimerImpl } from '../TTimersService.js' +import type { PlayoutModel } from '../../../../playout/model/PlayoutModel.js' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { mock, MockProxy } from 'jest-mock-extended' +import type { ReadonlyDeep } from 'type-fest' + +function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { + const mockPlayoutModel = mock() + const mockPlaylist = { + tTimers, + } as unknown as ReadonlyDeep + + Object.defineProperty(mockPlayoutModel, 'playlist', { + get: () => mockPlaylist, + configurable: true, + }) + + return mockPlayoutModel +} + +function createEmptyTTimers(): [RundownTTimer, RundownTTimer, RundownTTimer] { + return [ + { index: 1, label: 'Timer 1', mode: null, 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 service = new TTimersService(timers, updateFn) + + 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 service = TTimersService.withPlayoutModel(mockPlayoutModel) + expect(service.timers).toHaveLength(3) + + const timer = service.getTimer(1) + expect(timer.index).toBe(1) + + timer.setLabel('New Label') + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, label: 'New Label' }) + ) + }) + + describe('getTimer', () => { + it('should return the correct timer for index 1', () => { + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) + + 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 service = new TTimersService(timers, updateFn) + + 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 service = new TTimersService(timers, updateFn) + + 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 service = new TTimersService(timers, updateFn) + + expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') + expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') + }) + }) + + 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 timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) + + service.clearAllTimers() + + // updateTTimer should have been called 3 times (once for each timer) + expect(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 timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + + expect(timer.index).toBe(2) + }) + + it('should return the correct label', () => { + const tTimers = createEmptyTTimers() + tTimers[1].label = 'Custom Label' + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + + expect(timer.label).toBe('Custom Label') + }) + + it('should return null state when no mode is set', () => { + const tTimers = createEmptyTTimers() + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toBeNull() + }) + + it('should return running freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 15000 } + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 5000, // 10000 - 5000 + paused: false, // pauseTime is null = running + }) + }) + + it('should return paused freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: 3000 } + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 3000, // 8000 - 5000 + paused: true, // pauseTime is set = paused + }) + }) + + it('should return running countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + duration: 60000, + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 15000 } + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 5000, // 10000 - 5000 + duration: 60000, + paused: false, // pauseTime is null = running + stopAtZero: true, + }) + }) + + it('should return paused countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + duration: 60000, + stopAtZero: false, + } + tTimers[0].state = { paused: true, duration: 2000 } + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 2000, // 7000 - 5000 + duration: 60000, + paused: true, // pauseTime is set = paused + stopAtZero: false, + }) + }) + + it('should return timeOfDay state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + } + tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: 10000, // targetTime - getCurrentTime() = 20000 - 10000 + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + }) + }) + + it('should return timeOfDay state with numeric targetRaw', () => { + const tTimers = createEmptyTTimers() + const targetTimestamp = 1737331200000 + tTimers[0].mode = { + type: 'timeOfDay', + targetRaw: targetTimestamp, + stopAtZero: false, + } + tTimers[0].state = { paused: false, zeroTime: targetTimestamp } + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: targetTimestamp - 10000, // targetTime - getCurrentTime() + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: false, + }) + }) + }) + + describe('setLabel', () => { + it('should update the label', () => { + const tTimers = createEmptyTTimers() + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') + }) + + it('should throw for empty time string', () => { + const tTimers = createEmptyTTimers() + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + const result = timer.resume() + + // Returns true because timer supports resume, but it's already running + expect(result).toBe(true) + expect(updateFn).toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + 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 timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + + const result = timer.restart() + + expect(result).toBe(false) + expect(updateFn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 7631c647c5d..54b97fb0110 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 00000000000..6b688cf1d04 --- /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 b663ad3501c..3f63fe88589 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) @@ -315,6 +328,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 b92cbe77665..cc40fff7157 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/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap index 8c1b68d4433..12dd5ff6796 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 6f8352751c5..afee746ca29 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 { @@ -135,6 +136,7 @@ export class SyncChangesToPartInstancesWorker { }, this.#context.studio, this.#showStyle, + this.#playoutModel.playlist, instanceToSync.playoutRundownModel.rundown, existingPartInstance, proposedPieceInstances, @@ -152,6 +154,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 +196,7 @@ export class SyncChangesToPartInstancesWorker { } } - collectNewIngestDataToSync( + private collectNewIngestDataToSync( partId: PartId, instanceToSync: PartInstanceToSync, proposedPieceInstances: PieceInstance[] @@ -204,7 +211,18 @@ export class SyncChangesToPartInstancesWorker { if (adLibPiece) referencedAdlibs.push(convertAdLibPieceToBlueprints(adLibPiece)) } + const allModelParts = this.#ingestModel.getAllOrderedParts() + return { + allParts: allModelParts.map((part) => convertPartToBlueprints(part.part)), + currentPartIndex: computeCurrentPartIndex( + this.#ingestModel.getOrderedSegments().map((s) => s.segment), + allModelParts.map((p) => p.part), + partId, + instanceToSync.existingPartInstance.partInstance.segmentId, + instanceToSync.existingPartInstance.partInstance.part._rank + ), + part: instanceToSync.newPart ? convertPartToBlueprints(instanceToSync.newPart) : undefined, pieceInstances: proposedPieceInstances.map(convertPieceInstanceToBlueprints), adLibPieces: @@ -480,3 +498,71 @@ function findLastUnorphanedPartInstanceInSegment( part: previousPart, } } + +/** + * Compute an approximate (possibly non-integer) index of the part within all parts + * This is used to give the blueprints an idea of where the part is within the rundown + * Note: this assumes each part has a unique integer rank, which is what ingest will produce + * @returns The approximate index, or `null` if the part could not be placed + */ +export function computeCurrentPartIndex( + allOrderedSegments: ReadonlyDeep[], + allOrderedParts: ReadonlyDeep[], + partId: PartId, + segmentId: SegmentId, + targetRank: number +): number | null { + // Exact match by part id + const exactIdx = allOrderedParts.findIndex((p) => p._id === partId) + if (exactIdx !== -1) return exactIdx + + // Find the segment object + const segment = allOrderedSegments.find((s) => s._id === segmentId) + if (!segment) return null + + // Prepare parts with their global indices + const partsWithGlobal = allOrderedParts.map((p, globalIndex) => ({ part: p, globalIndex })) + + // Parts in the same segment + const partsInSegment = partsWithGlobal.filter((pg) => pg.part.segmentId === segmentId) + + if (partsInSegment.length === 0) { + // Segment has no parts: place between the previous/next parts by segment order + const segmentRank = segment._rank + + const prev = partsWithGlobal.findLast((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank < segmentRank + }) + + const next = partsWithGlobal.find((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank > segmentRank + }) + + if (prev && next) return (prev.globalIndex + next.globalIndex) / 2 + if (prev) return prev.globalIndex + 0.5 + if (next) return next.globalIndex - 0.5 + + // No parts at all + return null + } + + // There are parts in the segment: decide placement by rank within the segment. + + const nextIdx = partsInSegment.findIndex((pg) => pg.part._rank > targetRank) + if (nextIdx === -1) { + // After last + return partsInSegment[partsInSegment.length - 1].globalIndex + 0.5 + } + + if (nextIdx === 0) { + // Before first + return partsInSegment[0].globalIndex - 0.5 + } + + // Between two adjacent parts: interpolate by their ranks (proportionally) + const prev = partsInSegment[nextIdx - 1] + const next = partsInSegment[nextIdx] + return prev.globalIndex + (next.globalIndex - prev.globalIndex) / 2 +} 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 d99635086b3..690dd9ac31b 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 00000000000..bea1a2c92b3 --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,658 @@ +/* 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') + }) + }) + + 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/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 0dff06ff919..439d58b8954 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -17,6 +17,7 @@ import { DBRundownPlaylist, QuickLoopMarker, RundownHoldState, + RundownTTimer, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' import { StudioPlayoutModelBase, StudioPlayoutModelBaseReadonly } from '../../studio/model/StudioPlayoutModel.js' @@ -374,6 +375,12 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void + /** + * Update a T-timer + * @param timer Timer properties + */ + updateTTimer(timer: RundownTTimer): void + calculatePartTimings( fromPartInstance: PlayoutPartInstanceModel | null, toPartInstance: PlayoutPartInstanceModel, diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 52253f1a2f5..a593fad9c66 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' @@ -71,6 +72,7 @@ import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout import { NotificationsModelHelper } from '../../../notifications/NotificationsModelHelper.js' import { getExpectedLatency } from '@sofie-automation/corelib/dist/studio/playout' import { ExpectedPackage } from '@sofie-automation/blueprints-integration' +import { validateTTimerIndex } from '../../tTimers.js' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -877,6 +879,13 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + updateTTimer(timer: RundownTTimer): void { + validateTTimerIndex(timer.index) + + this.playlistImpl.tTimers[timer.index - 1] = timer + this.#playlistHasChanged = true + } + #lastMonotonicNowInPlayout = getCurrentTime() getNowInPlayout(): number { const nowOffsetLatency = this.getNowOffsetLatency() ?? 0 diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts new file mode 100644 index 00000000000..af86616f82a --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,169 @@ +import type { + RundownTTimerIndex, + RundownTTimerMode, + RundownTTimer, + TimerState, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { getCurrentTime } from '../lib/index.js' +import type { ReadonlyDeep } from 'type-fest' +import * as chrono from 'chrono-node' + +export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { + if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) +} + +/** + * Returns an updated T-timer in the paused state (if supported) + * @param timer Timer to update + * @returns If the timer supports pausing, the timer in paused state, otherwise null + */ +export function pauseTTimer(timer: ReadonlyDeep): 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 +} diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a0..33faf33e29a 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/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 576b1cb7436..23b70507c10 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -34,6 +34,7 @@ export function makeTestPlaylist(id?: string): DBRundownPlaylist { studioId: protectString('STUDIO_1'), timing: { type: PlaylistTimingType.None }, publicData: { a: 'b' }, + tTimers: [] as any, } } diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 7434f499fbf..6ed4b6031b3 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 8e402449d98..a7cabd427ea 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/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index ffbf52f145a..818fc888742 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -106,6 +106,10 @@ export function RundownHeader({ const rundownTimesInfo = checkRundownTimes(playlist.timing) + useEffect(() => { + console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) + }, [playlist.tTimers]) + return ( <> diff --git a/packages/yarn.lock b/packages/yarn.lock index 477f1ce8d35..2af7a6d7fdb 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -6939,6 +6939,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" jest: "npm:^29.7.0" @@ -11278,6 +11279,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"