Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
f6760c5
feat: Add optional estimateState to T-Timer data type
rjmunro Jan 30, 2026
af76300
feat: Add function to Caclulate estimates for anchored T-Timers
rjmunro Feb 4, 2026
a156181
feat: Add RecalculateTTimerEstimates job and integrate into playout w…
rjmunro Feb 4, 2026
640f477
feat: add timeout for T-Timer recalculations when pushing expected to…
rjmunro Feb 4, 2026
18c85a4
feat: queue initial T-Timer recalculation when job-worker restarts
rjmunro Feb 4, 2026
6d86a9a
feat(blueprints): Add blueprint interface methods for T-Timer estimat…
rjmunro Feb 4, 2026
ae88fa7
feat: Add ignoreQuickLoop parameter to getOrderedPartsAfterPlayhead f…
rjmunro Feb 17, 2026
e1b26f4
feat: Refactor recalculateTTimerEstimates to use getOrderedPartsAfter…
rjmunro Feb 17, 2026
7d3258a
test: Add tests for new T-Timers functions
rjmunro Feb 4, 2026
7e41fea
feat(T-Timers): Add segment budget timing support to estimate calcula…
rjmunro Feb 5, 2026
2a48e27
Fix test by adding missing mocks
rjmunro Feb 20, 2026
f2e8dd9
feat(T-Timers): Add convenience method to set estimate anchor part by…
rjmunro Feb 25, 2026
80150aa
feat(T-Timers): Add pauseTime field to timer estimates
rjmunro Feb 18, 2026
30b032d
Remove timeout based update of T-Timer now we have pauseTime
rjmunro Feb 19, 2026
76b669a
docs(T-Timers): Add client rendering logic for pauseTime
rjmunro Feb 19, 2026
55632b9
feat(T-Timers): Add timerStateToDuration helper function
rjmunro Feb 19, 2026
40d90d5
Fix sign of over/under calculation
rjmunro Mar 6, 2026
b647e5f
Include next part in calculation
rjmunro Mar 6, 2026
ac306b6
Don't fetch all parts just to get a max length
rjmunro Mar 6, 2026
1132ad0
Ensure we recalculate timings when we queue segments
rjmunro Mar 6, 2026
9abc53e
Fix linting issue
rjmunro Mar 9, 2026
e22ff33
Remove mock data
rjmunro Mar 10, 2026
45f5c44
chore: changes wording for Rem. Dur (was Est. Dur)
jesperstarkar Mar 12, 2026
659c020
Merge commit 'fdc144a86623b2b5874789ac366307e514d3c2ec' into rjmunro/…
jesperstarkar Mar 12, 2026
78df2d3
Merge branch 'feat/t-timers-ui-topbar' into rjmunro/t-timers-expected
hummelstrand Mar 12, 2026
fb66b3f
remove RundownHeader_old folder left behind by mistake
rjmunro Mar 12, 2026
b6c3d44
fix: Correct duration calculations in RundownHeader components by usi…
rjmunro Mar 12, 2026
f30aaf3
Linting improvements
rjmunro Mar 12, 2026
36f3add
chore: Made all timer labels center-aligned.
hummelstrand Mar 12, 2026
423af55
Merge branch 'rjmunro/t-timers-expected' of https://github.com/bbc/so…
hummelstrand Mar 12, 2026
2f59e44
chore: Changed the Rehearsal background to striped grey, and added la…
hummelstrand Mar 12, 2026
5707c3a
fix: more explicit truthy check allow keeping 0 dur timers visible
jesperstarkar Mar 12, 2026
e4f15fa
chore: cleanup
jesperstarkar Mar 12, 2026
ffa7999
WIP: add open state to menu icon in top bar
anteeek Mar 12, 2026
7d4c6a1
fix: allow Plan. end to show even at the end of the show
jesperstarkar Mar 12, 2026
376613b
Remove no longer needed file
rjmunro Mar 12, 2026
0498831
Fix overdeletion of _old files
rjmunro Mar 12, 2026
1898fa3
Top bar: fix hamburger menu not closing in some cases
anteeek Mar 13, 2026
464bf59
Top bar: add close option to context menu
anteeek Mar 13, 2026
c302af4
Fix close button disappearing from view on smaller screens in rundown…
anteeek Mar 13, 2026
180fdc6
Top bar UI: disable diff display in untimed mode before first take
anteeek Mar 13, 2026
1758a6e
Top bar UI: keep showing remaining duration and estimated end before …
anteeek Mar 13, 2026
0215994
Top bar: disable mode switching of timers if the other one is empty
anteeek Mar 13, 2026
402375f
Unify t-timers styling in topbar
anteeek Mar 13, 2026
faf55fc
Top bar UI: fix baseline of playlist name
anteeek Mar 13, 2026
f6b0ccf
Revert "Top bar UI: keep showing remaining duration and estimated end…
jesperstarkar Mar 13, 2026
3eec775
chore: Changed the Detailed View Start label.
hummelstrand Mar 13, 2026
61342e4
chore: Removed the "Rehearsal" and "Deactivated" labels.
hummelstrand Mar 13, 2026
b2ea4d0
chore: Reliably vertically aligned the labels in the middle.
hummelstrand Mar 13, 2026
d8a269a
chore: Made the layout stable when icons of differing widths were use…
hummelstrand Mar 13, 2026
8f0d293
fix: Correct prefix for Start In when passing the planned start time
jesperstarkar Mar 13, 2026
da45749
fix: more robust falsy checks that allow 0 values
jesperstarkar Mar 13, 2026
e92f1e1
chore: Tweaks to the menu wording and menu styling. Added visible men…
hummelstrand Mar 13, 2026
6dc7d0e
fix: swaps the timers in the simple view mode
jesperstarkar Mar 13, 2026
e55a39c
chore: Made sure the menu icon only changed state and was clickable w…
hummelstrand Mar 13, 2026
04bea65
chore: Made Top Bar label colored instead of semi-transparent since t…
hummelstrand Mar 13, 2026
26a73cc
chore: Flipped the angle of the "unfocused window" border so that it …
hummelstrand Mar 13, 2026
fd73923
Top bar UI: css improvements
anteeek Mar 16, 2026
0034eec
chore: Added visual hover indication on the menu and close buttons.
hummelstrand Mar 16, 2026
7e06a08
chore: Added a Bootstrap CSS customization to get around the faulty s…
hummelstrand Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/blueprints-integration/src/context/tTimersContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@ export interface IPlaylistTTimer {
* @returns True if the timer was restarted, false if it could not be restarted
*/
restart(): boolean

/**
* Clear any estimate (manual or anchor-based) for this timer
* This removes both manual estimates set via setEstimateTime/setEstimateDuration
* and automatic estimates based on anchor parts set via setEstimateAnchorPart.
*/
clearEstimate(): void

/**
* Set the anchor part for automatic estimate calculation
* When set, the server automatically calculates when we expect to reach this part
* based on remaining part durations, and updates the estimate accordingly.
* Clears any manual estimate set via setEstimateTime/setEstimateDuration.
* @param partId The ID of the part to use as timing anchor
*/
setEstimateAnchorPart(partId: string): void

/**
* Set the anchor part for automatic estimate calculation, looked up by its externalId.
* This is a convenience method when you know the externalId of the part (e.g. set during ingest)
* but not its internal PartId. If no part with the given externalId is found, this is a no-op.
* Clears any manual estimate set via setEstimateTime/setEstimateDuration.
* @param externalId The externalId of the part to use as timing anchor
*/
setEstimateAnchorPartByExternalId(externalId: string): void

/**
* Manually set the estimate as an absolute timestamp
* Use this when you have custom logic for calculating when you expect to reach a timing point.
* Clears any anchor part set via setAnchorPart.
* @param time Unix timestamp (milliseconds) when we expect to reach the timing point
* @param paused If true, we're currently delayed/pushing (estimate won't update with time passing).
* If false (default), we're progressing normally (estimate counts down in real-time).
*/
setEstimateTime(time: number, paused?: boolean): void

/**
* Manually set the estimate as a relative duration from now
* Use this when you want to express the estimate as "X milliseconds from now".
* Clears any anchor part set via setAnchorPart.
* @param duration Milliseconds until we expect to reach the timing point
* @param paused If true, we're currently delayed/pushing (estimate won't update with time passing).
* If false (default), we're progressing normally (estimate counts down in real-time).
*/
setEstimateDuration(duration: number, paused?: boolean): void
}

export type IPlaylistTTimerState =
Expand Down
51 changes: 47 additions & 4 deletions packages/corelib/src/dataModel/RundownPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,61 @@ export interface RundownTTimerModeTimeOfDay {
* Timing state for a timer, optimized for efficient client rendering.
* When running, the client calculates current time from zeroTime.
* When paused, the duration is frozen and sent directly.
* pauseTime indicates when the timer should automatically pause (when current part ends and overrun begins).
*
* Client rendering logic:
* ```typescript
* if (state.paused === true) {
* // Manually paused by user or already pushing/overrun
* duration = state.duration
* } else if (state.pauseTime && now >= state.pauseTime) {
* // Auto-pause at overrun (current part ended)
* duration = state.zeroTime - state.pauseTime
* } else {
* // Running normally
* duration = state.zeroTime - now
* }
* ```
*/
export type TimerState =
| {
/** Whether the timer is paused */
paused: false
/** The absolute timestamp (ms) when the timer reaches/reached zero */
zeroTime: number
/** Optional timestamp when the timer should pause (when current part ends) */
pauseTime?: number | null
}
| {
/** Whether the timer is paused */
paused: true
/** The frozen duration value in milliseconds */
duration: number
/** Optional timestamp when the timer should pause (null when already paused/pushing) */
pauseTime?: number | null
}

/**
* Calculate the current duration for a timer state.
* Handles paused, auto-pause (pauseTime), and running states.
*
* @param state The timer state
* @param now Current timestamp in milliseconds
* @returns The current duration in milliseconds
*/
export function timerStateToDuration(state: TimerState, now: number): number {
if (state.paused) {
// Manually paused by user or already pushing/overrun
return state.duration
} else if (state.pauseTime && now >= state.pauseTime) {
// Auto-pause at overrun (current part ended)
return state.zeroTime - state.pauseTime
} else {
// Running normally
return state.zeroTime - now
}
}

export type RundownTTimerIndex = 1 | 2 | 3

export interface RundownTTimer {
Expand All @@ -168,15 +208,18 @@ export interface RundownTTimer {
/** The estimated time when we expect to reach the anchor part, for calculating over/under diff.
*
* Based on scheduled durations of remaining parts and segments up to the anchor.
* Running means we are progressing towards the anchor (estimate moves with real time).
* Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed).
* The over/under diff is calculated as the difference between this estimate and the timer's target (state.zeroTime).
*
* Running means we are progressing towards the anchor (estimate moves with real time)
* Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed)
*
* Calculated automatically when anchorPartId is set, or can be set manually by a blueprint.
* Calculated automatically when anchorPartId is set, or can be set manually by a blueprint if custom logic is needed.
*/
estimateState?: TimerState

/** The target Part that this timer is counting towards (the "timing anchor").
/** The target Part that this timer is counting towards (the "timing anchor")
*
* This is typically a "break" part or other milestone in the rundown.
* When set, the server calculates estimateState based on when we expect to reach this part.
* If not set, estimateState is not calculated automatically but can still be set manually by a blueprint.
*/
Expand Down
8 changes: 8 additions & 0 deletions packages/corelib/src/worker/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export enum StudioJobs {
*/
OnTimelineTriggerTime = 'onTimelineTriggerTime',

/**
* Recalculate T-Timer estimates based on current playlist state
* Called after setNext, takes, and ingest changes to update timing anchor estimates
*/
RecalculateTTimerEstimates = 'recalculateTTimerEstimates',

/**
* Update the timeline with a regenerated Studio Baseline
* Has no effect if a Playlist is active
Expand Down Expand Up @@ -412,6 +418,8 @@ export type StudioJobFunc = {
[StudioJobs.OnPlayoutPlaybackChanged]: (data: OnPlayoutPlaybackChangedProps) => void
[StudioJobs.OnTimelineTriggerTime]: (data: OnTimelineTriggerTimeProps) => void

[StudioJobs.RecalculateTTimerEstimates]: () => void

[StudioJobs.UpdateStudioBaseline]: () => string | false
[StudioJobs.CleanupEmptyPlaylists]: () => void

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class OnSetAsNextContext
public readonly manuallySelected: boolean
) {
super(contextInfo, context, showStyle, watchedPackages)
this.#tTimersService = TTimersService.withPlayoutModel(playoutModel)
this.#tTimersService = TTimersService.withPlayoutModel(playoutModel, context)
}

public get quickLoopInfo(): BlueprintQuickLookInfo | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex
) {
super(contextInfo, _context, showStyle, watchedPackages)
this.isTakeAborted = false
this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel)
this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context)
}

async getUpcomingParts(limit: number = 5): Promise<ReadonlyDeep<IBlueprintPart[]>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class RundownActivationContext extends RundownEventContext implements IRu
this._previousState = options.previousState
this._currentState = options.currentState

this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel)
this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel, this._context)
}

get previousState(): IRundownActivationContextState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns
import { normalizeArrayToMap, omit } from '@sofie-automation/corelib/dist/lib'
import { protectString, protectStringArray, unprotectStringArray } from '@sofie-automation/corelib/dist/protectedString'
import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel.js'
import { PlayoutModel } from '../../playout/model/PlayoutModel.js'
import { ReadonlyDeep } from 'type-fest'
import _ from 'underscore'
import { ContextInfo } from './CommonContext.js'
Expand Down Expand Up @@ -45,6 +46,7 @@ export class SyncIngestUpdateToPartInstanceContext
implements ISyncIngestUpdateToPartInstanceContext
{
readonly #context: JobContext
readonly #playoutModel: PlayoutModel
readonly #proposedPieceInstances: Map<PieceInstanceId, ReadonlyDeep<PieceInstance>>
readonly #tTimersService: TTimersService
readonly #changedTTimers = new Map<RundownTTimerIndex, RundownTTimer>()
Expand All @@ -61,6 +63,7 @@ export class SyncIngestUpdateToPartInstanceContext

constructor(
context: JobContext,
playoutModel: PlayoutModel,
contextInfo: ContextInfo,
studio: ReadonlyDeep<JobStudio>,
showStyleCompound: ReadonlyDeep<ProcessedShowStyleCompound>,
Expand All @@ -80,12 +83,18 @@ export class SyncIngestUpdateToPartInstanceContext
)

this.#context = context
this.#playoutModel = playoutModel
this.#partInstance = partInstance

this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id')
this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => {
this.#changedTTimers.set(updatedTimer.index, updatedTimer)
})
this.#tTimersService = new TTimersService(
playlist.tTimers,
(updatedTimer) => {
this.#changedTTimers.set(updatedTimer.index, updatedTimer)
},
this.#playoutModel,
this.#context
)
}

getTimer(index: RundownTTimerIndex): IPlaylistTTimer {
Expand Down
2 changes: 1 addition & 1 deletion packages/job-worker/src/blueprints/context/adlibActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct
private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService
) {
super(contextInfo, _context, showStyle, watchedPackages)
this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel)
this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context)
}

async getUpcomingParts(limit: number = 5): Promise<ReadonlyDeep<IBlueprintPart[]>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import type {
IPlaylistTTimerState,
} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext'
import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
import { assertNever } from '@sofie-automation/corelib/dist/lib'
import type { TimerState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids'
import { assertNever, literal } from '@sofie-automation/corelib/dist/lib'
import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString'
import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js'
import { ReadonlyDeep } from 'type-fest'
import {
Expand All @@ -14,27 +17,36 @@ import {
restartTTimer,
resumeTTimer,
validateTTimerIndex,
recalculateTTimerEstimates,
} from '../../../playout/tTimers.js'
import { getCurrentTime } from '../../../lib/time.js'
import type { JobContext } from '../../../jobs/index.js'

export class TTimersService {
readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl]

constructor(
timers: ReadonlyDeep<RundownTTimer[]>,
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void,
playoutModel: PlayoutModel,
jobContext: JobContext
) {
this.timers = [
new PlaylistTTimerImpl(timers[0], emitChange),
new PlaylistTTimerImpl(timers[1], emitChange),
new PlaylistTTimerImpl(timers[2], emitChange),
new PlaylistTTimerImpl(timers[0], emitChange, playoutModel, jobContext),
new PlaylistTTimerImpl(timers[1], emitChange, playoutModel, jobContext),
new PlaylistTTimerImpl(timers[2], emitChange, playoutModel, jobContext),
]
}

static withPlayoutModel(playoutModel: PlayoutModel): TTimersService {
return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => {
playoutModel.updateTTimer(updatedTimer)
})
static withPlayoutModel(playoutModel: PlayoutModel, jobContext: JobContext): TTimersService {
return new TTimersService(
playoutModel.playlist.tTimers,
(updatedTimer) => {
playoutModel.updateTTimer(updatedTimer)
},
playoutModel,
jobContext
)
}

getTimer(index: RundownTTimerIndex): IPlaylistTTimer {
Expand All @@ -50,6 +62,8 @@ export class TTimersService {

export class PlaylistTTimerImpl implements IPlaylistTTimer {
readonly #emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void
readonly #playoutModel: PlayoutModel
readonly #jobContext: JobContext

#timer: ReadonlyDeep<RundownTTimer>

Expand Down Expand Up @@ -96,9 +110,18 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer {
}
}

constructor(timer: ReadonlyDeep<RundownTTimer>, emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void) {
constructor(
timer: ReadonlyDeep<RundownTTimer>,
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void,
playoutModel: PlayoutModel,
jobContext: JobContext
) {
this.#timer = timer
this.#emitChange = emitChange
this.#playoutModel = playoutModel
this.#jobContext = jobContext

validateTTimerIndex(timer.index)
}

setLabel(label: string): void {
Expand Down Expand Up @@ -168,4 +191,58 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer {
this.#emitChange(newTimer)
return true
}

clearEstimate(): void {
this.#timer = {
...this.#timer,
anchorPartId: undefined,
estimateState: undefined,
}
this.#emitChange(this.#timer)
}

setEstimateAnchorPart(partId: string): void {
this.#timer = {
...this.#timer,
anchorPartId: protectString<PartId>(partId),
estimateState: undefined, // Clear manual estimate
}
this.#emitChange(this.#timer)

// Recalculate estimates immediately since we already have the playout model
recalculateTTimerEstimates(this.#jobContext, this.#playoutModel)
}

setEstimateAnchorPartByExternalId(externalId: string): void {
const part = this.#playoutModel.getAllOrderedParts().find((p) => p.externalId === externalId)
if (!part) return

this.setEstimateAnchorPart(unprotectString(part._id))
}

setEstimateTime(time: number, paused: boolean = false): void {
const estimateState: TimerState = paused
? literal<TimerState>({ paused: true, duration: time - getCurrentTime() })
: literal<TimerState>({ paused: false, zeroTime: time })

this.#timer = {
...this.#timer,
anchorPartId: undefined, // Clear automatic anchor
estimateState,
}
this.#emitChange(this.#timer)
}

setEstimateDuration(duration: number, paused: boolean = false): void {
const estimateState: TimerState = paused
? literal<TimerState>({ paused: true, duration })
: literal<TimerState>({ paused: false, zeroTime: getCurrentTime() + duration })

this.#timer = {
...this.#timer,
anchorPartId: undefined, // Clear automatic anchor
estimateState,
}
this.#emitChange(this.#timer)
}
}
Loading
Loading