Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
c88243f
wip: data sketch
Julusian Jan 13, 2026
d5d7483
wip: fix types
Julusian Jan 13, 2026
b0930d2
wip: migration
Julusian Jan 13, 2026
006d153
wip: blueprint api sketch
Julusian Jan 13, 2026
4a32487
wip: complete sketch
Julusian Jan 13, 2026
d219b91
wip: clear all methiod
Julusian Jan 13, 2026
336874c
wip
Julusian Jan 14, 2026
1ce855a
wip
Julusian Jan 14, 2026
7395d85
wip: first draft complete?
Julusian Jan 14, 2026
bf6fc3d
wip: tests
Julusian Jan 14, 2026
47e38b4
lint
Julusian Jan 14, 2026
baa8947
time of day timer mode
Julusian Jan 19, 2026
9150212
lockfile
Julusian Jan 19, 2026
7a3ceca
SOFIE-261 | add UI for t-timers (WIP)
anteeek Jan 16, 2026
884cb66
SOFIE-261 | change alignment of t-timers in rundown screen
anteeek Feb 4, 2026
9892421
Refactor UI for new timer style
rjmunro Jan 30, 2026
44aa842
Merge pull request #72 from bbc/rjmunro/t-timers-ui
anteeek Feb 11, 2026
b7dd288
Merge branch 'feat/t-timers' of github.com:bbc/sofie-core into feat/t…
anteeek Feb 11, 2026
a5bf059
SOFIE-261 | (WIP) add estimates over/under to t-timers UI in director
anteeek Feb 11, 2026
15ad2fd
SOFIE-261 | add UI for t-timers (WIP)
anteeek Jan 16, 2026
95dc71f
SOFIE-261 | change alignment of t-timers in rundown screen
anteeek Feb 4, 2026
079b70b
Refactor UI for new timer style
rjmunro Jan 30, 2026
e9fc4c4
Apply code review suggestions
anteeek Feb 17, 2026
b45e406
WIP - new topbar
anteeek Feb 17, 2026
0c62b53
New top bar UI - WIP
anteeek Feb 25, 2026
f8cac9a
top-bar ui: some style changes, implement est. end properly'
anteeek Feb 25, 2026
174eed5
chore: Styling walltime clock with correct font properties.
hummelstrand Feb 26, 2026
e92803e
chore: Styling of Over/Under labels and ON AIR label.
hummelstrand Feb 26, 2026
9656727
chore: Common label style for header labels that react to hover. Fixe…
hummelstrand Feb 27, 2026
9e73ab7
chore: Updated styling on the Over/Under (previously known as "Diff")…
hummelstrand Feb 27, 2026
7322b8e
chore: Removed extra styling of the labels of the Show Counters group.
hummelstrand Feb 27, 2026
6deba15
add simplified mode to top bar
anteeek Feb 27, 2026
c6e1de5
New top bar UI: refactor class names
anteeek Mar 2, 2026
9e75447
New top bar UI: remove caret on text and restore legacy component for…
anteeek Mar 2, 2026
c40538b
new top bar UI: make playlist name hidden until hovered over
anteeek Mar 2, 2026
3756004
Merge branch 'feat/t-timers-ui-estimates' into feat/t-timers-ui-topba…
anteeek Mar 3, 2026
8ffbbd4
chore: Added single-pixel line at the bottom of the Top Bar.
hummelstrand Mar 3, 2026
3dbfc0d
chore: Created two distinct styles for two types of counters.
hummelstrand Mar 3, 2026
38db359
chore: Small tweaks to the typographic styles of Top Bar counters.
hummelstrand Mar 3, 2026
af310ea
New top bar UI: visual tweaks
anteeek Mar 3, 2026
30d32b9
New top bar UI: fix circular scss dependencies
anteeek Mar 3, 2026
efa0a3d
Merge branch 'feat/t-timers-ui-topbar' of github.com:bbc/sofie-core i…
anteeek Mar 3, 2026
264bb6e
Top bar UI: fix clocks alignment
anteeek Mar 3, 2026
0903449
chore: Created the two separate font stylings for the Over/Under pill…
hummelstrand Mar 4, 2026
bd175d7
New top bar ui: visual changes - fix hover transition on text in play…
anteeek Mar 4, 2026
b8ba5b3
New top bar UI: unify styles, fix visual issues
anteeek Mar 4, 2026
1a405ac
chore: Playlist and Rundown font styling.
hummelstrand Mar 5, 2026
033d22b
New top bar UI: fix countdown classes
anteeek Mar 5, 2026
eba0f26
chore: Counter and TimeOf Day styling.
hummelstrand Mar 5, 2026
b646924
Top bar UI: unify over/under in t-timers
anteeek Mar 5, 2026
ac48665
Merge branch 'feat/t-timers-ui-topbar' of github.com:bbc/sofie-core i…
anteeek Mar 5, 2026
c0f38eb
Top bar UI: change layout of t-timers to grid
anteeek Mar 5, 2026
c279847
Top bar UI: add dimming to inactive timer parts
anteeek Mar 5, 2026
37f7a55
chore: Tweaks to styling of T-timers.
hummelstrand Mar 5, 2026
31e0f8f
Top bar UI: css tweaks
anteeek Mar 5, 2026
c220ca4
Top bar UI: css tweaks
anteeek Mar 5, 2026
4a28bf8
chore: Tweaked vertical label placement.
hummelstrand Mar 5, 2026
bdbc0f0
Top bar UI: css tweaks
anteeek Mar 6, 2026
9c9c50b
Merge branch 'feat/t-timers-ui-topbar' of github.com:bbc/sofie-core i…
anteeek Mar 6, 2026
5d9b415
New Top Bar UI: align onair styles with timeline, hide segment budget…
anteeek Mar 6, 2026
e323268
chore: Corrected the vertical alignment of the ON AIR label.
hummelstrand Mar 6, 2026
13dd50a
chore: Tweaked the T-timer Over/Under pill and narrowed the gap betwe…
hummelstrand Mar 6, 2026
d8521d1
chore: Made the Show Timers group glow when the user hovers over the …
hummelstrand Mar 6, 2026
8ce7480
chore: Tweak to vertical counter label alignment.
hummelstrand Mar 6, 2026
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
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
60 changes: 60 additions & 0 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 @@ -165,6 +205,26 @@ export interface RundownTTimer {
*/
state: TimerState | null

/** The estimated time when we expect to reach the anchor part, for calculating over/under diff.
*
* Based on scheduled durations of remaining parts and segments up to the anchor.
* 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 if custom logic is needed.
*/
estimateState?: TimerState

/** The target Part that this timer is counting towards (the "timing anchor")
*
* This is typically a "break" part or other milestone in the rundown.
* When set, the server calculates estimateState based on when we expect to reach this part.
* If not set, estimateState is not calculated automatically but can still be set manually by a blueprint.
*/
anchorPartId?: PartId

/*
* Future ideas:
* allowUiControl: boolean
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