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-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml index d09a8222ef3..05ef11767ac 100644 --- a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml +++ b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent-example.yaml @@ -15,3 +15,18 @@ timing: $ref: '../../timing/activePlaylistTiming/activePlaylistTiming-example.yaml' quickLoop: $ref: '../../quickLoop/activePlaylistQuickLoop/activePlaylistQuickLoop-example.yaml' +tTimers: + - index: 1 + label: 'On Air Timer' + configured: true + mode: + $ref: '../../tTimers/tTimerMode/tTimerModeCountdown-example.yaml' + - index: 2 + label: '' + configured: false + mode: null + - index: 3 + label: 'Studio Clock' + configured: true + mode: + $ref: '../../tTimers/tTimerMode/tTimerModeFreeRun-example.yaml' diff --git a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml index c41fed04c05..48ccc7a8fd2 100644 --- a/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml +++ b/packages/live-status-gateway-api/api/components/playlist/activePlaylistEvent/activePlaylistEvent.yaml @@ -44,7 +44,14 @@ $defs: $ref: '../../timing/activePlaylistTiming/activePlaylistTiming.yaml#/$defs/activePlaylistTiming' quickLoop: $ref: '../../quickLoop/activePlaylistQuickLoop/activePlaylistQuickLoop.yaml#/$defs/activePlaylistQuickLoop' - required: [event, id, externalId, name, rundownIds, currentPart, currentSegment, nextPart, timing] + tTimers: + description: T-timers for the playlist. Always contains 3 elements (one for each timer slot). + type: array + items: + $ref: '../../tTimers/tTimerStatus/tTimerStatus.yaml#/$defs/tTimerStatus' + minItems: 3 + maxItems: 3 + required: [event, id, externalId, name, rundownIds, currentPart, currentSegment, nextPart, timing, tTimers] additionalProperties: false examples: - $ref: './activePlaylistEvent-example.yaml' diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml new file mode 100644 index 00000000000..aab940cecd5 --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerIndex.yaml @@ -0,0 +1,6 @@ +$defs: + tTimerIndex: + type: integer + title: TTimerIndex + description: Timer index (1-3). The playlist always has 3 T-timer slots. + enum: [1, 2, 3] diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerMode.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerMode.yaml new file mode 100644 index 00000000000..1cb36d05a7a --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerMode.yaml @@ -0,0 +1,65 @@ +$defs: + tTimerModeCountdown: + type: object + title: TTimerModeCountdown + description: Countdown timer mode - counts down from a duration + properties: + type: + type: string + const: countdown + paused: + description: Whether the timer is currently paused + type: boolean + zeroTime: + description: >- + Unix timestamp (ms) when the timer reaches/reached zero. + Present when paused is false. The client calculates remaining time as zeroTime - Date.now(). + type: number + remainingMs: + description: >- + Frozen remaining duration in milliseconds. + Present when paused is true. + type: number + durationMs: + description: Total countdown duration in milliseconds (the original configured duration) + type: number + stopAtZero: + description: Whether timer stops at zero or continues into negative values + type: boolean + required: [type, paused, durationMs, stopAtZero] + additionalProperties: false + examples: + - $ref: './tTimerModeCountdown-example.yaml' + + tTimerModeFreeRun: + type: object + title: TTimerModeFreeRun + description: Free-running timer mode - counts up from start time + properties: + type: + type: string + const: freeRun + paused: + description: Whether the timer is currently paused + type: boolean + zeroTime: + description: >- + Unix timestamp (ms) when the timer was at zero (i.e. when it was started). + Present when paused is false. The client calculates elapsed time as Date.now() - zeroTime. + type: number + elapsedMs: + description: >- + Frozen elapsed time in milliseconds. + Present when paused is true. + type: number + required: [type, paused] + additionalProperties: false + examples: + - $ref: './tTimerModeFreeRun-example.yaml' + + tTimerMode: + title: TTimerMode + description: The mode/state of a T-timer + oneOf: + - $ref: '#/$defs/tTimerModeCountdown' + - $ref: '#/$defs/tTimerModeFreeRun' diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeCountdown-example.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeCountdown-example.yaml new file mode 100644 index 00000000000..bcc642bbe7f --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeCountdown-example.yaml @@ -0,0 +1,5 @@ +type: countdown +paused: false +zeroTime: 1706371920000 +durationMs: 120000 +stopAtZero: true diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeFreeRun-example.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeFreeRun-example.yaml new file mode 100644 index 00000000000..1cad209ada8 --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerMode/tTimerModeFreeRun-example.yaml @@ -0,0 +1,3 @@ +type: freeRun +paused: false +zeroTime: 1706371800000 diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus-example.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus-example.yaml new file mode 100644 index 00000000000..03e3f3e8340 --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus-example.yaml @@ -0,0 +1,13 @@ +index: 1 +label: 'Segment Timer' +configured: true +mode: + type: countdown + paused: false + zeroTime: 1706371920000 + durationMs: 120000 + stopAtZero: true +estimate: + paused: false + zeroTime: 1706371920000 +anchorPartId: 'part_break_1' diff --git a/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus.yaml b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus.yaml new file mode 100644 index 00000000000..90b17e2e619 --- /dev/null +++ b/packages/live-status-gateway-api/api/components/tTimers/tTimerStatus/tTimerStatus.yaml @@ -0,0 +1,51 @@ +$defs: + tTimerStatus: + type: object + title: TTimerStatus + description: Status of a single T-timer in the playlist + properties: + index: + $ref: '../tTimerIndex.yaml#/$defs/tTimerIndex' + label: + description: User-defined label for the timer + type: string + configured: + description: Whether the timer has been configured (mode is not null) + type: boolean + mode: + description: Timer mode and timing state. Null if not configured. + oneOf: + - type: 'null' + - $ref: '../tTimerMode/tTimerMode.yaml#/$defs/tTimerMode' + estimate: + description: >- + Estimated timing for when we expect to reach an anchor part. + Used to calculate over/under diff + oneOf: + - type: 'null' + - type: object + title: TTimerEstimate + description: >- + Estimate timing state for a T-timer + properties: + paused: + description: Whether the estimate is frozen + type: boolean + zeroTime: + description: >- + Unix timestamp in milliseconds of estimated arrival at the anchor part + type: number + durationMs: + description: >- + Frozen remaining duration estimate in milliseconds + type: number + required: [paused] + additionalProperties: false + anchorPartId: + description: >- + The Part ID that this timer is counting towards (the timing anchor) + type: string + required: [index, label, configured] + additionalProperties: false + examples: + - $ref: './tTimerStatus-example.yaml' diff --git a/packages/live-status-gateway-api/src/generated/asyncapi.yaml b/packages/live-status-gateway-api/src/generated/asyncapi.yaml index b747e97a84c..2e0d68e2190 100644 --- a/packages/live-status-gateway-api/src/generated/asyncapi.yaml +++ b/packages/live-status-gateway-api/src/generated/asyncapi.yaml @@ -401,7 +401,7 @@ channels: pieces: description: All pieces in this part type: array - items: &a30 + items: &a32 type: object title: PieceStatus properties: @@ -510,7 +510,7 @@ channels: - type: object title: CurrentSegment allOf: - - &a32 + - &a34 title: SegmentBase type: object properties: @@ -529,7 +529,7 @@ channels: title: CurrentSegmentTiming description: Timing information about the current segment allOf: - - &a33 + - &a35 type: object title: SegmentTiming properties: @@ -709,6 +709,150 @@ channels: running: true start: *a23 end: *a23 + tTimers: + description: T-timers for the playlist. Always contains 3 elements (one for each + timer slot). + type: array + items: + type: object + title: TTimerStatus + description: Status of a single T-timer in the playlist + properties: + index: + type: integer + title: TTimerIndex + description: Timer index (1-3). The playlist always has 3 T-timer slots. + enum: + - 1 + - 2 + - 3 + label: + description: User-defined label for the timer + type: string + configured: + description: Whether the timer has been configured (mode is not null) + type: boolean + mode: + description: Timer mode and timing state. Null if not configured. + oneOf: + - type: "null" + - title: TTimerMode + description: The mode/state of a T-timer + oneOf: + - type: object + title: TTimerModeCountdown + description: Countdown timer mode - counts down from a duration + properties: + type: + type: string + const: countdown + paused: + description: Whether the timer is currently paused + type: boolean + zeroTime: + description: Unix timestamp (ms) when the timer reaches/reached zero. Present + when paused is false. The client + calculates remaining time as zeroTime - + Date.now(). + type: number + remainingMs: + description: Frozen remaining duration in milliseconds. Present when paused is + true. + type: number + durationMs: + description: Total countdown duration in milliseconds (the original configured + duration) + type: number + stopAtZero: + description: Whether timer stops at zero or continues into negative values + type: boolean + required: + - type + - paused + - durationMs + - stopAtZero + additionalProperties: false + examples: + - &a29 + type: countdown + paused: false + zeroTime: 1706371920000 + durationMs: 120000 + stopAtZero: true + - type: object + title: TTimerModeFreeRun + description: Free-running timer mode - counts up from start time + properties: + type: + type: string + const: freeRun + paused: + description: Whether the timer is currently paused + type: boolean + zeroTime: + description: Unix timestamp (ms) when the timer was at zero (i.e. when it was + started). Present when paused is false. + The client calculates elapsed time as + Date.now() - zeroTime. + type: number + elapsedMs: + description: Frozen elapsed time in milliseconds. Present when paused is true. + type: number + required: + - type + - paused + additionalProperties: false + examples: + - &a30 + type: freeRun + paused: false + zeroTime: 1706371800000 + estimate: + description: Estimated timing for when we expect to reach an anchor part. Used + to calculate over/under diff + oneOf: + - type: "null" + - type: object + title: TTimerEstimate + description: Estimate timing state for a T-timer + properties: + paused: + description: Whether the estimate is frozen + type: boolean + zeroTime: + description: Unix timestamp in milliseconds of estimated arrival at the anchor + part + type: number + durationMs: + description: Frozen remaining duration estimate in milliseconds + type: number + required: + - paused + additionalProperties: false + anchorPartId: + description: The Part ID that this timer is counting towards (the timing anchor) + type: string + required: + - index + - label + - configured + additionalProperties: false + examples: + - index: 1 + label: Segment Timer + configured: true + mode: + type: countdown + paused: false + zeroTime: 1706371920000 + durationMs: 120000 + stopAtZero: true + estimate: + paused: false + zeroTime: 1706371920000 + anchorPartId: part_break_1 + minItems: 3 + maxItems: 3 required: - event - id @@ -719,9 +863,10 @@ channels: - currentSegment - nextPart - timing + - tTimers additionalProperties: false examples: - - &a29 + - &a31 event: activePlaylist id: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ externalId: 1ZIYVYL1aEkNEJbeGsmRXr5s8wtkyxfPRjNSTxZfcoEI @@ -735,8 +880,21 @@ channels: category: Evening News timing: *a27 quickLoop: *a28 + tTimers: + - index: 1 + label: On Air Timer + configured: true + mode: *a29 + - index: 2 + label: "" + configured: false + mode: null + - index: 3 + label: Studio Clock + configured: true + mode: *a30 examples: - - payload: *a29 + - payload: *a31 activePieces: description: Topic for active pieces updates subscribe: @@ -761,20 +919,20 @@ channels: activePieces: description: Pieces that are currently active (on air) type: array - items: *a30 + items: *a32 required: - event - rundownPlaylistId - activePieces additionalProperties: false examples: - - &a31 + - &a33 event: activePieces rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ activePieces: - *a13 examples: - - payload: *a31 + - payload: *a33 segments: description: Topic for Segment updates subscribe: @@ -803,7 +961,7 @@ channels: type: object title: Segment allOf: - - *a32 + - *a34 - type: object title: Segment properties: @@ -817,7 +975,7 @@ channels: name: description: Name of the segment type: string - timing: *a33 + timing: *a35 publicData: description: Optional arbitrary data required: @@ -830,7 +988,7 @@ channels: - name - timing examples: - - &a34 + - &a36 identifier: Segment 0 identifier rundownId: y9HauyWkcxQS3XaAOsW40BRLLsI_ name: Segment 0 @@ -846,13 +1004,13 @@ channels: - rundownPlaylistId - segments examples: - - &a35 + - &a37 event: segments rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ segments: - - *a34 + - *a36 examples: - - payload: *a35 + - payload: *a37 adLibs: description: Topic for AdLibs updates subscribe: @@ -882,7 +1040,7 @@ channels: items: title: AdLibStatus allOf: - - &a40 + - &a42 title: AdLibBase type: object properties: @@ -917,7 +1075,7 @@ channels: - label additionalProperties: false examples: - - &a36 + - &a38 name: pvw label: Preview tags: @@ -937,15 +1095,15 @@ channels: - sourceLayer - actionType examples: - - &a41 + - &a43 id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: &a37 - - *a36 - tags: &a38 + actionType: &a39 + - *a38 + tags: &a40 - music_video - publicData: &a39 + publicData: &a41 fileName: MV000123.mxf optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video @@ -977,15 +1135,15 @@ channels: - segmentId - partId examples: - - &a42 + - &a44 segmentId: HsD8_QwE1ZmR5vN3XcK_Ab7y partId: JkL3_OpR6WxT1bF8Vq2_Zy9u id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: *a37 - tags: *a38 - publicData: *a39 + actionType: *a39 + tags: *a40 + publicData: *a41 optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video Clip","type":"object","properties":{"type":"adlib_action_video_clip","label":{"type":"string"},"clipId":{"type":"string"},"vo":{"type":"boolean"},"target":{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Object @@ -1009,9 +1167,9 @@ channels: items: title: GlobalAdLibStatus allOf: - - *a40 + - *a42 examples: - - *a41 + - *a43 required: - event - rundownPlaylistId @@ -1019,15 +1177,15 @@ channels: - globalAdLibs additionalProperties: false examples: - - &a43 + - &a45 event: adLibs rundownPlaylistId: OKAgZmZ0Buc99lE_2uPPSKVbMrQ_ adLibs: - - *a42 + - *a44 globalAdLibs: - - *a41 + - *a43 examples: - - payload: *a43 + - payload: *a45 packages: description: Packages topic for websocket subscriptions. Packages are assets that need to be prepared by Sofie Package Manager or third-party systems @@ -1135,7 +1293,7 @@ channels: - pieceOrAdLibId additionalProperties: false examples: - - &a44 + - &a46 packageName: MV000123.mxf status: ok rundownId: y9HauyWkcxQS3XaAOsW40BRLLsI_ @@ -1153,7 +1311,7 @@ channels: - event: packages rundownPlaylistId: y9HauyWkcxQS3XaAOsW40BRLLsI_ packages: - - *a44 + - *a46 buckets: description: Buckets schema for websocket subscriptions subscribe: @@ -1189,7 +1347,7 @@ channels: items: title: BucketAdLibStatus allOf: - - *a40 + - *a42 - type: object title: BucketAdLibStatus properties: @@ -1200,14 +1358,14 @@ channels: required: - externalId examples: - - &a45 + - &a47 externalId: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: Music video clip sourceLayer: Video Clip - actionType: *a37 - tags: *a38 - publicData: *a39 + actionType: *a39 + tags: *a40 + publicData: *a41 optionsSchema: '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Play Video Clip","type":"object","properties":{"type":"adlib_action_video_clip","label":{"type":"string"},"clipId":{"type":"string"},"vo":{"type":"boolean"},"target":{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"Object @@ -1231,22 +1389,22 @@ channels: - adLibs additionalProperties: false examples: - - &a46 + - &a48 id: C6K_yIMuGFUk8X_L9A9_jRT6aq4_ name: My Bucket adLibs: - - *a45 + - *a47 required: - event - buckets additionalProperties: false examples: - - &a47 + - &a49 event: buckets buckets: - - *a46 + - *a48 examples: - - payload: *a47 + - payload: *a49 notifications: description: Notifications topic for websocket subscriptions. subscribe: @@ -1310,7 +1468,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: &a48 + enum: &a50 - rundown - playlist - partInstance @@ -1322,7 +1480,7 @@ channels: type: string additionalProperties: false examples: - - &a49 + - &a51 type: rundown studioId: studio01 rundownId: rd123 @@ -1338,14 +1496,14 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a50 studioId: type: string playlistId: type: string additionalProperties: false examples: - - &a50 + - &a52 type: playlist studioId: studio01 playlistId: pl456 @@ -1362,7 +1520,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a50 studioId: type: string rundownId: @@ -1371,7 +1529,7 @@ channels: type: string additionalProperties: false examples: - - &a51 + - &a53 type: partInstance studioId: studio01 rundownId: rd123 @@ -1390,7 +1548,7 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a50 studioId: type: string rundownId: @@ -1401,7 +1559,7 @@ channels: type: string additionalProperties: false examples: - - &a52 + - &a54 type: pieceInstance studioId: studio01 rundownId: rd123 @@ -1417,17 +1575,17 @@ channels: type: string title: NotificationTargetType description: Possible NotificationTarget types - enum: *a48 + enum: *a50 additionalProperties: false examples: - - &a53 + - &a55 type: unknown examples: - - *a49 - - *a50 - *a51 - *a52 - *a53 + - *a54 + - *a55 created: type: integer format: int64 @@ -1438,11 +1596,11 @@ channels: description: Unix timestamp of last modification additionalProperties: false examples: - - &a54 + - &a56 _id: notif123 severity: error message: disk.space.low - relatedTo: *a52 + relatedTo: *a54 created: 1694784932 modified: 1694784950 required: @@ -1450,9 +1608,9 @@ channels: - activeNotifications additionalProperties: false examples: - - &a55 + - &a57 event: notifications activeNotifications: - - *a54 + - *a56 examples: - - payload: *a55 + - payload: *a57 diff --git a/packages/live-status-gateway-api/src/generated/schema.ts b/packages/live-status-gateway-api/src/generated/schema.ts index b8f0970662a..235e374de37 100644 --- a/packages/live-status-gateway-api/src/generated/schema.ts +++ b/packages/live-status-gateway-api/src/generated/schema.ts @@ -186,6 +186,10 @@ interface ActivePlaylistEvent { * Information about the current quickLoop, if any */ quickLoop?: ActivePlaylistQuickLoop + /** + * T-timers for the playlist. Always contains 3 elements (one for each timer slot). + */ + tTimers: TTimerStatus[] } interface CurrentPartStatus { @@ -454,6 +458,109 @@ enum QuickLoopMarkerType { PART = 'part', } +/** + * Status of a single T-timer in the playlist + */ +interface TTimerStatus { + /** + * Timer index (1-3). The playlist always has 3 T-timer slots. + */ + index: TTimerIndex + /** + * User-defined label for the timer + */ + label: string + /** + * Whether the timer has been configured (mode is not null) + */ + configured: boolean + /** + * Timer mode and timing state. Null if not configured. + */ + mode?: TTimerModeCountdown | TTimerModeFreeRun | null + /** + * Estimated timing for when we expect to reach an anchor part. Used to calculate over/under diff + */ + estimate?: TTimerEstimate | null + /** + * The Part ID that this timer is counting towards (the timing anchor) + */ + anchorPartId?: string +} + +/** + * Timer index (1-3). The playlist always has 3 T-timer slots. + */ +enum TTimerIndex { + NUMBER_1 = 1, + NUMBER_2 = 2, + NUMBER_3 = 3, +} + +/** + * Countdown timer mode - counts down from a duration + */ +interface TTimerModeCountdown { + type: 'countdown' + /** + * Whether the timer is currently paused + */ + paused: boolean + /** + * Unix timestamp (ms) when the timer reaches/reached zero. Present when paused is false. The client calculates remaining time as zeroTime - Date.now(). + */ + zeroTime?: number + /** + * Frozen remaining duration in milliseconds. Present when paused is true. + */ + remainingMs?: number + /** + * Total countdown duration in milliseconds (the original configured duration) + */ + durationMs: number + /** + * Whether timer stops at zero or continues into negative values + */ + stopAtZero: boolean +} + +/** + * Free-running timer mode - counts up from start time + */ +interface TTimerModeFreeRun { + type: 'freeRun' + /** + * Whether the timer is currently paused + */ + paused: boolean + /** + * Unix timestamp (ms) when the timer was at zero (i.e. when it was started). Present when paused is false. The client calculates elapsed time as Date.now() - zeroTime. + */ + zeroTime?: number + /** + * Frozen elapsed time in milliseconds. Present when paused is true. + */ + elapsedMs?: number +} + +/** + * Estimate timing state for a T-timer + */ +interface TTimerEstimate { + /** + * Whether the estimate is frozen + */ + paused: boolean + /** + * Unix timestamp in milliseconds of estimated arrival at the anchor part + */ + zeroTime?: number + /** + * Frozen remaining duration estimate in milliseconds + */ + durationMs?: number +} + interface ActivePiecesEvent { event: 'activePieces' /** @@ -924,6 +1031,11 @@ export { ActivePlaylistQuickLoop, QuickLoopMarker, QuickLoopMarkerType, + TTimerStatus, + TTimerIndex, + TTimerModeCountdown, + TTimerModeFreeRun, + TTimerEstimate, ActivePiecesEvent, SegmentsEvent, Segment, diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index 702ee867c6d..7e173d89fb5 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -19,6 +19,7 @@ import { ActivePlaylistEvent, ActivePlaylistTimingMode, SegmentCountdownType, + TTimerIndex, } from '@sofie-automation/live-status-gateway-api' function makeEmptyTestPartInstances(): SelectedPartInstances { @@ -63,6 +64,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: TTimerIndex.NUMBER_1, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_2, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_3, label: '', configured: false, mode: null, estimate: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method @@ -164,6 +170,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: TTimerIndex.NUMBER_1, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_2, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_3, label: '', configured: false, mode: null, estimate: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method @@ -270,6 +281,11 @@ describe('ActivePlaylistTopic', () => { timingMode: ActivePlaylistTimingMode.NONE, }, quickLoop: undefined, + tTimers: [ + { index: TTimerIndex.NUMBER_1, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_2, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_3, label: '', configured: false, mode: null, estimate: null }, + ], } // eslint-disable-next-line @typescript-eslint/unbound-method @@ -278,4 +294,95 @@ describe('ActivePlaylistTopic', () => { JSON.parse(JSON.stringify(expectedStatus)) ) }) + + it('transforms configured T-timers correctly', async () => { + const handlers = makeMockHandlers() + const topic = new ActivePlaylistTopic(makeMockLogger(), handlers) + const mockSubscriber = makeMockSubscriber() + + const playlist = makeTestPlaylist() + playlist.activationId = protectString('somethingRandom') + // Configure timers with different modes + playlist.tTimers = [ + { + index: 1, + label: 'Countdown Timer', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1600000060000 }, + estimateState: { paused: false, zeroTime: 1600000060000 }, + anchorPartId: protectString('PART_BREAK'), + }, + { + index: 2, + label: 'Paused FreeRun', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 10000 }, + estimateState: { paused: true, duration: 5000 }, + }, + { index: 3, label: '', mode: null, state: null }, + ] + handlers.playlistHandler.notify(playlist) + + const testShowStyleBase = makeTestShowStyleBase() + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) + + const testPartInstancesMap = makeEmptyTestPartInstances() + handlers.partInstancesHandler.notify(testPartInstancesMap) + + topic.addSubscriber(mockSubscriber) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockSubscriber.send).toHaveBeenCalledTimes(1) + const receivedStatus = JSON.parse(mockSubscriber.send.mock.calls[0][0] as string) as ActivePlaylistEvent + + // Verify running countdown timer transformation + expect(receivedStatus.tTimers[0]).toEqual({ + index: TTimerIndex.NUMBER_1, + label: 'Countdown Timer', + configured: true, + mode: { + type: 'countdown', + paused: false, + zeroTime: 1600000060000, + durationMs: 60000, + stopAtZero: true, + }, + estimate: { + paused: false, + zeroTime: 1600000060000, + }, + anchorPartId: 'PART_BREAK', + }) + + // Verify paused freeRun timer transformation + expect(receivedStatus.tTimers[1]).toEqual({ + index: TTimerIndex.NUMBER_2, + label: 'Paused FreeRun', + configured: true, + mode: { + type: 'freeRun', + paused: true, + elapsedMs: 10000, + }, + estimate: { + paused: true, + durationMs: 5000, + }, + }) + + // Verify unconfigured timer + expect(receivedStatus.tTimers[2]).toEqual({ + index: TTimerIndex.NUMBER_3, + label: '', + configured: false, + mode: null, + estimate: null, + }) + }) }) diff --git a/packages/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 576b1cb7436..7afa37e1104 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -34,6 +34,11 @@ export function makeTestPlaylist(id?: string): DBRundownPlaylist { studioId: protectString('STUDIO_1'), timing: { type: PlaylistTimingType.None }, publicData: { a: 'b' }, + 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/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index f1f29c940d4..21563e79487 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -5,6 +5,7 @@ import { DBRundownPlaylist, QuickLoopMarker, QuickLoopMarkerType, + RundownTTimer, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { assertNever, literal } from '@sofie-automation/shared-lib/dist/lib/lib' @@ -30,6 +31,11 @@ import { ActivePlaylistQuickLoop, QuickLoopMarker as QuickLoopMarkerStatus, QuickLoopMarkerType as QuickLoopMarkerStatusType, + TTimerStatus, + TTimerEstimate, + TTimerModeCountdown, + TTimerModeFreeRun, + TTimerIndex, } from '@sofie-automation/live-status-gateway-api' import { CollectionHandlers } from '../liveStatusServer.js' @@ -50,6 +56,7 @@ const PLAYLIST_KEYS = [ 'timing', 'startedPlayback', 'quickLoop', + 'tTimers', ] as const type Playlist = PickKeys @@ -170,6 +177,7 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket ? this._activePlaylist.timing.expectedEnd : undefined, }, + tTimers: this.transformTTimers(this._activePlaylist.tTimers), }) : literal({ event: 'activePlaylist', @@ -185,6 +193,7 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket timing: { timingMode: ActivePlaylistTimingMode.NONE, }, + tTimers: this.transformTTimers(undefined), }) this.sendMessage(subscribers, message) @@ -204,6 +213,111 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket } } + /** + * Transform T-timers from database format to API status format + */ + private transformTTimers( + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] | undefined + ): [TTimerStatus, TTimerStatus, TTimerStatus] { + if (!tTimers) { + // Return 3 unconfigured timers when no playlist is active + return [ + { index: TTimerIndex.NUMBER_1, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_2, label: '', configured: false, mode: null, estimate: null }, + { index: TTimerIndex.NUMBER_3, label: '', configured: false, mode: null, estimate: null }, + ] + } + + return [this.transformTTimer(tTimers[0]), this.transformTTimer(tTimers[1]), this.transformTTimer(tTimers[2])] + } + + /** + * Transform a single T-timer from database format to API status format + */ + private transformTTimer(timer: RundownTTimer): TTimerStatus { + const index = + timer.index === 1 ? TTimerIndex.NUMBER_1 : timer.index === 2 ? TTimerIndex.NUMBER_2 : TTimerIndex.NUMBER_3 + + const estimate = this.transformTimerEstimate(timer.estimateState) + const anchorPartId = timer.anchorPartId ? unprotectString(timer.anchorPartId) : undefined + + if (!timer.mode || !timer.state) { + return { + index, + label: timer.label, + configured: false, + mode: null, + estimate, + anchorPartId, + } + } + + if (timer.mode.type === 'countdown') { + const mode: TTimerModeCountdown = timer.state.paused + ? { + type: 'countdown', + paused: true, + remainingMs: timer.state.duration, + durationMs: timer.mode.duration, + stopAtZero: timer.mode.stopAtZero, + } + : { + type: 'countdown', + paused: false, + zeroTime: timer.state.zeroTime, + durationMs: timer.mode.duration, + stopAtZero: timer.mode.stopAtZero, + } + return { + index, + label: timer.label, + configured: true, + mode, + estimate, + anchorPartId, + } + } else { + const mode: TTimerModeFreeRun = timer.state.paused + ? { + type: 'freeRun', + paused: true, + elapsedMs: timer.state.duration, + } + : { + type: 'freeRun', + paused: false, + zeroTime: timer.state.zeroTime, + } + return { + index, + label: timer.label, + configured: true, + mode, + estimate, + anchorPartId, + } + } + } + + /** + * Transform a TimerState from the data model to a TTimerEstimate for the API + */ + private transformTimerEstimate(estimateState: RundownTTimer['estimateState']): TTimerEstimate | null { + if (!estimateState) return null + + if (estimateState.paused) { + return { + paused: true, + durationMs: estimateState.duration, + } + } else { + return { + paused: false, + zeroTime: estimateState.zeroTime, + } + } + } + private transformQuickLoopMarkerStatus(marker: QuickLoopMarker | undefined): QuickLoopMarkerStatus | undefined { if (!marker) return undefined 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"