From f6760c57e1974af07fd0c0533303b16f6ec38a93 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 15:26:10 +0000 Subject: [PATCH 01/57] feat: Add optional estimateState to T-Timer data type So we can measure if we are over or under time --- packages/corelib/src/dataModel/RundownPlaylist.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 2629f9a0b2..e426fb3f8b 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -168,15 +168,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). * - * Calculated automatically when anchorPartId is set, or can be set manually by a blueprint. + * 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"). + /** 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. */ From af76300c76d8b2ff733a6c078377a3c6e48e5045 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 13:19:36 +0000 Subject: [PATCH 02/57] feat: Add function to Caclulate estimates for anchored T-Timers --- packages/job-worker/src/playout/tTimers.ts | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index af86616f82..2f327550f1 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -4,9 +4,14 @@ import type { RundownTTimer, TimerState, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' +import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { JobContext } from '../jobs/index.js' +import { PlayoutModel } from './model/PlayoutModel.js' 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}`) @@ -167,3 +172,142 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe }) return parsed ? parsed.getTime() : null } + +/** + * Recalculate T-Timer estimates based on timing anchors + * + * For each T-Timer that has an anchorPartId set, this function: + * 1. Iterates through ordered parts from current/next onwards + * 2. Accumulates expected durations until the anchor part is reached + * 3. Updates estimateState with the calculated duration + * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * + * @param context Job context + * @param playoutModel The playout model containing the playlist and parts + */ +export function recalculateTTimerEstimates(context: JobContext, playoutModel: PlayoutModel): void { + const span = context.startSpan('recalculateTTimerEstimates') + + const playlist = playoutModel.playlist + const tTimers = playlist.tTimers + + // Find which timers have anchors that need calculation + const timerAnchors = new Map() + for (const timer of tTimers) { + if (timer.anchorPartId) { + const existingTimers = timerAnchors.get(timer.anchorPartId) ?? [] + existingTimers.push(timer.index) + timerAnchors.set(timer.anchorPartId, existingTimers) + } + } + + // If no timers have anchors, nothing to do + if (timerAnchors.size === 0) { + if (span) span.end() + return + } + + const currentPartInstance = playoutModel.currentPartInstance?.partInstance + const nextPartInstance = playoutModel.nextPartInstance?.partInstance + + // Get ordered parts to iterate through + const orderedParts = playoutModel.getAllOrderedParts() + + // Start from next part if available, otherwise current, otherwise first playable part + let startPartIndex: number | undefined + if (nextPartInstance) { + // We have a next part selected, start from there + startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) + } else if (currentPartInstance) { + // No next, but we have current - start from the part after current + const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) + if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { + startPartIndex = currentIndex + 1 + } + } + + // If we couldn't find a starting point, start from the first playable part + startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) + + if (startPartIndex === undefined || startPartIndex < 0) { + // No parts to iterate through, clear estimates + for (const timer of tTimers) { + if (timer.anchorPartId) { + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + if (span) span.end() + return + } + + // Iterate through parts and accumulate durations + const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) + + const now = getCurrentTime() + let accumulatedDuration = 0 + + // Calculate remaining time for current part + // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) + // Account for playOffset (e.g., from play-from-anywhere feature) + let isPushing = false + if (currentPartInstance) { + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + accumulatedDuration = Math.max(0, remaining) + } + } + + for (const part of playablePartsSlice) { + // Add this part's expected duration to the accumulator + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + accumulatedDuration += partDuration + + // Check if this part is an anchor for any timer + const timersForThisPart = timerAnchors.get(part._id) + if (timersForThisPart) { + for (const timerIndex of timersForThisPart) { + const timer = tTimers[timerIndex - 1] + + // Update the timer's estimate + const estimateState: TimerState = isPushing + ? literal({ + paused: true, + duration: accumulatedDuration, + }) + : literal({ + paused: false, + zeroTime: now + accumulatedDuration, + }) + + playoutModel.updateTTimer({ ...timer, estimateState }) + } + // Remove this anchor since we've processed it + timerAnchors.delete(part._id) + } + + // Early exit if we've resolved all timers + if (timerAnchors.size === 0) { + break + } + } + + // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) + // Any remaining entries in timerAnchors are anchors that weren't reached + for (const timerIndices of timerAnchors.values()) { + for (const timerIndex of timerIndices) { + const timer = tTimers[timerIndex - 1] + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + + if (span) span.end() +} From a156181ff9139288b04d01113f58f62246c09981 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:45:27 +0000 Subject: [PATCH 03/57] feat: Add RecalculateTTimerEstimates job and integrate into playout workflow --- packages/corelib/src/worker/studio.ts | 8 ++++ packages/job-worker/src/ingest/commit.ts | 21 ++++++--- packages/job-worker/src/playout/setNext.ts | 4 ++ .../job-worker/src/playout/tTimersJobs.ts | 44 +++++++++++++++++++ .../job-worker/src/workers/studio/jobs.ts | 3 ++ 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/job-worker/src/playout/tTimersJobs.ts diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 6eb045fc5e..18516b1d66 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -126,6 +126,12 @@ export enum StudioJobs { */ OnTimelineTriggerTime = 'onTimelineTriggerTime', + /** + * Recalculate T-Timer 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 @@ -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 diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 47e26f850c..31f6ce0313 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -29,6 +29,7 @@ import { clone, groupByToMapFunc } from '@sofie-automation/corelib/dist/lib' import { PlaylistLock } from '../jobs/lock.js' import { syncChangesToPartInstances } from './syncChangesToPartInstance.js' import { ensureNextPartIsValid } from './updateNext.js' +import { recalculateTTimerEstimates } from '../playout/tTimers.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { getTranslatedMessage, ServerTranslatedMesssages } from '../notes.js' import _ from 'underscore' @@ -234,6 +235,16 @@ export async function CommitIngestOperation( // update the quickloop in case we did any changes to things involving marker playoutModel.updateQuickLoopState() + // wait for the ingest changes to save + await pSaveIngest + + // do some final playout checks, which may load back some Parts data + // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above + await ensureNextPartIsValid(context, playoutModel) + + // Recalculate T-Timer estimates after ingest changes + recalculateTTimerEstimates(context, playoutModel) + playoutModel.deferAfterSave(() => { // Run in the background, we don't want to hold onto the lock to do this context @@ -248,13 +259,6 @@ export async function CommitIngestOperation( triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) }) - // wait for the ingest changes to save - await pSaveIngest - - // do some final playout checks, which may load back some Parts data - // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above - await ensureNextPartIsValid(context, playoutModel) - // save the final playout changes await playoutModel.saveAllToDatabase() } finally { @@ -613,6 +617,9 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( const shouldUpdateTimeline = await ensureNextPartIsValid(context, playoutModel) + // Recalculate T-Timer estimates after playlist changes + recalculateTTimerEstimates(context, playoutModel) + if (playoutModel.playlist.activationId || shouldUpdateTimeline) { triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 8739e289a3..45209a6494 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -33,6 +33,7 @@ import { import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { convertNoteToNotification } from '../notifications/util.js' import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore.js' +import { recalculateTTimerEstimates } from './tTimers.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -96,6 +97,9 @@ export async function setNextPart( await cleanupOrphanedItems(context, playoutModel) + // Recalculate T-Timer estimates based on the new next part + recalculateTTimerEstimates(context, playoutModel) + if (span) span.end() } diff --git a/packages/job-worker/src/playout/tTimersJobs.ts b/packages/job-worker/src/playout/tTimersJobs.ts new file mode 100644 index 0000000000..b1fede7642 --- /dev/null +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -0,0 +1,44 @@ +import { JobContext } from '../jobs/index.js' +import { recalculateTTimerEstimates } from './tTimers.js' +import { runWithPlayoutModel, runWithPlaylistLock } from './lock.js' + +/** + * Handle RecalculateTTimerEstimates job + * This is called after setNext, takes, and ingest changes to update T-Timer estimates + * Since this job doesn't take a playlistId parameter, it finds the active playlist in the studio + */ +export async function handleRecalculateTTimerEstimates(context: JobContext): Promise { + // Find active playlists in this studio (projection to just get IDs) + const activePlaylistIds = await context.directCollections.RundownPlaylists.findFetch( + { + studioId: context.studioId, + activationId: { $exists: true }, + }, + { + projection: { + _id: 1, + }, + } + ) + + if (activePlaylistIds.length === 0) { + // No active playlist, nothing to do + return + } + + // Process each active playlist (typically there's only one) + for (const playlistRef of activePlaylistIds) { + await runWithPlaylistLock(context, playlistRef._id, async (lock) => { + // Fetch the full playlist object + const playlist = await context.directCollections.RundownPlaylists.findOne(playlistRef._id) + if (!playlist) { + // Playlist was removed between query and lock + return + } + + await runWithPlayoutModel(context, playlist, lock, null, async (playoutModel) => { + recalculateTTimerEstimates(context, playoutModel) + }) + }) + } +} diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index be5d81787d..7b66526a4d 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -49,6 +49,7 @@ import { handleActivateAdlibTesting } from '../../playout/adlibTesting.js' import { handleExecuteBucketAdLibOrAction } from '../../playout/bucketAdlibJobs.js' import { handleSwitchRouteSet } from '../../studio/routeSet.js' import { handleCleanupOrphanedExpectedPackageReferences } from '../../playout/expectedPackages.js' +import { handleRecalculateTTimerEstimates } from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -87,6 +88,8 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.OnPlayoutPlaybackChanged]: handleOnPlayoutPlaybackChanged, [StudioJobs.OnTimelineTriggerTime]: handleTimelineTriggerTime, + [StudioJobs.RecalculateTTimerEstimates]: handleRecalculateTTimerEstimates, + [StudioJobs.UpdateStudioBaseline]: handleUpdateStudioBaseline, [StudioJobs.CleanupEmptyPlaylists]: handleRemoveEmptyPlaylists, From 640f477de757990953a7164816f7615aed16bf56 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:46:30 +0000 Subject: [PATCH 04/57] feat: add timeout for T-Timer recalculations when pushing expected to start --- packages/job-worker/src/playout/tTimers.ts | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 2f327550f1..15f2e27a37 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -9,9 +9,18 @@ import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' +import { logger } from '../logging.js' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' + +/** + * Map of active setTimeout timeouts by studioId + * Used to clear previous timeout when recalculation is triggered before the timeout fires + */ +const activeTimeouts = new Map() 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}`) @@ -189,6 +198,14 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const span = context.startSpan('recalculateTTimerEstimates') const playlist = playoutModel.playlist + + // Clear any existing timeout for this studio + const existingTimeout = activeTimeouts.get(playlist.studioId) + if (existingTimeout) { + clearTimeout(existingTimeout) + activeTimeouts.delete(playlist.studioId) + } + const tTimers = playlist.tTimers // Find which timers have anchors that need calculation @@ -204,7 +221,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // If no timers have anchors, nothing to do if (timerAnchors.size === 0) { if (span) span.end() - return + return undefined } const currentPartInstance = playoutModel.currentPartInstance?.partInstance @@ -263,6 +280,17 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl isPushing = remaining < 0 accumulatedDuration = Math.max(0, remaining) + + // Schedule next recalculation for when current part ends (if not pushing and no autoNext) + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = remaining + 5 // 5ms buffer + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } } From 18c85a48bc62cf1f43bfff7f429ba73e883c70bb Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:47:51 +0000 Subject: [PATCH 05/57] feat: queue initial T-Timer recalculation when job-worker restarts This will ensure a timeout is set for the next expected push start time. --- packages/job-worker/src/workers/studio/child.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index 57974fbb73..138bfd10d0 100644 --- a/packages/job-worker/src/workers/studio/child.ts +++ b/packages/job-worker/src/workers/studio/child.ts @@ -1,5 +1,6 @@ import { studioJobHandlers } from './jobs.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { MongoClient } from 'mongodb' import { createMongoConnection, getMongoCollections, IDirectCollections } from '../../db/index.js' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -75,6 +76,16 @@ export class StudioWorkerChild { } logger.info(`Studio thread for ${this.#studioId} initialised`) + + // Queue initial T-Timer recalculation to set up timers after startup + this.#queueJob( + getStudioQueueName(this.#studioId), + StudioJobs.RecalculateTTimerEstimates, + undefined, + undefined + ).catch((err) => { + logger.error(`Failed to queue initial T-Timer recalculation: ${err}`) + }) } async lockChange(lockId: string, locked: boolean): Promise { if (!this.#staticData) throw new Error('Worker not initialised') From 6d86a9a951cb2ece6e64f1724f04c019f39da3b2 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 16:10:48 +0000 Subject: [PATCH 06/57] feat(blueprints): Add blueprint interface methods for T-Timer estimate management Add three new methods to IPlaylistTTimer interface: - clearEstimate() - Clear both manual estimates and anchor parts - setEstimateAnchorPart(partId) - Set anchor part for automatic calculation - setEstimateTime(time, paused?) - Manually set estimate as timestamp - setEstimateDuration(duration, paused?) - Manually set estimate as duration When anchor part is set, automatically queues RecalculateTTimerEstimates job. Manual estimates clear anchor parts and vice versa. Updated TTimersService to accept JobContext for job queueing capability. Updated all blueprint context instantiations and tests. --- .../src/context/tTimersContext.ts | 36 ++++ .../blueprints/context/OnSetAsNextContext.ts | 2 +- .../src/blueprints/context/OnTakeContext.ts | 2 +- .../context/RundownActivationContext.ts | 2 +- .../SyncIngestUpdateToPartInstanceContext.ts | 15 +- .../src/blueprints/context/adlibActions.ts | 2 +- .../context/services/TTimersService.ts | 90 ++++++++- .../services/__tests__/TTimersService.test.ts | 188 ++++++++++++------ .../src/ingest/syncChangesToPartInstance.ts | 1 + 9 files changed, 263 insertions(+), 75 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 8747f450a2..cce8ca198d 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -71,6 +71,42 @@ 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 + + /** + * 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 = diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 0e2f530946..2a9ff33ad9 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -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 { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index f403d33723..dbf70196b5 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -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> { diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index 3f0b47cc1d..0e631d8833 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -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 { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 3bbec8cdaa..61e2dcb486 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -3,6 +3,7 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns import { normalizeArrayToMap, omit } from '@sofie-automation/corelib/dist/lib' import { protectString, protectStringArray, unprotectStringArray } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel.js' +import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import _ from 'underscore' import { ContextInfo } from './CommonContext.js' @@ -45,6 +46,7 @@ export class SyncIngestUpdateToPartInstanceContext implements ISyncIngestUpdateToPartInstanceContext { readonly #context: JobContext + readonly #playoutModel: PlayoutModel readonly #proposedPieceInstances: Map> readonly #tTimersService: TTimersService readonly #changedTTimers = new Map() @@ -61,6 +63,7 @@ export class SyncIngestUpdateToPartInstanceContext constructor( context: JobContext, + playoutModel: PlayoutModel, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, @@ -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 { diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 8c41cc7d7d..0544c90ecd 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -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> { diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b1eeafd49c..aee1064e57 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -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 } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { @@ -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, - emitChange: (updatedTimer: ReadonlyDeep) => void + emitChange: (updatedTimer: ReadonlyDeep) => 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 { @@ -50,6 +62,8 @@ export class TTimersService { export class PlaylistTTimerImpl implements IPlaylistTTimer { readonly #emitChange: (updatedTimer: ReadonlyDeep) => void + readonly #playoutModel: PlayoutModel + readonly #jobContext: JobContext #timer: ReadonlyDeep @@ -96,9 +110,18 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } } - constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + constructor( + timer: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void, + playoutModel: PlayoutModel, + jobContext: JobContext + ) { this.#timer = timer this.#emitChange = emitChange + this.#playoutModel = playoutModel + this.#jobContext = jobContext + + validateTTimerIndex(timer.index) } setLabel(label: string): void { @@ -168,4 +191,51 @@ 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), + estimateState: undefined, // Clear manual estimate + } + this.#emitChange(this.#timer) + + // Recalculate estimates immediately since we already have the playout model + recalculateTTimerEstimates(this.#jobContext, this.#playoutModel) + } + + setEstimateTime(time: number, paused: boolean = false): void { + const estimateState: TimerState = paused + ? literal({ paused: true, duration: time - getCurrentTime() }) + : literal({ 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({ paused: true, duration }) + : literal({ paused: false, zeroTime: getCurrentTime() + duration }) + + this.#timer = { + ...this.#timer, + anchorPartId: undefined, // Clear automatic anchor + estimateState, + } + this.#emitChange(this.#timer) + } } 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 index 2fe7a21b29..9f8355cac6 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -6,6 +6,11 @@ import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/coreli import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { mock, MockProxy } from 'jest-mock-extended' import type { ReadonlyDeep } from 'type-fest' +import type { JobContext } from '../../../../jobs/index.js' + +function createMockJobContext(): MockProxy { + return mock() +} function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { const mockPlayoutModel = mock() @@ -42,8 +47,10 @@ describe('TTimersService', () => { it('should create three timer instances', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) @@ -54,8 +61,9 @@ describe('TTimersService', () => { it('from playout model', () => { const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const mockJobContext = createMockJobContext() - const service = TTimersService.withPlayoutModel(mockPlayoutModel) + const service = TTimersService.withPlayoutModel(mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) const timer = service.getTimer(1) @@ -71,8 +79,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 1', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(1) @@ -82,8 +92,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 2', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(2) @@ -93,8 +105,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 3', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(3) @@ -104,8 +118,10 @@ describe('TTimersService', () => { it('should throw for invalid index', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') @@ -120,10 +136,11 @@ describe('TTimersService', () => { tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[1].state = { paused: false, zeroTime: 65000 } - const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(tTimers, updateFn, mockPlayoutModel, mockJobContext) service.clearAllTimers() @@ -149,7 +166,9 @@ describe('PlaylistTTimerImpl', () => { it('should return the correct index', () => { const tTimers = createEmptyTTimers() const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.index).toBe(2) }) @@ -158,16 +177,19 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[1].label = 'Custom Label' const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.label).toBe('Custom Label') }) it('should return null state when no mode is set', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toBeNull() }) @@ -177,7 +199,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -191,7 +215,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: 3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -209,7 +235,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -229,7 +257,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 2000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -249,7 +279,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -270,7 +302,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -285,9 +319,10 @@ describe('PlaylistTTimerImpl', () => { describe('setLabel', () => { it('should update the label', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.setLabel('New Label') @@ -306,7 +341,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.clearTimer() @@ -322,9 +359,10 @@ describe('PlaylistTTimerImpl', () => { 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) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(60000) @@ -342,9 +380,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused countdown', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) @@ -364,9 +403,10 @@ describe('PlaylistTTimerImpl', () => { describe('startFreeRun', () => { it('should start a running free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun() @@ -382,9 +422,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun({ startPaused: true }) @@ -402,9 +443,10 @@ describe('PlaylistTTimerImpl', () => { 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) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('15:30') @@ -425,9 +467,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with numeric timestamp', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const targetTimestamp = 1737331200000 timer.startTimeOfDay(targetTimestamp) @@ -449,9 +492,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with stopAtZero false', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('18:00', { stopAtZero: false }) @@ -472,9 +516,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with 12-hour format', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('5:30pm') @@ -495,18 +540,20 @@ describe('PlaylistTTimerImpl', () => { it('should throw for invalid time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') }) it('should throw for empty time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') }) @@ -518,7 +565,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -538,7 +587,9 @@ describe('PlaylistTTimerImpl', () => { 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 mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -557,9 +608,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -576,7 +628,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -591,7 +645,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: -3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -611,7 +667,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -622,9 +680,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -641,7 +700,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -656,7 +717,9 @@ describe('PlaylistTTimerImpl', () => { 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 mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -682,7 +745,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -704,7 +769,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -721,7 +788,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -750,7 +819,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -760,9 +831,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index afee746ca2..41de01b1bf 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -130,6 +130,7 @@ export class SyncChangesToPartInstancesWorker { const syncContext = new SyncIngestUpdateToPartInstanceContext( this.#context, + this.#playoutModel, { name: `Update to ${existingPartInstance.partInstance.part.externalId}`, identifier: `rundownId=${existingPartInstance.partInstance.part.rundownId},segmentId=${existingPartInstance.partInstance.part.segmentId}`, From ae88fa756ca1e8e29a440452cf4167c988ef70f4 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:02 +0000 Subject: [PATCH 07/57] feat: Add ignoreQuickLoop parameter to getOrderedPartsAfterPlayhead function --- packages/job-worker/src/playout/lookahead/util.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 72bb201dc6..99d692d259 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -34,11 +34,16 @@ export function isPieceInstance(piece: Piece | PieceInstance | PieceInstancePiec /** * Excludes the previous, current and next part + * @param context Job context + * @param playoutModel The playout model + * @param partCount Maximum number of parts to return + * @param ignoreQuickLoop If true, ignores quickLoop markers and returns parts in linear order. Defaults to false for backwards compatibility. */ export function getOrderedPartsAfterPlayhead( context: JobContext, playoutModel: PlayoutModel, - partCount: number + partCount: number, + ignoreQuickLoop: boolean = false ): ReadonlyDeep[] { if (partCount <= 0) { return [] @@ -66,7 +71,7 @@ export function getOrderedPartsAfterPlayhead( null, orderedSegments, orderedParts, - { ignoreUnplayable: true, ignoreQuickLoop: false } + { ignoreUnplayable: true, ignoreQuickLoop } ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything From e1b26f4b9befe9fbcf0d14be9dad006900869245 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:19 +0000 Subject: [PATCH 08/57] feat: Refactor recalculateTTimerEstimates to use getOrderedPartsAfterPlayhead for improved part iteration --- packages/job-worker/src/playout/tTimers.ts | 28 ++++------------------ 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 15f2e27a37..0615294d71 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,13 +8,13 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { logger } from '../logging.js' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' /** * Map of active setTimeout timeouts by studioId @@ -225,28 +225,13 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const currentPartInstance = playoutModel.currentPartInstance?.partInstance - const nextPartInstance = playoutModel.nextPartInstance?.partInstance - // Get ordered parts to iterate through + // Get ordered parts after playhead (excludes previous, current, and next) + // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior const orderedParts = playoutModel.getAllOrderedParts() + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, orderedParts.length, true) - // Start from next part if available, otherwise current, otherwise first playable part - let startPartIndex: number | undefined - if (nextPartInstance) { - // We have a next part selected, start from there - startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) - } else if (currentPartInstance) { - // No next, but we have current - start from the part after current - const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) - if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { - startPartIndex = currentIndex + 1 - } - } - - // If we couldn't find a starting point, start from the first playable part - startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) - - if (startPartIndex === undefined || startPartIndex < 0) { + if (playablePartsSlice.length === 0 && !currentPartInstance) { // No parts to iterate through, clear estimates for (const timer of tTimers) { if (timer.anchorPartId) { @@ -257,9 +242,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl return } - // Iterate through parts and accumulate durations - const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) - const now = getCurrentTime() let accumulatedDuration = 0 From 7d3258a217887a82845d340aa78e33d6c92dacb2 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 22:49:19 +0000 Subject: [PATCH 09/57] test: Add tests for new T-Timers functions --- .../services/__tests__/TTimersService.test.ts | 224 ++++++++++++++++++ .../src/playout/__tests__/tTimersJobs.test.ts | 211 +++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts 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 index 9f8355cac6..8922d386cc 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -842,4 +842,228 @@ describe('PlaylistTTimerImpl', () => { expect(updateFn).not.toHaveBeenCalled() }) }) + + describe('clearEstimate', () => { + it('should clear both anchorPartId and estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + + it('should work when estimates are already cleared', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + }) + + describe('setEstimateAnchorPart', () => { + it('should set anchorPartId and clear estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateAnchorPart('part123') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: 'part123', + estimateState: undefined, + }) + }) + + it('should not queue job or throw error', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + // Should not throw + expect(() => timer.setEstimateAnchorPart('part456')).not.toThrow() + + // Job queue should not be called (recalculate is called directly) + expect(mockJobContext.queueStudioJob).not.toHaveBeenCalled() + }) + }) + + describe('setEstimateTime', () => { + it('should set estimateState with absolute time (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 50000 }, + }) + }) + + it('should set estimateState with absolute time (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 50000 }, + }) + ) + }) + }) + + describe('setEstimateDuration', () => { + it('should set estimateState with relative duration (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) + }) + }) + + it('should set estimateState with relative duration (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 30000 }, + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 40000 }, + }) + ) + }) + }) }) diff --git a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts new file mode 100644 index 0000000000..e6623a952b --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -0,0 +1,211 @@ +import { setupDefaultJobEnvironment, MockJobContext } from '../../__mocks__/context.js' +import { handleRecalculateTTimerEstimates } from '../tTimersJobs.js' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' + +describe('tTimersJobs', () => { + let context: MockJobContext + + beforeEach(() => { + context = setupDefaultJobEnvironment() + }) + + describe('handleRecalculateTTimerEstimates', () => { + it('should handle studio with active playlists', async () => { + // Create an active playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Test Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle studio with no active playlists', async () => { + // Create an inactive playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Inactive Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: undefined, // Not active + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors (just does nothing) + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle multiple active playlists', async () => { + // Create multiple active playlists + const playlistId1 = protectString('playlist1') + const playlistId2 = protectString('playlist2') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId1, + externalId: 'test1', + studioId: context.studioId, + name: 'Active Playlist 1', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId2, + externalId: 'test2', + studioId: context.studioId, + name: 'Active Playlist 2', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation2'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors, processing both playlists + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle playlist deleted between query and lock', async () => { + // This test is harder to set up properly, but the function should handle it + // by checking if playlist exists after acquiring lock + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + }) +}) From 7e41fea17724acf837945324cddf8a9470186b71 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 5 Feb 2026 12:05:45 +0000 Subject: [PATCH 10/57] feat(T-Timers): Add segment budget timing support to estimate calculations Implements segment budget timing for T-Timer estimate calculations in recalculateTTimerEstimates(). When a segment has a budgetDuration set, the function now: - Uses the segment budget instead of individual part durations - Tracks budget consumption as parts are traversed - Ignores budget timing if the anchor is within the budget segment (anchor part uses normal part duration timing) This matches the front-end timing behavior in rundownTiming.ts and ensures server-side estimates align with UI countdown calculations for budget-controlled segments. --- packages/job-worker/src/playout/tTimers.ts | 137 +++++++++++++-------- 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 0615294d71..b1c9b6192e 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,7 +8,7 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' @@ -183,13 +183,17 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe } /** - * Recalculate T-Timer estimates based on timing anchors + * Recalculate T-Timer estimates based on timing anchors using segment budget timing. * - * For each T-Timer that has an anchorPartId set, this function: - * 1. Iterates through ordered parts from current/next onwards - * 2. Accumulates expected durations until the anchor part is reached - * 3. Updates estimateState with the calculated duration - * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * Uses a single-pass algorithm with two accumulators: + * - totalAccumulator: Accumulated time across completed segments + * - segmentAccumulator: Accumulated time within current segment + * + * At each segment boundary: + * - If segment has a budget → use segment budget duration + * - Otherwise → use accumulated part durations + * + * Handles starting mid-segment with budget by calculating remaining budget time. * * @param context Job context * @param playoutModel The playout model containing the playlist and parts @@ -243,76 +247,113 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const now = getCurrentTime() - let accumulatedDuration = 0 - // Calculate remaining time for current part - // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) - // Account for playOffset (e.g., from play-from-anywhere feature) + // Initialize accumulators + let totalAccumulator = 0 + let segmentAccumulator = 0 let isPushing = false + let currentSegmentId: SegmentId | undefined = undefined + + // Handle current part/segment if (currentPartInstance) { - const currentPartDuration = - currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration - if (currentPartDuration) { - const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback - const startedPlayback = - currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now - const playOffset = currentPartInstance.timings?.playOffset || 0 - const elapsed = now - startedPlayback - playOffset - const remaining = currentPartDuration - elapsed - - isPushing = remaining < 0 - accumulatedDuration = Math.max(0, remaining) - - // Schedule next recalculation for when current part ends (if not pushing and no autoNext) - if (!isPushing && !currentPartInstance.part.autoNext) { - const delay = remaining + 5 // 5ms buffer - const timeoutId = setTimeout(() => { - context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { - logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) - }) - }, delay) - activeTimeouts.set(playlist.studioId, timeoutId) + currentSegmentId = currentPartInstance.segmentId + const currentSegment = playoutModel.findSegment(currentPartInstance.segmentId) + const currentSegmentBudget = currentSegment?.segment.segmentTiming?.budgetDuration + + if (currentSegmentBudget === undefined) { + // Normal part duration timing + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } + } else { + // Segment budget timing - we're already inside a budgeted segment + const segmentStartedPlayback = + playlist.segmentsStartedPlayback?.[currentPartInstance.segmentId as unknown as string] + if (segmentStartedPlayback) { + const segmentElapsed = now - segmentStartedPlayback + const remaining = currentSegmentBudget - segmentElapsed + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } else { + totalAccumulator = currentSegmentBudget } } + + // Schedule next recalculation + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = totalAccumulator + 5 + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } + // Single pass through parts for (const part of playablePartsSlice) { - // Add this part's expected duration to the accumulator - const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 - accumulatedDuration += partDuration + // Detect segment boundary + if (part.segmentId !== currentSegmentId) { + // Flush previous segment + if (currentSegmentId !== undefined) { + const lastSegment = playoutModel.findSegment(currentSegmentId) + const segmentBudget = lastSegment?.segment.segmentTiming?.budgetDuration + + // Use budget if it exists, otherwise use accumulated part durations + if (segmentBudget !== undefined) { + totalAccumulator += segmentBudget + } else { + totalAccumulator += segmentAccumulator + } + } + + // Reset for new segment + segmentAccumulator = 0 + currentSegmentId = part.segmentId + } - // Check if this part is an anchor for any timer + // Check if this part is an anchor const timersForThisPart = timerAnchors.get(part._id) if (timersForThisPart) { + const anchorTime = totalAccumulator + segmentAccumulator + for (const timerIndex of timersForThisPart) { const timer = tTimers[timerIndex - 1] - // Update the timer's estimate const estimateState: TimerState = isPushing ? literal({ paused: true, - duration: accumulatedDuration, + duration: anchorTime, }) : literal({ paused: false, - zeroTime: now + accumulatedDuration, + zeroTime: now + anchorTime, }) playoutModel.updateTTimer({ ...timer, estimateState }) } - // Remove this anchor since we've processed it + timerAnchors.delete(part._id) } - // Early exit if we've resolved all timers - if (timerAnchors.size === 0) { - break - } + // Accumulate this part's duration + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + segmentAccumulator += partDuration } - // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) - // Any remaining entries in timerAnchors are anchors that weren't reached - for (const timerIndices of timerAnchors.values()) { + // Clear estimates for unresolved anchors + for (const [, timerIndices] of timerAnchors.entries()) { for (const timerIndex of timerIndices) { const timer = tTimers[timerIndex - 1] playoutModel.updateTTimer({ ...timer, estimateState: undefined }) From 2a48e27c1aadcf3c688709ddd134087c935b0503 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 20 Feb 2026 10:51:13 +0000 Subject: [PATCH 11/57] Fix test by adding missing mocks --- .../src/ingest/__tests__/syncChangesToPartInstance.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 3f63fe8858..6fd99f4862 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -118,6 +118,9 @@ describe('SyncChangesToPartInstancesWorker', () => { { findPart: jest.fn(() => undefined), getGlobalPieces: jest.fn(() => []), + getAllOrderedParts: jest.fn(() => []), + getOrderedSegments: jest.fn(() => []), + findAdlibPiece: jest.fn(() => undefined), }, mockOptions ) From f2e8dd91101a70cd1627d7ebe62e33fdd44a221f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 25 Feb 2026 10:37:56 +0000 Subject: [PATCH 12/57] feat(T-Timers): Add convenience method to set estimate anchor part by externalId --- .../blueprints-integration/src/context/tTimersContext.ts | 9 +++++++++ .../src/blueprints/context/services/TTimersService.ts | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index cce8ca198d..28e03b8ad6 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -88,6 +88,15 @@ export interface IPlaylistTTimer { */ 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. diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index aee1064e57..d5e4150e6a 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -6,7 +6,7 @@ import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/coreli 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 } from '@sofie-automation/corelib/dist/protectedString' +import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { @@ -213,6 +213,13 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { 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({ paused: true, duration: time - getCurrentTime() }) From 80150aa06dbb9e7d28284e4b883dda67e25ee8c6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 18 Feb 2026 17:01:13 +0000 Subject: [PATCH 13/57] feat(T-Timers): Add pauseTime field to timer estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional pauseTime field to TimerState type to indicate when a timer should automatically pause (when current part ends and overrun begins). Benefits: - Client can handle running→paused transition locally without server update - Reduces latency in state transitions - Server still triggers recalculation on Take/part changes - More declarative timing ("pause at this time" vs "set paused now") Implementation: - When not pushing: pauseTime = now + currentPartRemainingTime - When already pushing: pauseTime = null - Client should display timer as paused when now >= pauseTime --- packages/corelib/src/dataModel/RundownPlaylist.ts | 5 +++++ packages/job-worker/src/playout/tTimers.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e426fb3f8b..de3c92a54b 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -130,6 +130,7 @@ 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). */ export type TimerState = | { @@ -137,12 +138,16 @@ export type TimerState = paused: false /** The absolute timestamp (ms) when the timer reaches/reached zero */ zeroTime: number + /** Optional timestamp when the timer should pause (when current part ends) */ + pauseTime?: number | null } | { /** Whether the timer is paused */ paused: true /** The frozen duration value in milliseconds */ duration: number + /** Optional timestamp when the timer should pause (null when already paused/pushing) */ + pauseTime?: number | null } export type RundownTTimerIndex = 1 | 2 | 3 diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index b1c9b6192e..e843ed40c0 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -301,6 +301,9 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } } + // Save remaining current part time for pauseTime calculation + const currentPartRemainingTime = totalAccumulator + // Single pass through parts for (const part of playablePartsSlice) { // Detect segment boundary @@ -335,10 +338,12 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl ? literal({ paused: true, duration: anchorTime, + pauseTime: null, // Already paused/pushing }) : literal({ paused: false, zeroTime: now + anchorTime, + pauseTime: now + currentPartRemainingTime, // When current part ends and pushing begins }) playoutModel.updateTTimer({ ...timer, estimateState }) From 30b032d93b9144ba2d75075070968272bb2a2de3 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:21:56 +0000 Subject: [PATCH 14/57] Remove timeout based update of T-Timer now we have pauseTime --- packages/job-worker/src/playout/tTimers.ts | 29 +--------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index e843ed40c0..917aa31027 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,20 +8,11 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { PartId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' -import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { logger } from '../logging.js' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' -/** - * Map of active setTimeout timeouts by studioId - * Used to clear previous timeout when recalculation is triggered before the timeout fires - */ -const activeTimeouts = new Map() - 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}`) } @@ -203,13 +194,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const playlist = playoutModel.playlist - // Clear any existing timeout for this studio - const existingTimeout = activeTimeouts.get(playlist.studioId) - if (existingTimeout) { - clearTimeout(existingTimeout) - activeTimeouts.delete(playlist.studioId) - } - const tTimers = playlist.tTimers // Find which timers have anchors that need calculation @@ -288,17 +272,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl totalAccumulator = currentSegmentBudget } } - - // Schedule next recalculation - if (!isPushing && !currentPartInstance.part.autoNext) { - const delay = totalAccumulator + 5 - const timeoutId = setTimeout(() => { - context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { - logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) - }) - }, delay) - activeTimeouts.set(playlist.studioId, timeoutId) - } } // Save remaining current part time for pauseTime calculation From 76b669ac73868372cda59f9a448a3bc92d8bd0e4 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 11:23:33 +0000 Subject: [PATCH 15/57] docs(T-Timers): Add client rendering logic for pauseTime Document the client-side logic for rendering timer states with pauseTime support: - paused === true: use frozen duration - pauseTime && now >= pauseTime: use zeroTime - pauseTime (auto-pause) - otherwise: use zeroTime - now (running normally) --- packages/corelib/src/dataModel/RundownPlaylist.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index de3c92a54b..b7ab807d24 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -131,6 +131,20 @@ export interface RundownTTimerModeTimeOfDay { * 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 = | { From 55632b94c07f8ce77ade7e145569f4d4992e2b30 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 19 Feb 2026 13:20:36 +0000 Subject: [PATCH 16/57] feat(T-Timers): Add timerStateToDuration helper function Add timerStateToDuration() function to calculate current timer duration from TimerState, handling all three cases: - Manually paused or already pushing - Auto-pause at overrun (pauseTime) - Running normally Also rename "currentTime" to "currentDuration" in "calculateTTimerDiff" method --- .../corelib/src/dataModel/RundownPlaylist.ts | 21 +++++++++++++++++++ packages/webui/src/client/lib/tTimerUtils.ts | 16 +++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index b7ab807d24..410f275fb7 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -164,6 +164,27 @@ export type TimerState = 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 { diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index 08ec4f19e2..de7377a5b0 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -1,4 +1,4 @@ -import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownTTimer, timerStateToDuration } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' /** * Calculate the display diff for a T-Timer. @@ -11,19 +11,19 @@ export function calculateTTimerDiff(timer: RundownTTimer, now: number): number { } // Get current time: either frozen duration or calculated from zeroTime - const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + const currentDuration = timerStateToDuration(timer.state, now) // Free run counts up, so negate to get positive elapsed time if (timer.mode?.type === 'freeRun') { - return -currentTime + return -currentDuration } // Apply stopAtZero if configured - if (timer.mode?.stopAtZero && currentTime < 0) { + if (timer.mode?.stopAtZero && currentDuration < 0) { return 0 } - return currentTime + return currentDuration } /** @@ -40,10 +40,8 @@ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): num return undefined } - const duration = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now - const estimateDuration = timer.estimateState.paused - ? timer.estimateState.duration - : timer.estimateState.zeroTime - now + const duration = timerStateToDuration(timer.state, now) + const estimateDuration = timerStateToDuration(timer.estimateState, now) return duration - estimateDuration } From 40d90d55f8cf2eb910a7669d10ca48b6f8febbd6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:29:35 +0000 Subject: [PATCH 17/57] Fix sign of over/under calculation If the estimate is big, the output should be positive for over. --- packages/webui/src/client/lib/tTimerUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/lib/tTimerUtils.ts b/packages/webui/src/client/lib/tTimerUtils.ts index de7377a5b0..8b5a0938ea 100644 --- a/packages/webui/src/client/lib/tTimerUtils.ts +++ b/packages/webui/src/client/lib/tTimerUtils.ts @@ -43,7 +43,7 @@ export function calculateTTimerOverUnder(timer: RundownTTimer, now: number): num const duration = timerStateToDuration(timer.state, now) const estimateDuration = timerStateToDuration(timer.estimateState, now) - return duration - estimateDuration + return estimateDuration - duration } // TODO: remove this mock From b647e5fad6d6de6a4865e699d79b08f2a58cadec Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 13:55:45 +0000 Subject: [PATCH 18/57] Include next part in calculation --- packages/job-worker/src/playout/tTimers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 917aa31027..5bd5f04c05 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -277,6 +277,14 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // Save remaining current part time for pauseTime calculation const currentPartRemainingTime = totalAccumulator + // Add the next part to the beginning of playablePartsSlice + // getOrderedPartsAfterPlayhead excludes both current and next, so we need to prepend next + // This allows the loop to handle it normally, including detecting if it's an anchor + const nextPartInstance = playoutModel.nextPartInstance?.partInstance + if (nextPartInstance) { + playablePartsSlice.unshift(nextPartInstance.part) + } + // Single pass through parts for (const part of playablePartsSlice) { // Detect segment boundary From ac306b60965352460439d07598716c57e1528e04 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:42:53 +0000 Subject: [PATCH 19/57] Don't fetch all parts just to get a max length Just use infininty. The total length may not even be long enough in certain edge cases, for example if you requeue the first segment while later in the showl. --- packages/job-worker/src/playout/tTimers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 5bd5f04c05..bb005e52b7 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -216,8 +216,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // Get ordered parts after playhead (excludes previous, current, and next) // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior - const orderedParts = playoutModel.getAllOrderedParts() - const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, orderedParts.length, true) + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, Infinity, true) if (playablePartsSlice.length === 0 && !currentPartInstance) { // No parts to iterate through, clear estimates From 1132ad0292bbb57cc397952b9af09b87b7877b20 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 6 Mar 2026 14:43:25 +0000 Subject: [PATCH 20/57] Ensure we recalculate timings when we queue segments --- packages/job-worker/src/playout/setNext.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 45209a6494..4f4f2a1953 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -529,6 +529,10 @@ export async function queueNextSegment( } else { playoutModel.setQueuedSegment(null) } + + // Recalculate timer estimates as the queued segment affects what comes after next + recalculateTTimerEstimates(context, playoutModel) + span?.end() return { queuedSegmentId: queuedSegment?.segment?._id ?? null } } From 9abc53e858bc63e3f65eec17cca5af14cd09a2dc Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 9 Mar 2026 17:55:44 +0000 Subject: [PATCH 21/57] Fix linting issue --- .../ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx index 18318ac74f..7a6328d03e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/HeaderFreezeFrameIcon.tsx @@ -5,7 +5,7 @@ import { useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' import { PartInstances, PieceInstances } from '../../../collections' import { VTContent } from '@sofie-automation/blueprints-integration' -export function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }) { +export function HeaderFreezeFrameIcon({ partInstanceId }: { partInstanceId: PartInstanceId }): JSX.Element | null { const timingDurations = useTiming(TimingTickResolution.Synced, TimingDataResolution.Synced) const freezeFrameIcon = useTracker( From e22ff3367132165b20ea0e39194820fd5dfc029d Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 10 Mar 2026 13:16:56 +0000 Subject: [PATCH 22/57] Remove mock data --- .../RundownHeader/RundownHeaderTimers.tsx | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 01dc9323f9..30f7785bb7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -14,44 +14,6 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - tTimers = [ - { - index: 1, - label: 'T-timer mock 1', - mode: { type: 'countdown' }, - state: { - zeroTime: 1772700194670 + 5 * 60 * 1000, - duration: 0, - paused: false, - }, - estimateState: { - zeroTime: 1772700194670 + 7 * 60 * 1000, - duration: 0, - paused: false, - }, - }, - { - index: 2, - label: 'T-timer mock 2', - mode: { type: 'freeRun' }, - state: { - zeroTime: 1772700194670 + 45 * 60 * 1000, - duration: 0, - paused: false, - }, - }, - { - index: 3, - label: 'T-timer mock 3', - mode: null, - state: { - zeroTime: 1772700194670 - 15 * 60 * 1000, - duration: 0, - paused: false, - }, - }, - ] as unknown as [RundownTTimer, RundownTTimer, RundownTTimer] - if (!tTimers?.length) { return null } From 45f5c448493c3f075e3d6628018f805e2c8fb655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 12:14:18 +0100 Subject: [PATCH 23/57] chore: changes wording for Rem. Dur (was Est. Dur) --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 2018192cd9..c543acea2e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -47,7 +47,7 @@ export function RundownHeaderDurations({ ) : null} {!simplified && estDuration != null ? ( - + {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} ) : null} From fb66b3fce4732916597457ffa150a72ba93e9d12 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 13:09:48 +0000 Subject: [PATCH 24/57] remove RundownHeader_old folder left behind by mistake --- .../RundownHeader_old/RundownHeaderTimers.tsx | 97 --- .../RundownHeader_old/RundownHeader_old.tsx | 235 ------ .../RundownReloadResponse.ts | 177 ----- .../RundownHeader_old/TimingDisplay.tsx | 97 --- .../useRundownPlaylistOperations.tsx | 693 ------------------ 5 files changed, 1299 deletions(-) delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx deleted file mode 100644 index 132963d696..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeaderTimers.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react' -import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { useTiming } from '../RundownTiming/withTiming' -import { RundownUtils } from '../../../lib/rundown' -import classNames from 'classnames' -import { getCurrentTime } from '../../../lib/systemTime' - -interface IProps { - tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] -} - -export const RundownHeaderTimers: React.FC = ({ tTimers }) => { - useTiming() - - const activeTimers = tTimers.filter((t) => t.mode) - - if (activeTimers.length == 0) return null - - return ( -
- {activeTimers.map((timer) => ( - - ))} -
- ) -} - -interface ISingleTimerProps { - timer: RundownTTimer -} - -function SingleTimer({ timer }: ISingleTimerProps) { - const now = getCurrentTime() - - const isRunning = !!timer.state && !timer.state.paused - - const diff = calculateDiff(timer, now) - const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) - const parts = timeStr.split(':') - - const timerSign = diff >= 0 ? '+' : '-' - - const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning - - return ( -
- {timer.label} -
- {timerSign} - {parts.map((p, i) => ( - - - {p} - - {i < parts.length - 1 && :} - - ))} -
-
- ) -} - -function calculateDiff(timer: RundownTTimer, now: number): number { - if (!timer.state || timer.state.paused === undefined) { - return 0 - } - - // Get current time: either frozen duration or calculated from zeroTime - const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now - - // Free run counts up, so negate to get positive elapsed time - if (timer.mode?.type === 'freeRun') { - return -currentTime - } - - // Apply stopAtZero if configured - if (timer.mode?.stopAtZero && currentTime < 0) { - return 0 - } - - return currentTime -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx deleted file mode 100644 index e235cb792f..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownHeader_old.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import * as CoreIcon from '@nrk/core-icons/jsx' -import ClassNames from 'classnames' -import Escape from '../../../lib/Escape' -import Tooltip from 'rc-tooltip' -import { NavLink } from 'react-router-dom' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { Rundown, getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { ContextMenu, MenuItem, ContextMenuTrigger } from '@jstarpl/react-contextmenu' -import { PieceUi } from '../../SegmentTimeline/SegmentTimelineContainer' -import { RundownSystemStatus } from '../RundownSystemStatus' -import { getHelpMode } from '../../../lib/localStorage' -import { reloadRundownPlaylistClick } from '../RundownNotifier' -import { useRundownViewEventBusListener } from '../../../lib/lib' -import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { contextMenuHoldToDisplayTime } from '../../../lib/lib' -import { - ActivateRundownPlaylistEvent, - DeactivateRundownPlaylistEvent, - IEventContext, - RundownViewEvents, -} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' -import { RundownLayoutsAPI } from '../../../lib/rundownLayouts' -import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' -import { BucketAdLibItem } from '../../Shelf/RundownViewBuckets' -import { IAdLibListItem } from '../../Shelf/AdLibListItem' -import { ShelfDashboardLayout } from '../../Shelf/ShelfDashboardLayout' -import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' -import { UserPermissionsContext } from '../../UserPermissions' -import * as RundownResolver from '../../../lib/RundownResolver' -import Navbar from 'react-bootstrap/Navbar' -import { WarningDisplay } from '../WarningDisplay' -import { TimingDisplay } from './TimingDisplay' -import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations' - -interface IRundownHeaderProps { - playlist: DBRundownPlaylist - showStyleBase: UIShowStyleBase - showStyleVariant: DBShowStyleVariant - currentRundown: Rundown | undefined - studio: UIStudio - rundownIds: RundownId[] - firstRundown: Rundown | undefined - onActivate?: (isRehearsal: boolean) => void - inActiveRundownView?: boolean - layout: RundownLayoutRundownHeader | undefined -} - -export function RundownHeader_old({ - playlist, - showStyleBase, - showStyleVariant, - currentRundown, - studio, - rundownIds, - firstRundown, - inActiveRundownView, - layout, -}: IRundownHeaderProps): JSX.Element { - const { t } = useTranslation() - - const userPermissions = useContext(UserPermissionsContext) - - const [selectedPiece, setSelectedPiece] = useState(undefined) - const [shouldQueueAdlibs, setShouldQueueAdlibs] = useState(false) - - const operations = useRundownPlaylistOperations() - - const eventActivate = useCallback( - (e: ActivateRundownPlaylistEvent) => { - if (e.rehearsal) { - operations.activateRehearsal(e.context) - } else { - operations.activate(e.context) - } - }, - [operations] - ) - const eventDeactivate = useCallback( - (e: DeactivateRundownPlaylistEvent) => operations.deactivate(e.context), - [operations] - ) - const eventResync = useCallback((e: IEventContext) => operations.reloadRundownPlaylist(e.context), [operations]) - const eventTake = useCallback((e: IEventContext) => operations.take(e.context), [operations]) - const eventResetRundownPlaylist = useCallback((e: IEventContext) => operations.resetRundown(e.context), [operations]) - const eventCreateSnapshot = useCallback((e: IEventContext) => operations.takeRundownSnapshot(e.context), [operations]) - - useRundownViewEventBusListener(RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, eventActivate) - useRundownViewEventBusListener(RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, eventDeactivate) - useRundownViewEventBusListener(RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, eventResync) - useRundownViewEventBusListener(RundownViewEvents.TAKE, eventTake) - useRundownViewEventBusListener(RundownViewEvents.RESET_RUNDOWN_PLAYLIST, eventResetRundownPlaylist) - useRundownViewEventBusListener(RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, eventCreateSnapshot) - - useEffect(() => { - reloadRundownPlaylistClick.set(operations.reloadRundownPlaylist) - }, [operations.reloadRundownPlaylist]) - - const canClearQuickLoop = - !!studio.settings.enableQuickLoop && - !RundownResolver.isLoopLocked(playlist) && - RundownResolver.isAnyLoopMarkerDefined(playlist) - - const rundownTimesInfo = checkRundownTimes(playlist.timing) - - useEffect(() => { - console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) - }, [playlist.tTimers]) - - return ( - <> - - -
{playlist && playlist.name}
- {userPermissions.studio ? ( - - {!(playlist.activationId && playlist.rehearsal) ? ( - !rundownTimesInfo.shouldHaveStarted && !playlist.activationId ? ( - - {t('Prepare Studio and Activate (Rehearsal)')} - - ) : ( - {t('Activate (Rehearsal)')} - ) - ) : ( - {t('Activate (On-Air)')} - )} - {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( - {t('Activate (On-Air)')} - )} - {playlist.activationId ? {t('Deactivate')} : null} - {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( - {t('AdLib Testing')} - ) : null} - {playlist.activationId ? {t('Take')} : null} - {studio.settings.allowHold && playlist.activationId ? ( - {t('Hold')} - ) : null} - {playlist.activationId && canClearQuickLoop ? ( - {t('Clear QuickLoop')} - ) : null} - {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( - {t('Reset Rundown')} - ) : null} - - {t('Reload {{nrcsName}} Data', { - nrcsName: getRundownNrcsName(firstRundown), - })} - - {t('Store Snapshot')} - - ) : ( - - {t('No actions available')} - - )} -
-
- - - - noResetOnActivate ? operations.activateRundown(e) : operations.resetAndActivateRundown(e) - } - /> -
-
-
- -
- -
-
- {layout && RundownLayoutsAPI.isDashboardLayout(layout) ? ( - - ) : ( - <> - - - - )} -
-
- - - -
-
-
- - - - ) -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts b/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts deleted file mode 100644 index a858db9caf..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/RundownReloadResponse.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { RundownPlaylists, Rundowns } from '../../../collections' -import { - ReloadRundownPlaylistResponse, - TriggerReloadDataResponse, -} from '@sofie-automation/meteor-lib/dist/api/userActions' -import _ from 'underscore' -import { RundownPlaylistCollectionUtil } from '../../../collections/rundownPlaylistUtil' -import * as i18next from 'i18next' -import { UserPermissions } from '../../UserPermissions' -import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' -import { Tracker } from 'meteor/tracker' -import { doUserAction } from '../../../lib/clientUserAction' -import { MeteorCall } from '../../../lib/meteorApi' -import { doModalDialog } from '../../../lib/ModalDialog' - -export function handleRundownPlaylistReloadResponse( - t: i18next.TFunction, - userPermissions: Readonly, - result: ReloadRundownPlaylistResponse -): boolean { - const rundownsInNeedOfHandling = result.rundownsResponses.filter( - (r) => r.response === TriggerReloadDataResponse.MISSING - ) - const firstRundownId = _.first(rundownsInNeedOfHandling)?.rundownId - let allRundownsAffected = false - - if (firstRundownId) { - const firstRundown = Rundowns.findOne(firstRundownId) - const playlist = RundownPlaylists.findOne(firstRundown?.playlistId) - const allRundownIds = playlist ? RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) : [] - if ( - allRundownIds.length > 0 && - _.difference( - allRundownIds, - rundownsInNeedOfHandling.map((r) => r.rundownId) - ).length === 0 - ) { - allRundownsAffected = true - } - } - - const actionsTaken: RundownReloadResponseUserAction[] = [] - function onActionTaken(action: RundownReloadResponseUserAction): void { - actionsTaken.push(action) - if (actionsTaken.length === rundownsInNeedOfHandling.length) { - // the user has taken action on all of the missing rundowns - if (allRundownsAffected && actionsTaken.filter((actionTaken) => actionTaken !== 'removed').length === 0) { - // all rundowns in the playlist were affected and all of them were removed - // we redirect to the Lobby - window.location.assign('/') - } - } - } - - const handled = rundownsInNeedOfHandling.map((r) => - handleRundownReloadResponse(t, userPermissions, r.rundownId, r.response, onActionTaken) - ) - return handled.reduce((previousValue, value) => previousValue || value, false) -} - -export type RundownReloadResponseUserAction = 'removed' | 'unsynced' | 'error' - -export function handleRundownReloadResponse( - t: i18next.TFunction, - userPermissions: Readonly, - rundownId: RundownId, - result: TriggerReloadDataResponse, - clb?: (action: RundownReloadResponseUserAction) => void -): boolean { - let hasDoneSomething = false - - if (result === TriggerReloadDataResponse.MISSING) { - const rundown = Rundowns.findOne(rundownId) - const playlist = RundownPlaylists.findOne(rundown?.playlistId) - - hasDoneSomething = true - const notification = new Notification( - undefined, - NoticeLevel.CRITICAL, - t( - 'Rundown {{rundownName}} in Playlist {{playlistName}} is missing in the data from {{nrcsName}}. You can either leave it in Sofie and mark it as Unsynced or remove the rundown from Sofie. What do you want to do?', - { - nrcsName: getRundownNrcsName(rundown), - rundownName: rundown?.name || t('(Unknown rundown)'), - playlistName: playlist?.name || t('(Unknown playlist)'), - } - ), - 'userAction', - undefined, - true, - [ - // actions: - { - label: t('Leave Unsynced'), - type: 'default', - disabled: !userPermissions.studio, - action: () => { - doUserAction( - t, - 'Missing rundown action', - UserAction.UNSYNC_RUNDOWN, - async (e, ts) => MeteorCall.userAction.unsyncRundown(e, ts, rundownId), - (err) => { - if (!err) { - notificationHandle.stop() - clb?.('unsynced') - } else { - clb?.('error') - } - } - ) - }, - }, - { - label: t('Remove'), - type: 'default', - action: () => { - doModalDialog({ - title: t('Remove rundown'), - message: t( - 'Do you really want to remove just the rundown "{{rundownName}}" in the playlist {{playlistName}} from Sofie? \n\nThis cannot be undone!', - { - rundownName: rundown?.name || 'N/A', - playlistName: playlist?.name || 'N/A', - } - ), - onAccept: () => { - // nothing - doUserAction( - t, - 'Missing rundown action', - UserAction.REMOVE_RUNDOWN, - async (e, ts) => MeteorCall.userAction.removeRundown(e, ts, rundownId), - (err) => { - if (!err) { - notificationHandle.stop() - clb?.('removed') - } else { - clb?.('error') - } - } - ) - }, - }) - }, - }, - ] - ) - const notificationHandle = NotificationCenter.push(notification) - - if (rundown) { - // This allows the semi-modal dialog above to be closed automatically, once the rundown stops existing - // for whatever reason - const comp = Tracker.autorun(() => { - const rundown = Rundowns.findOne(rundownId, { - fields: { - _id: 1, - orphaned: 1, - }, - }) - // we should hide the message - if (!rundown || !rundown.orphaned) { - notificationHandle.stop() - } - }) - notification.on('dropped', () => { - // clean up the reactive computation above when the notification is closed. Will be also executed by - // the notificationHandle.stop() above, so the Tracker.autorun will clean up after itself as well. - comp.stop() - }) - } - } - return hasDoneSomething -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx deleted file mode 100644 index 809c544fff..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/TimingDisplay.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBRundownPlaylist, RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' -import { RundownLayoutRundownHeader } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' -import { useTranslation } from 'react-i18next' -import * as RundownResolver from '../../../lib/RundownResolver' -import { AutoNextStatus } from '../RundownTiming/AutoNextStatus' -import { CurrentPartOrSegmentRemaining } from '../RundownHeader/CurrentPartOrSegmentRemaining' -import { NextBreakTiming } from '../RundownTiming/NextBreakTiming' -import { PlaylistEndTiming } from '../RundownTiming/PlaylistEndTiming' -import { PlaylistStartTiming } from '../RundownTiming/PlaylistStartTiming' -import { RundownName } from '../RundownTiming/RundownName' -import { TimeOfDay } from '../RundownTiming/TimeOfDay' -import { useTiming } from '../RundownTiming/withTiming' -import { RundownHeaderTimers } from './RundownHeaderTimers' - -interface ITimingDisplayProps { - rundownPlaylist: DBRundownPlaylist - currentRundown: Rundown | undefined - rundownCount: number - layout: RundownLayoutRundownHeader | undefined -} -export function TimingDisplay({ - rundownPlaylist, - currentRundown, - rundownCount, - layout, -}: ITimingDisplayProps): JSX.Element | null { - const { t } = useTranslation() - - const timingDurations = useTiming() - - if (!rundownPlaylist) return null - - const expectedStart = PlaylistTiming.getExpectedStart(rundownPlaylist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(rundownPlaylist.timing) - const expectedDuration = PlaylistTiming.getExpectedDuration(rundownPlaylist.timing) - const showEndTiming = - !timingDurations.rundownsBeforeNextBreak || - !layout?.showNextBreakTiming || - (timingDurations.rundownsBeforeNextBreak.length > 0 && - (!layout?.hideExpectedEndBeforeBreak || (timingDurations.breakIsLastRundown && layout?.lastRundownIsNotBreak))) - const showNextBreakTiming = - rundownPlaylist.startedPlayback && - timingDurations.rundownsBeforeNextBreak?.length && - layout?.showNextBreakTiming && - !(timingDurations.breakIsLastRundown && layout.lastRundownIsNotBreak) - - return ( -
-
- - -
-
- - -
-
-
- {rundownPlaylist.currentPartInfo && ( - - - - {rundownPlaylist.holdState && rundownPlaylist.holdState !== RundownHoldState.COMPLETE ? ( -
{t('Hold')}
- ) : null} -
- )} -
-
- {showNextBreakTiming ? ( - - ) : null} - {showEndTiming ? ( - - ) : null} -
-
-
- ) -} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx deleted file mode 100644 index 8f29d6e7ce..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader_old/useRundownPlaylistOperations.tsx +++ /dev/null @@ -1,693 +0,0 @@ -import { SerializedUserError, UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' -import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' -import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' -import { doUserAction } from '../../../lib/clientUserAction' -import { MeteorCall } from '../../../lib/meteorApi' -import { doModalDialog } from '../../../lib/ModalDialog' -import { useTranslation } from 'react-i18next' -import React, { useContext, useEffect, useMemo } from 'react' -import { UserPermissions, UserPermissionsContext } from '../../UserPermissions' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { logger } from '../../../lib/logging' -import * as i18next from 'i18next' -import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' -import { Meteor } from 'meteor/meteor' -import { Tracker } from 'meteor/tracker' -import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' -import { handleRundownPlaylistReloadResponse } from './RundownReloadResponse' -import { scrollToPartInstance } from '../../../lib/viewPort' -import { hashSingleUseToken } from '../../../lib/lib' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' -import { getCurrentTime } from '../../../lib/systemTime' -import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' -import { REHEARSAL_MARGIN } from '../WarningDisplay' -import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' - -class RundownPlaylistOperationsService { - constructor( - public studio: UIStudio, - public playlist: DBRundownPlaylist, - public currentRundown: Rundown | undefined, - public userPermissions: UserPermissions, - public onActivate?: (isRehearsal: boolean) => void - ) {} - - public executeTake(t: i18next.TFunction, e: EventLike): void { - if (!this.userPermissions.studio) return - - if (!this.playlist.activationId) { - const onSuccess = () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const handleResult = (err: any) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - // ask to activate - doModalDialog({ - title: t('Failed to execute take'), - message: t( - 'The rundown you are trying to execute a take on is inactive, would you like to activate this rundown?' - ), - acceptOnly: false, - warning: true, - yes: t('Activate "On Air"'), - no: t('Cancel'), - discardAsPrimary: true, - onDiscard: () => { - // Do nothing - }, - actions: [ - { - label: t('Activate "Rehearsal"'), - classNames: 'btn-secondary', - on: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, this.playlist._id, true), - handleResult - ) - }, - }, - ], - onAccept: () => { - // nothing - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - handleResult - ) - }, - }) - } else { - doUserAction(t, e, UserAction.TAKE, async (e, ts) => - MeteorCall.userAction.take(e, ts, this.playlist._id, this.playlist.currentPartInfo?.partInstanceId ?? null) - ) - } - } - - private handleAnotherPlaylistActive( - t: i18next.TFunction, - playlistId: RundownPlaylistId, - rehersal: boolean, - err: SerializedUserError, - clb?: (response: void) => void - ): void { - function handleResult(err: any, response: void) { - if (!err) { - if (typeof clb === 'function') clb(response) - } else { - logger.error(err) - doModalDialog({ - title: t('Failed to activate'), - message: t('Something went wrong, please contact the system administrator if the problem persists.'), - acceptOnly: true, - warning: true, - yes: t('OK'), - onAccept: () => { - // nothing - }, - }) - } - } - - doModalDialog({ - title: t('Another Rundown is Already Active!'), - message: t( - 'The rundown: "{{rundownName}}" will need to be deactivated in order to activate this one.\n\nAre you sure you want to activate this one anyway?', - { - // TODO: this is a bit of a hack, could a better string sent from the server instead? - rundownName: err.userMessage.args?.names ?? '', - } - ), - yes: t('Activate "On Air"'), - no: t('Cancel'), - discardAsPrimary: true, - actions: [ - { - label: t('Activate "Rehearsal"'), - classNames: 'btn-secondary', - on: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, rehersal), - handleResult - ) - }, - }, - ], - warning: true, - onAccept: (e) => { - doUserAction( - t, - e, - UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, false), - handleResult - ) - }, - }) - } - - public executeHold(t: i18next.TFunction, e: EventLike): void { - if (this.userPermissions.studio && this.playlist.activationId) { - doUserAction(t, e, UserAction.ACTIVATE_HOLD, async (e, ts) => - MeteorCall.userAction.activateHold(e, ts, this.playlist._id, false) - ) - } - } - - public executeClearQuickLoop(t: i18next.TFunction, e: EventLike) { - if (this.userPermissions.studio && this.playlist.activationId) { - doUserAction(t, e, UserAction.CLEAR_QUICK_LOOP, async (e, ts) => - MeteorCall.userAction.clearQuickLoop(e, ts, this.playlist._id) - ) - } - } - - public executeActivate(t: i18next.TFunction, e: EventLike) { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - (!this.playlist.activationId || (this.playlist.activationId && this.playlist.rehearsal)) - ) { - const onSuccess = () => { - this.deferFlushAndRewindSegments() - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const doActivate = () => { - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - (err) => { - if (!err) { - if (typeof this.onActivate === 'function') this.onActivate(false) - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - }) - return false - } - } - } - ) - } - - const doActivateAndReset = () => { - this.rewindSegments() - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) - return false - } - } - } - ) - } - - if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - // The broadcast hasn't started yet - doModalDialog({ - title: 'Activate "On Air"', - message: t('Do you want to activate this Rundown?'), - yes: 'Reset and Activate "On Air"', - no: t('Cancel'), - actions: [ - { - label: 'Activate "On Air"', - classNames: 'btn-secondary', - on: () => { - doActivate() // this one activates without resetting - }, - }, - ], - acceptOnly: false, - onAccept: () => { - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) - return false - } - } - } - ) - }, - }) - } else if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { - // The broadcast has started - doActivate() - } else { - // The broadcast has ended, going into active mode is probably not what you want to do - doModalDialog({ - title: 'Activate "On Air"', - message: t('The planned end time has passed, are you sure you want to activate this Rundown?'), - yes: 'Reset and Activate "On Air"', - no: t('Cancel'), - actions: [ - { - label: 'Activate "On Air"', - classNames: 'btn-secondary', - on: () => { - doActivate() // this one activates without resetting - }, - }, - ], - acceptOnly: false, - onAccept: () => { - doActivateAndReset() - }, - }) - } - } - } - - public executeActivateRehearsal = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - (!this.playlist.activationId || (this.playlist.activationId && !this.playlist.rehearsal)) - ) { - const onSuccess = () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - } - const doActivateRehersal = () => { - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, true), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - ) - } - if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - // The broadcast hasn't started yet - if (!this.playlist.activationId) { - // inactive, do the full preparation: - doUserAction( - t, - e, - UserAction.PREPARE_FOR_BROADCAST, - async (e, ts) => MeteorCall.userAction.prepareForBroadcast(e, ts, this.playlist._id), - (err) => { - if (!err) { - onSuccess() - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) - return false - } - } - } - ) - } else if (!this.playlist.rehearsal) { - // Active, and not in rehearsal - doModalDialog({ - title: 'Activate "Rehearsal"', - message: t('Are you sure you want to activate Rehearsal Mode?'), - yes: 'Activate "Rehearsal"', - no: t('Cancel'), - onAccept: () => { - doActivateRehersal() - }, - }) - } else { - // Already in rehearsal, do nothing - } - } else { - // The broadcast has started - if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { - // We are in the broadcast - doModalDialog({ - title: 'Activate "Rehearsal"', - message: t('Are you sure you want to activate Rehearsal Mode?'), - yes: 'Activate "Rehearsal"', - no: t('Cancel'), - onAccept: () => { - doActivateRehersal() - }, - }) - } else { - // The broadcast has ended - doActivateRehersal() - } - } - } - } - - public executeDeactivate = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if (this.userPermissions.studio && this.playlist.activationId) { - if (checkRundownTimes(this.playlist.timing).shouldHaveStarted) { - if (this.playlist.rehearsal) { - // We're in rehearsal mode - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - } else { - doModalDialog({ - title: 'Deactivate "On Air"', - message: t('Are you sure you want to deactivate this rundown?\n(This will clear the outputs.)'), - warning: true, - yes: t('Deactivate "On Air"'), - no: t('Cancel'), - onAccept: () => { - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - }, - }) - } - } else { - // Do it right away - doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => - MeteorCall.userAction.deactivate(e, ts, this.playlist._id) - ) - } - } - } - - public executeActivateAdlibTesting = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - if ( - this.userPermissions.studio && - this.studio.settings.allowAdlibTestingSegment && - this.playlist.activationId && - this.currentRundown - ) { - const rundownId = this.currentRundown._id - doUserAction(t, e, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts) => - MeteorCall.userAction.activateAdlibTestingMode(e, ts, this.playlist._id, rundownId) - ) - } - } - - public executeResetRundown = (t: i18next.TFunction, e: EventLike) => { - if ('persist' in e) e.persist() - - const doReset = () => { - this.rewindSegments() // Do a rewind right away - doUserAction( - t, - e, - UserAction.RESET_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetRundownPlaylist(e, ts, this.playlist._id), - () => { - this.deferFlushAndRewindSegments() - } - ) - } - if (this.playlist.activationId && !this.playlist.rehearsal && !this.studio.settings.allowRundownResetOnAir) { - // The rundown is active and not in rehearsal - doModalDialog({ - title: 'Reset Rundown', - message: t('The rundown can not be reset while it is active'), - onAccept: () => { - // nothing - }, - acceptOnly: true, - yes: 'OK', - }) - } else { - doReset() - } - } - - public executeReloadRundownPlaylist = (t: i18next.TFunction, e: EventLike) => { - if (!this.userPermissions.studio) return - - doUserAction( - t, - e, - UserAction.RELOAD_RUNDOWN_PLAYLIST_DATA, - async (e, ts) => MeteorCall.userAction.resyncRundownPlaylist(e, ts, this.playlist._id), - (err, reloadResponse) => { - if (!err && reloadResponse) { - if (!handleRundownPlaylistReloadResponse(t, this.userPermissions, reloadResponse)) { - if (this.playlist && this.playlist.nextPartInfo) { - scrollToPartInstance(this.playlist.nextPartInfo.partInstanceId).catch((error) => { - if (!error.toString().match(/another scroll/)) console.warn(error) - }) - } - } - } - } - ) - } - - public executeTakeRundownSnapshot = (t: i18next.TFunction, e: EventLike) => { - if (!this.userPermissions.studio) return - - const doneMessage = t('A snapshot of the current Running\xa0Order has been created for troubleshooting.') - doUserAction( - t, - e, - UserAction.CREATE_SNAPSHOT_FOR_DEBUG, - async (e, ts) => - MeteorCall.system.generateSingleUseToken().then(async (tokenResponse) => { - if (ClientAPI.isClientResponseError(tokenResponse)) { - throw UserError.fromSerialized(tokenResponse.error) - } else if (!tokenResponse.result) { - throw new Error(`Internal Error: No token.`) - } - return MeteorCall.userAction.storeRundownSnapshot( - e, - ts, - hashSingleUseToken(tokenResponse.result), - this.playlist._id, - 'Taken by user', - false - ) - }), - () => { - NotificationCenter.push( - new Notification( - undefined, - NoticeLevel.NOTIFICATION, - doneMessage, - 'userAction', - undefined, - false, - undefined, - undefined, - 5000 - ) - ) - return false - }, - doneMessage - ) - } - - public executeActivateRundown = (t: i18next.TFunction, e: EventLike) => { - // Called from the ModalDialog, 1 minute before broadcast starts - if (!this.userPermissions.studio) return - - this.rewindSegments() // Do a rewind right away - - doUserAction( - t, - e, - UserAction.ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), - (err) => { - if (!err) { - if (typeof this.onActivate === 'function') this.onActivate(false) - } else if (ClientAPI.isClientResponseError(err)) { - if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { - this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { - if (typeof this.onActivate === 'function') this.onActivate(false) - }) - return false - } - } - } - ) - } - - public executeResetAndActivateRundown = (t: i18next.TFunction, e: EventLike) => { - // Called from the ModalDialog, 1 minute before broadcast starts - if (!this.userPermissions.studio) return - - this.rewindSegments() // Do a rewind right away - - doUserAction( - t, - e, - UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, - async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), - (err) => { - if (!err) { - this.deferFlushAndRewindSegments() - if (typeof this.onActivate === 'function') this.onActivate(false) - } - } - ) - } - - private deferFlushAndRewindSegments = () => { - // Do a rewind later, when the UI has updated - Meteor.defer(() => { - Tracker.flush() - Meteor.setTimeout(() => { - this.rewindSegments() - RundownViewEventBus.emit(RundownViewEvents.GO_TO_TOP) - }, 500) - }) - } - - private rewindSegments = () => { - RundownViewEventBus.emit(RundownViewEvents.REWIND_SEGMENTS) - } -} - -export interface RundownPlaylistOperations { - take: (e: EventLike) => void - hold: (e: EventLike) => void - clearQuickLoop: (e: EventLike) => void - activate: (e: EventLike) => void - activateRehearsal: (e: EventLike) => void - deactivate: (e: EventLike) => void - activateAdlibTesting: (e: EventLike) => void - resetRundown: (e: EventLike) => void - reloadRundownPlaylist: (e: EventLike) => void - takeRundownSnapshot: (e: EventLike) => void - activateRundown: (e: EventLike) => void - resetAndActivateRundown: (e: EventLike) => void -} - -const RundownPlaylistOperationsContext = React.createContext(null) - -export function RundownPlaylistOperationsContextProvider({ - children, - currentRundown, - playlist, - studio, - onActivate, -}: React.PropsWithChildren<{ - studio: UIStudio - playlist: DBRundownPlaylist - currentRundown: Rundown | undefined - onActivate?: (isRehearsal: boolean) => void -}>): React.JSX.Element | null { - const { t } = useTranslation() - - const userPermissions = useContext(UserPermissionsContext) - - const service = useMemo( - () => new RundownPlaylistOperationsService(studio, playlist, currentRundown, userPermissions, onActivate), - [] - ) - - useEffect(() => { - service.studio = studio - service.playlist = playlist - service.currentRundown = currentRundown - service.userPermissions = userPermissions - service.onActivate = onActivate - }, [currentRundown, playlist, studio, userPermissions, onActivate]) - - const apiObject = useMemo( - () => - ({ - take: (e) => service.executeTake(t, e), - hold: (e) => service.executeHold(t, e), - clearQuickLoop: (e) => service.executeClearQuickLoop(t, e), - activate: (e) => service.executeActivate(t, e), - activateRehearsal: (e) => service.executeActivateRehearsal(t, e), - deactivate: (e) => service.executeDeactivate(t, e), - activateAdlibTesting: (e) => service.executeActivateAdlibTesting(t, e), - resetRundown: (e) => service.executeResetRundown(t, e), - reloadRundownPlaylist: (e) => service.executeReloadRundownPlaylist(t, e), - takeRundownSnapshot: (e) => service.executeTakeRundownSnapshot(t, e), - activateRundown: (e) => service.executeActivateRundown(t, e), - resetAndActivateRundown: (e) => service.executeResetAndActivateRundown(t, e), - }) satisfies RundownPlaylistOperations, - [service, t] - ) - - return ( - {children} - ) -} - -export function useRundownPlaylistOperations(): RundownPlaylistOperations { - const context = useContext(RundownPlaylistOperationsContext) - - if (!context) - throw new Error('This component must be a child of a `RundownPlaylistOperationsContextProvider` component.') - - return context -} - -interface RundownTimesInfo { - shouldHaveStarted: boolean - willShortlyStart: boolean - shouldHaveEnded: boolean -} - -type EventLike = - | { - persist(): void - } - | {} - -export function checkRundownTimes(playlistTiming: RundownPlaylistTiming): RundownTimesInfo { - const currentTime = getCurrentTime() - - const shouldHaveEnded = - currentTime > - (PlaylistTiming.getExpectedStart(playlistTiming) || 0) + (PlaylistTiming.getExpectedDuration(playlistTiming) || 0) - - return { - shouldHaveStarted: currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0), - willShortlyStart: - !shouldHaveEnded && currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0) - REHEARSAL_MARGIN, - shouldHaveEnded, - } -} From b6c3d44185a49581abe1524f9965c341916f64a9 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 13:59:51 +0000 Subject: [PATCH 25/57] fix: Correct duration calculations in RundownHeader components by using remainingPlaylistDuration --- .../RundownHeader/RundownHeaderDurations.tsx | 21 ++----------------- .../RundownHeaderExpectedEnd.tsx | 18 +++++----------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index c543acea2e..63cc538125 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' -import { getRemainingDurationFromCurrentPart } from './remainingDuration' export function RundownHeaderDurations({ playlist, @@ -18,24 +17,8 @@ export function RundownHeaderDurations({ const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) - const now = timingDurations.currentTime ?? Date.now() - const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId - - let estDuration: number | null = null - if (currentPartInstanceId && timingDurations.partStartsAt && timingDurations.partExpectedDurations) { - const remaining = getRemainingDurationFromCurrentPart( - currentPartInstanceId, - timingDurations.partStartsAt, - timingDurations.partExpectedDurations - ) - if (remaining != null) { - const elapsed = - playlist.startedPlayback == null - ? (timingDurations.asDisplayedPlaylistDuration ?? 0) - : now - playlist.startedPlayback - estDuration = elapsed + remaining - } - } + // Use remainingPlaylistDuration which includes current part's remaining time + const estDuration = timingDurations.remainingPlaylistDuration if (expectedDuration == null && estDuration == null) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index fe90f5b80a..5268ad04c6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -3,7 +3,6 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi import { useTranslation } from 'react-i18next' import { Countdown } from './Countdown' import { useTiming } from '../RundownTiming/withTiming' -import { getRemainingDurationFromCurrentPart } from './remainingDuration' export function RundownHeaderExpectedEnd({ playlist, @@ -18,18 +17,11 @@ export function RundownHeaderExpectedEnd({ const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) const now = timingDurations.currentTime ?? Date.now() - let estEnd: number | null = null - const currentPartInstanceId = playlist.currentPartInfo?.partInstanceId - if (currentPartInstanceId && timingDurations.partStartsAt && timingDurations.partExpectedDurations) { - const remaining = getRemainingDurationFromCurrentPart( - currentPartInstanceId, - timingDurations.partStartsAt, - timingDurations.partExpectedDurations - ) - if (remaining != null && remaining > 0) { - estEnd = now + remaining - } - } + // Use remainingPlaylistDuration which includes current part's remaining time + const estEnd = + timingDurations.remainingPlaylistDuration != null && timingDurations.remainingPlaylistDuration > 0 + ? now + timingDurations.remainingPlaylistDuration + : null if (!expectedEnd && !estEnd) return null From f30aaf35f790655e15e53d17e2d01ca1da4c7127 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 14:00:17 +0000 Subject: [PATCH 26/57] Linting improvements --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 63cc538125..f714a7e025 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration != null ? ( + {expectedDuration ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration != null ? ( + {!simplified && estDuration ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 5268ad04c6..ccbc68ccfd 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -8,8 +8,8 @@ export function RundownHeaderExpectedEnd({ playlist, simplified, }: { - playlist: DBRundownPlaylist - simplified?: boolean + readonly playlist: DBRundownPlaylist + readonly simplified?: boolean }): JSX.Element | null { const { t } = useTranslation() const timingDurations = useTiming() From 36f3add3577f02c292abf7bad2b5a8694f9edef8 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 15:32:48 +0100 Subject: [PATCH 27/57] chore: Made all timer labels center-aligned. --- .../src/client/ui/RundownView/RundownHeader/Countdown.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss index d0335ff432..6fdf4e253b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/Countdown.scss @@ -12,7 +12,7 @@ @extend %hoverable-label; white-space: nowrap; position: relative; - top: -0.51em; /* Visually push the label up to align with cap height */ + top: -0.3em; /* Visually place the label to vertically align */ margin-left: auto; text-align: right; width: 100%; From 2f59e44c78a43bacdc0c9274115ed222824130d7 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Thu, 12 Mar 2026 15:52:28 +0100 Subject: [PATCH 28/57] chore: Changed the Rehearsal background to striped grey, and added labels for Deactivated and Rehearsal. --- .../RundownHeader/RundownHeader.scss | 24 +++++++++++++++++-- .../RundownHeader/RundownHeader.tsx | 5 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 063edb2b33..4f5358e2ce 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -43,7 +43,15 @@ } &.rehearsal { - background: $color-header-rehearsal; + background-color: #06090d; + background-image: repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.08) 0, + rgba(255, 255, 255, 0.08) 18px, + transparent 18px, + transparent 36px + ); + border-bottom: 1px solid #256b91; } } @@ -62,6 +70,18 @@ flex: 1; } + .rundown-header__not-on-air-label { + @extend %hoverable-label; + opacity: 1; + color: #fff; + font-size: 0.8em; + letter-spacing: 0.02em; + + margin-left: 0.075em; + margin-right: 0.25em; + white-space: nowrap; + } + .rundown-header__right { display: flex; align-items: center; @@ -188,7 +208,7 @@ .rundown-header__clocks-diff__label { @extend .rundown-header__hoverable-label; - font-size: 0.7em; + font-size: 0.75em; opacity: 0.6; font-variation-settings: 'wdth' 25, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 0ea08f19fc..5ccfa63cf6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -64,6 +64,11 @@ export function RundownHeader({
+ {playlist.activationId && playlist.rehearsal && ( + {t('REHEARSAL')} + )} + {!playlist.activationId && {t('DEACTIVATED')}} {playlist.currentPartInfo && (
Date: Thu, 12 Mar 2026 16:10:22 +0100 Subject: [PATCH 29/57] fix: more explicit truthy check allow keeping 0 dur timers visible --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- .../RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index f714a7e025..ab981c7f95 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration ? ( + {expectedDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration ? ( + {!simplified && estDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index ccbc68ccfd..077f3129a7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -27,10 +27,10 @@ export function RundownHeaderExpectedEnd({ return (
- {expectedEnd ? ( + {expectedEnd !== undefined ? ( ) : null} - {!simplified && estEnd ? ( + {!simplified && estEnd !== null ? ( ) : null}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index a9ea781de1..af24aa612b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -25,7 +25,7 @@ export function RundownHeaderPlannedStart({
{!simplified && - (playlist.startedPlayback ? ( + (playlist.startedPlayback !== undefined ? ( ) : ( From e4f15fa30434b3de4e9da45c080a047799112b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 16:10:44 +0100 Subject: [PATCH 30/57] chore: cleanup --- packages/webui/src/client/lib/rundownTiming.ts | 2 +- .../src/client/ui/RundownView/RundownHeader/RundownHeader.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index 7d76b7da45..fb7c20b46e 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -473,7 +473,7 @@ export class RundownTimingCalculator { // partExpectedDuration is affected by displayGroups, and if it hasn't played yet then it shouldn't // add any duration to the "remaining" time pool remainingRundownDuration += - calculatePartInstanceExpectedDurationWithTransition(partInstance) || 0 + calculatePartInstanceExpectedDurationWithTransition(partInstance) || 0 // item is onAir right now, and it's is currently shorter than expectedDuration } else if ( lastStartedPlayback && diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 5ccfa63cf6..2f385072c5 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -98,7 +98,7 @@ export function RundownHeader({ {rundownCount > 1 ? ( {playlist.name} ) : ( - {(currentRundown ?? firstRundown)?.name} + {currentRundown?.name} )}
From ffa7999870d89edece1cd6903cce7ba76353339a Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:27:05 +0100 Subject: [PATCH 31/57] WIP: add open state to menu icon in top bar --- .../RundownHeader/RundownContextMenu.tsx | 70 ++++++++----- .../RundownHeader/RundownHeader.scss | 6 ++ .../RundownHeader/RundownHeader.tsx | 98 +++++++++++-------- .../RundownHeaderPlannedStart.tsx | 2 +- 4 files changed, 106 insertions(+), 70 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index 3941357cff..d564976e6c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next' import Escape from '../../../lib/Escape' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Rundown, getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { ContextMenu, MenuItem, ContextMenuTrigger } from '@jstarpl/react-contextmenu' +import { ContextMenu, MenuItem, ContextMenuTrigger, hideMenu, showMenu } from '@jstarpl/react-contextmenu' import { contextMenuHoldToDisplayTime, useRundownViewEventBusListener } from '../../../lib/lib' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBars } from '@fortawesome/free-solid-svg-icons' +import { faBars, faTimes } from '@fortawesome/free-solid-svg-icons' import { ActivateRundownPlaylistEvent, DeactivateRundownPlaylistEvent, @@ -25,6 +25,8 @@ interface RundownContextMenuProps { playlist: DBRundownPlaylist studio: UIStudio firstRundown: Rundown | undefined + onShow?: () => void + onHide?: () => void } /** @@ -32,7 +34,13 @@ interface RundownContextMenuProps { * trigger area. It also registers event bus listeners for playlist operations (activate, * deactivate, take, reset, etc.) since these are tightly coupled to the menu actions. */ -export function RundownContextMenu({ playlist, studio, firstRundown }: Readonly): JSX.Element { +export function RundownContextMenu({ + playlist, + studio, + firstRundown, + onShow, + onHide, +}: Readonly): JSX.Element { const { t } = useTranslation() const userPermissions = useContext(UserPermissionsContext) const operations = useRundownPlaylistOperations() @@ -77,7 +85,7 @@ export function RundownContextMenu({ playlist, studio, firstRundown }: Readonly< return ( - +
{playlist && playlist.name}
{userPermissions.studio ? ( @@ -147,33 +155,43 @@ export function RundownHeaderContextMenuTrigger({ children }: Readonly void }>): JSX.Element { const { t } = useTranslation() const buttonRef = useRef(null) - const handleClick = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - // Dispatch a custom contextmenu event - if (buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect() - const event = new MouseEvent('contextmenu', { - view: globalThis as unknown as Window, - bubbles: true, - cancelable: true, - clientX: rect.left, - clientY: rect.bottom + 5, - button: 2, - buttons: 2, - }) - buttonRef.current.dispatchEvent(event) - } - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (isOpen) { + hideMenu({ id: RUNDOWN_CONTEXT_MENU_ID }) + onClose() + return + } + + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + showMenu({ + position: { x: rect.left, y: rect.bottom + 5 }, + id: RUNDOWN_CONTEXT_MENU_ID, + }) + } + }, + [isOpen] + ) return ( - ) } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index 4f5358e2ce..eba36e72ec 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -68,6 +68,12 @@ display: flex; align-items: center; flex: 1; + + .rundown-header__left-context-menu-wrapper { + display: flex; + align-items: center; + height: 100%; + } } .rundown-header__not-on-air-label { diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index 2f385072c5..b3a1851e2c 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import ClassNames from 'classnames' import { NavLink } from 'react-router-dom' import * as CoreIcon from '@nrk/core-icons/jsx' @@ -46,10 +46,19 @@ export function RundownHeader({ }: IRundownHeaderProps): JSX.Element { const { t } = useTranslation() const [simplified, setSimplified] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const onMenuClose = useCallback(() => setIsMenuOpen(false), [setIsMenuOpen]) return ( <> - + setIsMenuOpen(true)} + onHide={() => setIsMenuOpen(false)} + /> - -
-
- - {playlist.activationId && playlist.rehearsal && ( - {t('REHEARSAL')} - )} - {!playlist.activationId && {t('DEACTIVATED')}} - {playlist.currentPartInfo && ( -
- - - {t('On Air')} - +
+ + +
+ {playlist.activationId && playlist.rehearsal && ( + {t('REHEARSAL')} + )} + {!playlist.activationId && {t('DEACTIVATED')}} + {playlist.currentPartInfo && ( +
+ - - -
- )} - -
+ + {t('On Air')} + + + +
+ )} + +
+ +
+
@@ -98,28 +110,28 @@ export function RundownHeader({ {rundownCount > 1 ? ( {playlist.name} ) : ( - {currentRundown?.name} + {(currentRundown ?? firstRundown)?.name} )}
+
-
- - - - -
+
+ + + +
- +
) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index af24aa612b..e3384f4de1 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -28,7 +28,7 @@ export function RundownHeaderPlannedStart({ (playlist.startedPlayback !== undefined ? ( ) : ( - + {diff >= 0 && '-'} {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} From 7d4c6a17ffabe18d940330396edafdff92027bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Thu, 12 Mar 2026 16:31:09 +0100 Subject: [PATCH 32/57] fix: allow Plan. end to show even at the end of the show --- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 077f3129a7..df93d376c6 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -19,7 +19,7 @@ export function RundownHeaderExpectedEnd({ // Use remainingPlaylistDuration which includes current part's remaining time const estEnd = - timingDurations.remainingPlaylistDuration != null && timingDurations.remainingPlaylistDuration > 0 + timingDurations.remainingPlaylistDuration !== undefined ? now + timingDurations.remainingPlaylistDuration : null From 376613bef11e955f9b7a905253e38f632da26a2f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 15:35:20 +0000 Subject: [PATCH 33/57] Remove no longer needed file --- .../RundownHeader/remainingDuration.ts | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts b/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts deleted file mode 100644 index b54bb6c74f..0000000000 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/remainingDuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' - -/** - * Compute the sum of expected durations of all parts after the current part. - * Uses partStartsAt to determine ordering and partExpectedDurations for the values. - * Returns 0 if the current part can't be found or there are no future parts. - */ -export function getRemainingDurationFromCurrentPart( - currentPartInstanceId: PartInstanceId, - partStartsAt: Record, - partExpectedDurations: Record -): number | null { - const currentKey = unprotectString(currentPartInstanceId) - const currentStartsAt = partStartsAt[currentKey] - - if (currentStartsAt == null) return null - - let remaining = 0 - for (const [partId, startsAt] of Object.entries(partStartsAt)) { - if (startsAt > currentStartsAt) { - remaining += partExpectedDurations[partId] ?? 0 - } - } - return remaining -} From 0498831be29fd2313c42e3596fef6417dc71b438 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 12 Mar 2026 15:35:58 +0000 Subject: [PATCH 34/57] Fix overdeletion of _old files --- .../webui/src/client/ui/RundownList/util.ts | 2 +- .../RundownHeader/RundownContextMenu.tsx | 2 +- .../RundownHeader/RundownReloadResponse.ts | 177 +++++ .../useRundownPlaylistOperations.tsx | 693 ++++++++++++++++++ .../client/ui/RundownView/RundownNotifier.tsx | 2 +- .../RundownViewContextProviders.tsx | 2 +- 6 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx diff --git a/packages/webui/src/client/ui/RundownList/util.ts b/packages/webui/src/client/ui/RundownList/util.ts index 7a492e91f3..cec30f2fd8 100644 --- a/packages/webui/src/client/ui/RundownList/util.ts +++ b/packages/webui/src/client/ui/RundownList/util.ts @@ -4,7 +4,7 @@ import { doModalDialog } from '../../lib/ModalDialog.js' import { doUserAction, UserAction } from '../../lib/clientUserAction.js' import { MeteorCall } from '../../lib/meteorApi.js' import { TFunction } from 'i18next' -import { handleRundownReloadResponse } from '../RundownView/RundownHeader_old/RundownReloadResponse.js' +import { handleRundownReloadResponse } from '../RundownView/RundownHeader/RundownReloadResponse.js' import { RundownId, RundownLayoutId, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index d564976e6c..1d2acbd09b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -16,7 +16,7 @@ import { import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { UserPermissionsContext } from '../../UserPermissions' import * as RundownResolver from '../../../lib/RundownResolver' -import { checkRundownTimes, useRundownPlaylistOperations } from '../RundownHeader_old/useRundownPlaylistOperations' +import { checkRundownTimes, useRundownPlaylistOperations } from './useRundownPlaylistOperations.js' import { reloadRundownPlaylistClick } from '../RundownNotifier' export const RUNDOWN_CONTEXT_MENU_ID = 'rundown-context-menu' diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts new file mode 100644 index 0000000000..a858db9caf --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownReloadResponse.ts @@ -0,0 +1,177 @@ +import { RundownPlaylists, Rundowns } from '../../../collections' +import { + ReloadRundownPlaylistResponse, + TriggerReloadDataResponse, +} from '@sofie-automation/meteor-lib/dist/api/userActions' +import _ from 'underscore' +import { RundownPlaylistCollectionUtil } from '../../../collections/rundownPlaylistUtil' +import * as i18next from 'i18next' +import { UserPermissions } from '../../UserPermissions' +import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { getRundownNrcsName } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' +import { Tracker } from 'meteor/tracker' +import { doUserAction } from '../../../lib/clientUserAction' +import { MeteorCall } from '../../../lib/meteorApi' +import { doModalDialog } from '../../../lib/ModalDialog' + +export function handleRundownPlaylistReloadResponse( + t: i18next.TFunction, + userPermissions: Readonly, + result: ReloadRundownPlaylistResponse +): boolean { + const rundownsInNeedOfHandling = result.rundownsResponses.filter( + (r) => r.response === TriggerReloadDataResponse.MISSING + ) + const firstRundownId = _.first(rundownsInNeedOfHandling)?.rundownId + let allRundownsAffected = false + + if (firstRundownId) { + const firstRundown = Rundowns.findOne(firstRundownId) + const playlist = RundownPlaylists.findOne(firstRundown?.playlistId) + const allRundownIds = playlist ? RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) : [] + if ( + allRundownIds.length > 0 && + _.difference( + allRundownIds, + rundownsInNeedOfHandling.map((r) => r.rundownId) + ).length === 0 + ) { + allRundownsAffected = true + } + } + + const actionsTaken: RundownReloadResponseUserAction[] = [] + function onActionTaken(action: RundownReloadResponseUserAction): void { + actionsTaken.push(action) + if (actionsTaken.length === rundownsInNeedOfHandling.length) { + // the user has taken action on all of the missing rundowns + if (allRundownsAffected && actionsTaken.filter((actionTaken) => actionTaken !== 'removed').length === 0) { + // all rundowns in the playlist were affected and all of them were removed + // we redirect to the Lobby + window.location.assign('/') + } + } + } + + const handled = rundownsInNeedOfHandling.map((r) => + handleRundownReloadResponse(t, userPermissions, r.rundownId, r.response, onActionTaken) + ) + return handled.reduce((previousValue, value) => previousValue || value, false) +} + +export type RundownReloadResponseUserAction = 'removed' | 'unsynced' | 'error' + +export function handleRundownReloadResponse( + t: i18next.TFunction, + userPermissions: Readonly, + rundownId: RundownId, + result: TriggerReloadDataResponse, + clb?: (action: RundownReloadResponseUserAction) => void +): boolean { + let hasDoneSomething = false + + if (result === TriggerReloadDataResponse.MISSING) { + const rundown = Rundowns.findOne(rundownId) + const playlist = RundownPlaylists.findOne(rundown?.playlistId) + + hasDoneSomething = true + const notification = new Notification( + undefined, + NoticeLevel.CRITICAL, + t( + 'Rundown {{rundownName}} in Playlist {{playlistName}} is missing in the data from {{nrcsName}}. You can either leave it in Sofie and mark it as Unsynced or remove the rundown from Sofie. What do you want to do?', + { + nrcsName: getRundownNrcsName(rundown), + rundownName: rundown?.name || t('(Unknown rundown)'), + playlistName: playlist?.name || t('(Unknown playlist)'), + } + ), + 'userAction', + undefined, + true, + [ + // actions: + { + label: t('Leave Unsynced'), + type: 'default', + disabled: !userPermissions.studio, + action: () => { + doUserAction( + t, + 'Missing rundown action', + UserAction.UNSYNC_RUNDOWN, + async (e, ts) => MeteorCall.userAction.unsyncRundown(e, ts, rundownId), + (err) => { + if (!err) { + notificationHandle.stop() + clb?.('unsynced') + } else { + clb?.('error') + } + } + ) + }, + }, + { + label: t('Remove'), + type: 'default', + action: () => { + doModalDialog({ + title: t('Remove rundown'), + message: t( + 'Do you really want to remove just the rundown "{{rundownName}}" in the playlist {{playlistName}} from Sofie? \n\nThis cannot be undone!', + { + rundownName: rundown?.name || 'N/A', + playlistName: playlist?.name || 'N/A', + } + ), + onAccept: () => { + // nothing + doUserAction( + t, + 'Missing rundown action', + UserAction.REMOVE_RUNDOWN, + async (e, ts) => MeteorCall.userAction.removeRundown(e, ts, rundownId), + (err) => { + if (!err) { + notificationHandle.stop() + clb?.('removed') + } else { + clb?.('error') + } + } + ) + }, + }) + }, + }, + ] + ) + const notificationHandle = NotificationCenter.push(notification) + + if (rundown) { + // This allows the semi-modal dialog above to be closed automatically, once the rundown stops existing + // for whatever reason + const comp = Tracker.autorun(() => { + const rundown = Rundowns.findOne(rundownId, { + fields: { + _id: 1, + orphaned: 1, + }, + }) + // we should hide the message + if (!rundown || !rundown.orphaned) { + notificationHandle.stop() + } + }) + notification.on('dropped', () => { + // clean up the reactive computation above when the notification is closed. Will be also executed by + // the notificationHandle.stop() above, so the Tracker.autorun will clean up after itself as well. + comp.stop() + }) + } + } + return hasDoneSomething +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx new file mode 100644 index 0000000000..c4d5a3b64d --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/useRundownPlaylistOperations.tsx @@ -0,0 +1,693 @@ +import { SerializedUserError, UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' +import { UserAction } from '@sofie-automation/meteor-lib/dist/userAction' +import { doUserAction } from '../../../lib/clientUserAction' +import { MeteorCall } from '../../../lib/meteorApi' +import { doModalDialog } from '../../../lib/ModalDialog' +import { useTranslation } from 'react-i18next' +import React, { useContext, useEffect, useMemo } from 'react' +import { UserPermissions, UserPermissionsContext } from '../../UserPermissions' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { logger } from '../../../lib/logging' +import * as i18next from 'i18next' +import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' +import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' +import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' +import { handleRundownPlaylistReloadResponse } from './RundownReloadResponse.js' +import { scrollToPartInstance } from '../../../lib/viewPort' +import { hashSingleUseToken } from '../../../lib/lib' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { getCurrentTime } from '../../../lib/systemTime' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { REHEARSAL_MARGIN } from '../WarningDisplay' +import { RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' + +class RundownPlaylistOperationsService { + constructor( + public studio: UIStudio, + public playlist: DBRundownPlaylist, + public currentRundown: Rundown | undefined, + public userPermissions: UserPermissions, + public onActivate?: (isRehearsal: boolean) => void + ) {} + + public executeTake(t: i18next.TFunction, e: EventLike): void { + if (!this.userPermissions.studio) return + + if (!this.playlist.activationId) { + const onSuccess = () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const handleResult = (err: any) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + // ask to activate + doModalDialog({ + title: t('Failed to execute take'), + message: t( + 'The rundown you are trying to execute a take on is inactive, would you like to activate this rundown?' + ), + acceptOnly: false, + warning: true, + yes: t('Activate "On Air"'), + no: t('Cancel'), + discardAsPrimary: true, + onDiscard: () => { + // Do nothing + }, + actions: [ + { + label: t('Activate "Rehearsal"'), + classNames: 'btn-secondary', + on: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, this.playlist._id, true), + handleResult + ) + }, + }, + ], + onAccept: () => { + // nothing + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + handleResult + ) + }, + }) + } else { + doUserAction(t, e, UserAction.TAKE, async (e, ts) => + MeteorCall.userAction.take(e, ts, this.playlist._id, this.playlist.currentPartInfo?.partInstanceId ?? null) + ) + } + } + + private handleAnotherPlaylistActive( + t: i18next.TFunction, + playlistId: RundownPlaylistId, + rehersal: boolean, + err: SerializedUserError, + clb?: (response: void) => void + ): void { + function handleResult(err: any, response: void) { + if (!err) { + if (typeof clb === 'function') clb(response) + } else { + logger.error(err) + doModalDialog({ + title: t('Failed to activate'), + message: t('Something went wrong, please contact the system administrator if the problem persists.'), + acceptOnly: true, + warning: true, + yes: t('OK'), + onAccept: () => { + // nothing + }, + }) + } + } + + doModalDialog({ + title: t('Another Rundown is Already Active!'), + message: t( + 'The rundown: "{{rundownName}}" will need to be deactivated in order to activate this one.\n\nAre you sure you want to activate this one anyway?', + { + // TODO: this is a bit of a hack, could a better string sent from the server instead? + rundownName: err.userMessage.args?.names ?? '', + } + ), + yes: t('Activate "On Air"'), + no: t('Cancel'), + discardAsPrimary: true, + actions: [ + { + label: t('Activate "Rehearsal"'), + classNames: 'btn-secondary', + on: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, rehersal), + handleResult + ) + }, + }, + ], + warning: true, + onAccept: (e) => { + doUserAction( + t, + e, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.forceResetAndActivate(e, ts, playlistId, false), + handleResult + ) + }, + }) + } + + public executeHold(t: i18next.TFunction, e: EventLike): void { + if (this.userPermissions.studio && this.playlist.activationId) { + doUserAction(t, e, UserAction.ACTIVATE_HOLD, async (e, ts) => + MeteorCall.userAction.activateHold(e, ts, this.playlist._id, false) + ) + } + } + + public executeClearQuickLoop(t: i18next.TFunction, e: EventLike) { + if (this.userPermissions.studio && this.playlist.activationId) { + doUserAction(t, e, UserAction.CLEAR_QUICK_LOOP, async (e, ts) => + MeteorCall.userAction.clearQuickLoop(e, ts, this.playlist._id) + ) + } + } + + public executeActivate(t: i18next.TFunction, e: EventLike) { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + (!this.playlist.activationId || (this.playlist.activationId && this.playlist.rehearsal)) + ) { + const onSuccess = () => { + this.deferFlushAndRewindSegments() + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const doActivate = () => { + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + (err) => { + if (!err) { + if (typeof this.onActivate === 'function') this.onActivate(false) + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + }) + return false + } + } + } + ) + } + + const doActivateAndReset = () => { + this.rewindSegments() + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) + return false + } + } + } + ) + } + + if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + // The broadcast hasn't started yet + doModalDialog({ + title: 'Activate "On Air"', + message: t('Do you want to activate this Rundown?'), + yes: 'Reset and Activate "On Air"', + no: t('Cancel'), + actions: [ + { + label: 'Activate "On Air"', + classNames: 'btn-secondary', + on: () => { + doActivate() // this one activates without resetting + }, + }, + ], + acceptOnly: false, + onAccept: () => { + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, onSuccess) + return false + } + } + } + ) + }, + }) + } else if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { + // The broadcast has started + doActivate() + } else { + // The broadcast has ended, going into active mode is probably not what you want to do + doModalDialog({ + title: 'Activate "On Air"', + message: t('The planned end time has passed, are you sure you want to activate this Rundown?'), + yes: 'Reset and Activate "On Air"', + no: t('Cancel'), + actions: [ + { + label: 'Activate "On Air"', + classNames: 'btn-secondary', + on: () => { + doActivate() // this one activates without resetting + }, + }, + ], + acceptOnly: false, + onAccept: () => { + doActivateAndReset() + }, + }) + } + } + } + + public executeActivateRehearsal = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + (!this.playlist.activationId || (this.playlist.activationId && !this.playlist.rehearsal)) + ) { + const onSuccess = () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + } + const doActivateRehersal = () => { + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, true), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + ) + } + if (!checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + // The broadcast hasn't started yet + if (!this.playlist.activationId) { + // inactive, do the full preparation: + doUserAction( + t, + e, + UserAction.PREPARE_FOR_BROADCAST, + async (e, ts) => MeteorCall.userAction.prepareForBroadcast(e, ts, this.playlist._id), + (err) => { + if (!err) { + onSuccess() + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, true, err.error, onSuccess) + return false + } + } + } + ) + } else if (!this.playlist.rehearsal) { + // Active, and not in rehearsal + doModalDialog({ + title: 'Activate "Rehearsal"', + message: t('Are you sure you want to activate Rehearsal Mode?'), + yes: 'Activate "Rehearsal"', + no: t('Cancel'), + onAccept: () => { + doActivateRehersal() + }, + }) + } else { + // Already in rehearsal, do nothing + } + } else { + // The broadcast has started + if (!checkRundownTimes(this.playlist.timing).shouldHaveEnded) { + // We are in the broadcast + doModalDialog({ + title: 'Activate "Rehearsal"', + message: t('Are you sure you want to activate Rehearsal Mode?'), + yes: 'Activate "Rehearsal"', + no: t('Cancel'), + onAccept: () => { + doActivateRehersal() + }, + }) + } else { + // The broadcast has ended + doActivateRehersal() + } + } + } + } + + public executeDeactivate = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if (this.userPermissions.studio && this.playlist.activationId) { + if (checkRundownTimes(this.playlist.timing).shouldHaveStarted) { + if (this.playlist.rehearsal) { + // We're in rehearsal mode + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + } else { + doModalDialog({ + title: 'Deactivate "On Air"', + message: t('Are you sure you want to deactivate this rundown?\n(This will clear the outputs.)'), + warning: true, + yes: t('Deactivate "On Air"'), + no: t('Cancel'), + onAccept: () => { + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + }, + }) + } + } else { + // Do it right away + doUserAction(t, e, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts) => + MeteorCall.userAction.deactivate(e, ts, this.playlist._id) + ) + } + } + } + + public executeActivateAdlibTesting = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + if ( + this.userPermissions.studio && + this.studio.settings.allowAdlibTestingSegment && + this.playlist.activationId && + this.currentRundown + ) { + const rundownId = this.currentRundown._id + doUserAction(t, e, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts) => + MeteorCall.userAction.activateAdlibTestingMode(e, ts, this.playlist._id, rundownId) + ) + } + } + + public executeResetRundown = (t: i18next.TFunction, e: EventLike) => { + if ('persist' in e) e.persist() + + const doReset = () => { + this.rewindSegments() // Do a rewind right away + doUserAction( + t, + e, + UserAction.RESET_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetRundownPlaylist(e, ts, this.playlist._id), + () => { + this.deferFlushAndRewindSegments() + } + ) + } + if (this.playlist.activationId && !this.playlist.rehearsal && !this.studio.settings.allowRundownResetOnAir) { + // The rundown is active and not in rehearsal + doModalDialog({ + title: 'Reset Rundown', + message: t('The rundown can not be reset while it is active'), + onAccept: () => { + // nothing + }, + acceptOnly: true, + yes: 'OK', + }) + } else { + doReset() + } + } + + public executeReloadRundownPlaylist = (t: i18next.TFunction, e: EventLike) => { + if (!this.userPermissions.studio) return + + doUserAction( + t, + e, + UserAction.RELOAD_RUNDOWN_PLAYLIST_DATA, + async (e, ts) => MeteorCall.userAction.resyncRundownPlaylist(e, ts, this.playlist._id), + (err, reloadResponse) => { + if (!err && reloadResponse) { + if (!handleRundownPlaylistReloadResponse(t, this.userPermissions, reloadResponse)) { + if (this.playlist && this.playlist.nextPartInfo) { + scrollToPartInstance(this.playlist.nextPartInfo.partInstanceId).catch((error) => { + if (!error.toString().match(/another scroll/)) console.warn(error) + }) + } + } + } + } + ) + } + + public executeTakeRundownSnapshot = (t: i18next.TFunction, e: EventLike) => { + if (!this.userPermissions.studio) return + + const doneMessage = t('A snapshot of the current Running\xa0Order has been created for troubleshooting.') + doUserAction( + t, + e, + UserAction.CREATE_SNAPSHOT_FOR_DEBUG, + async (e, ts) => + MeteorCall.system.generateSingleUseToken().then(async (tokenResponse) => { + if (ClientAPI.isClientResponseError(tokenResponse)) { + throw UserError.fromSerialized(tokenResponse.error) + } else if (!tokenResponse.result) { + throw new Error(`Internal Error: No token.`) + } + return MeteorCall.userAction.storeRundownSnapshot( + e, + ts, + hashSingleUseToken(tokenResponse.result), + this.playlist._id, + 'Taken by user', + false + ) + }), + () => { + NotificationCenter.push( + new Notification( + undefined, + NoticeLevel.NOTIFICATION, + doneMessage, + 'userAction', + undefined, + false, + undefined, + undefined, + 5000 + ) + ) + return false + }, + doneMessage + ) + } + + public executeActivateRundown = (t: i18next.TFunction, e: EventLike) => { + // Called from the ModalDialog, 1 minute before broadcast starts + if (!this.userPermissions.studio) return + + this.rewindSegments() // Do a rewind right away + + doUserAction( + t, + e, + UserAction.ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.activate(e, ts, this.playlist._id, false), + (err) => { + if (!err) { + if (typeof this.onActivate === 'function') this.onActivate(false) + } else if (ClientAPI.isClientResponseError(err)) { + if (err.error.key === UserErrorMessage.RundownAlreadyActiveNames) { + this.handleAnotherPlaylistActive(t, this.playlist._id, false, err.error, () => { + if (typeof this.onActivate === 'function') this.onActivate(false) + }) + return false + } + } + } + ) + } + + public executeResetAndActivateRundown = (t: i18next.TFunction, e: EventLike) => { + // Called from the ModalDialog, 1 minute before broadcast starts + if (!this.userPermissions.studio) return + + this.rewindSegments() // Do a rewind right away + + doUserAction( + t, + e, + UserAction.RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + async (e, ts) => MeteorCall.userAction.resetAndActivate(e, ts, this.playlist._id), + (err) => { + if (!err) { + this.deferFlushAndRewindSegments() + if (typeof this.onActivate === 'function') this.onActivate(false) + } + } + ) + } + + private deferFlushAndRewindSegments = () => { + // Do a rewind later, when the UI has updated + Meteor.defer(() => { + Tracker.flush() + Meteor.setTimeout(() => { + this.rewindSegments() + RundownViewEventBus.emit(RundownViewEvents.GO_TO_TOP) + }, 500) + }) + } + + private rewindSegments = () => { + RundownViewEventBus.emit(RundownViewEvents.REWIND_SEGMENTS) + } +} + +export interface RundownPlaylistOperations { + take: (e: EventLike) => void + hold: (e: EventLike) => void + clearQuickLoop: (e: EventLike) => void + activate: (e: EventLike) => void + activateRehearsal: (e: EventLike) => void + deactivate: (e: EventLike) => void + activateAdlibTesting: (e: EventLike) => void + resetRundown: (e: EventLike) => void + reloadRundownPlaylist: (e: EventLike) => void + takeRundownSnapshot: (e: EventLike) => void + activateRundown: (e: EventLike) => void + resetAndActivateRundown: (e: EventLike) => void +} + +const RundownPlaylistOperationsContext = React.createContext(null) + +export function RundownPlaylistOperationsContextProvider({ + children, + currentRundown, + playlist, + studio, + onActivate, +}: React.PropsWithChildren<{ + studio: UIStudio + playlist: DBRundownPlaylist + currentRundown: Rundown | undefined + onActivate?: (isRehearsal: boolean) => void +}>): React.JSX.Element | null { + const { t } = useTranslation() + + const userPermissions = useContext(UserPermissionsContext) + + const service = useMemo( + () => new RundownPlaylistOperationsService(studio, playlist, currentRundown, userPermissions, onActivate), + [] + ) + + useEffect(() => { + service.studio = studio + service.playlist = playlist + service.currentRundown = currentRundown + service.userPermissions = userPermissions + service.onActivate = onActivate + }, [currentRundown, playlist, studio, userPermissions, onActivate]) + + const apiObject = useMemo( + () => + ({ + take: (e) => service.executeTake(t, e), + hold: (e) => service.executeHold(t, e), + clearQuickLoop: (e) => service.executeClearQuickLoop(t, e), + activate: (e) => service.executeActivate(t, e), + activateRehearsal: (e) => service.executeActivateRehearsal(t, e), + deactivate: (e) => service.executeDeactivate(t, e), + activateAdlibTesting: (e) => service.executeActivateAdlibTesting(t, e), + resetRundown: (e) => service.executeResetRundown(t, e), + reloadRundownPlaylist: (e) => service.executeReloadRundownPlaylist(t, e), + takeRundownSnapshot: (e) => service.executeTakeRundownSnapshot(t, e), + activateRundown: (e) => service.executeActivateRundown(t, e), + resetAndActivateRundown: (e) => service.executeResetAndActivateRundown(t, e), + }) satisfies RundownPlaylistOperations, + [service, t] + ) + + return ( + {children} + ) +} + +export function useRundownPlaylistOperations(): RundownPlaylistOperations { + const context = useContext(RundownPlaylistOperationsContext) + + if (!context) + throw new Error('This component must be a child of a `RundownPlaylistOperationsContextProvider` component.') + + return context +} + +interface RundownTimesInfo { + shouldHaveStarted: boolean + willShortlyStart: boolean + shouldHaveEnded: boolean +} + +type EventLike = + | { + persist(): void + } + | {} + +export function checkRundownTimes(playlistTiming: RundownPlaylistTiming): RundownTimesInfo { + const currentTime = getCurrentTime() + + const shouldHaveEnded = + currentTime > + (PlaylistTiming.getExpectedStart(playlistTiming) || 0) + (PlaylistTiming.getExpectedDuration(playlistTiming) || 0) + + return { + shouldHaveStarted: currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0), + willShortlyStart: + !shouldHaveEnded && currentTime > (PlaylistTiming.getExpectedStart(playlistTiming) || 0) - REHEARSAL_MARGIN, + shouldHaveEnded, + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index 1cd767f05f..b0f50326dc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -25,7 +25,7 @@ import { doUserAction, UserAction } from '../../lib/clientUserAction.js' import { i18nTranslator as t } from '../i18n.js' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PeripheralDevicesAPI } from '../../lib/clientAPI.js' -import { handleRundownReloadResponse } from '../RundownView/RundownHeader_old/RundownReloadResponse.js' +import { handleRundownReloadResponse } from './RundownHeader/RundownReloadResponse.js' import { MeteorCall } from '../../lib/meteorApi.js' import { UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { isTranslatableMessage, translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' diff --git a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx index 552f54afeb..4dea007a6d 100644 --- a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx @@ -1,7 +1,7 @@ import React from 'react' import { RundownTimingProvider } from './RundownTiming/RundownTimingProvider' import StudioContext from './StudioContext' -import { RundownPlaylistOperationsContextProvider } from './RundownHeader_old/useRundownPlaylistOperations' +import { RundownPlaylistOperationsContextProvider } from './RundownHeader/useRundownPlaylistOperations.js' import { PreviewPopUpContextProvider } from '../PreviewPopUp/PreviewPopUpContext' import { SelectedElementProvider } from './SelectedElementsContext' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' From 1898fa3f260f9a814cff505b6b767812882b179c Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:46:31 +0100 Subject: [PATCH 35/57] Top bar: fix hamburger menu not closing in some cases --- .../RundownView/RundownHeader/RundownContextMenu.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index 1d2acbd09b..45bb25eedb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -162,7 +162,7 @@ export function RundownHamburgerButton({ const { t } = useTranslation() const buttonRef = useRef(null) - const handleClick = useCallback( + const handleToggle = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -181,14 +181,19 @@ export function RundownHamburgerButton({ }) } }, - [isOpen] + [isOpen, onClose] ) return ( ) } From 8f0d293353cfdad35009eca36e19eea4420f0237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 13:45:19 +0100 Subject: [PATCH 48/57] fix: Correct prefix for Start In when passing the planned start time --- .../ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index 923570f319..de7d132bce 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -29,7 +29,7 @@ export function RundownHeaderPlannedStart({ ) : ( - {diff >= 0 && '-'} + {diff >= 0 && '+'} {RundownUtils.formatDiffToTimecode(Math.abs(diff), false, false, true, true, true)} ))} From da457497bb97a7dd311a73577e228bc6070480ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 13:55:22 +0100 Subject: [PATCH 49/57] fix: more robust falsy checks that allow 0 values --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 2 +- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index 881d32f788..e949f45111 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -20,7 +20,7 @@ export function RundownHeaderDurations({ // Use remainingPlaylistDuration which includes current part's remaining time const estDuration = timingDurations.remainingPlaylistDuration - if (expectedDuration == null && estDuration == null) return null + if (expectedDuration == undefined && estDuration == undefined) return null return (
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index df93d376c6..52af4a76e7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -23,7 +23,7 @@ export function RundownHeaderExpectedEnd({ ? now + timingDurations.remainingPlaylistDuration : null - if (!expectedEnd && !estEnd) return null + if (expectedEnd === undefined && estEnd === null) return null return (
From e92f1e17abdcbdf3852340a528eea26c7e33c373 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 13 Mar 2026 14:11:31 +0100 Subject: [PATCH 50/57] chore: Tweaks to the menu wording and menu styling. Added visible menu dividers. --- .../webui/src/client/styles/contextMenu.scss | 19 ++++++++++++++---- .../RundownHeader/RundownContextMenu.tsx | 20 +++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/webui/src/client/styles/contextMenu.scss b/packages/webui/src/client/styles/contextMenu.scss index 40e4c97e67..4217ce8b37 100644 --- a/packages/webui/src/client/styles/contextMenu.scss +++ b/packages/webui/src/client/styles/contextMenu.scss @@ -3,7 +3,7 @@ nav.react-contextmenu { font-size: 1.0875rem; font-weight: 400; line-height: 1.5; - letter-spacing: 0.5px; + letter-spacing: -0.01em; z-index: 900; user-select: none; @@ -22,7 +22,6 @@ nav.react-contextmenu { .react-contextmenu-item, .react-contextmenu-label { margin: 0; - padding: 4px 13px 7px 13px; display: block; border: none; background: none; @@ -37,14 +36,16 @@ nav.react-contextmenu { .react-contextmenu-label { color: #49c0fb; background: #3e4041; + padding-left: 8px; cursor: default; } .react-contextmenu-item { + padding: 2px 13px 4px 13px; color: #494949; font-weight: 300; - padding-left: 25px; - padding-right: 25px; + padding-left: 18px; + padding-right: 30px; cursor: pointer; display: flex; @@ -59,6 +60,16 @@ nav.react-contextmenu { &.react-contextmenu-item--disabled { opacity: 0.5; + cursor: default; + } + + &.react-contextmenu-item--divider { + cursor: default; + padding: 0; + margin: 0 15px; + width: auto; + border-bottom: 1px solid #ddd; + height: 0; } > svg, diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index f5389a3b24..e7958a1fc7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -100,16 +100,21 @@ export function RundownContextMenu({ {t('Activate (Rehearsal)')} ) ) : ( - {t('Activate (On-Air)')} + {t('Activate On Air')} )} {rundownTimesInfo.willShortlyStart && !playlist.activationId && ( - {t('Activate (On-Air)')} + {t('Activate On Air')} )} - {playlist.activationId ? {t('Deactivate')} : null} + {playlist.activationId ? {t('Deactivate Studio')} : null} {studio.settings.allowAdlibTestingSegment && playlist.activationId ? ( {t('AdLib Testing')} ) : null} - {playlist.activationId ? {t('Take')} : null} + {playlist.activationId ? ( + <> + + {t('Take')} + + ) : null} {studio.settings.allowHold && playlist.activationId ? ( {t('Hold')} ) : null} @@ -117,7 +122,10 @@ export function RundownContextMenu({ {t('Clear QuickLoop')} ) : null} {!(playlist.activationId && !playlist.rehearsal && !studio.settings.allowRundownResetOnAir) ? ( - {t('Reset Rundown')} + <> + + {t('Reset Rundown')} + ) : null} {t('Reload {{nrcsName}} Data', { @@ -126,7 +134,7 @@ export function RundownContextMenu({ {t('Store Snapshot')} - history.push('/')}>{t('Close')} + history.push('/')}>{t('Close Rundown')} ) : ( From 6dc7d0ed5adb1465c162d0a74d55b0deea4edc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 13 Mar 2026 14:29:03 +0100 Subject: [PATCH 51/57] fix: swaps the timers in the simple view mode --- .../ui/RundownView/RundownHeader/RundownHeaderDurations.tsx | 4 ++-- .../ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx | 4 ++-- .../RundownView/RundownHeader/RundownHeaderPlannedStart.tsx | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx index e949f45111..b49e21f09b 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx @@ -24,12 +24,12 @@ export function RundownHeaderDurations({ return (
- {expectedDuration ? ( + {!simplified && expectedDuration ? ( {RundownUtils.formatDiffToTimecode(expectedDuration, false, true, true, true, true)} ) : null} - {!simplified && estDuration !== undefined ? ( + {estDuration !== undefined ? ( {RundownUtils.formatDiffToTimecode(estDuration, false, true, true, true, true)} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx index 52af4a76e7..4ce2497d83 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx @@ -27,10 +27,10 @@ export function RundownHeaderExpectedEnd({ return (
- {expectedEnd !== undefined ? ( + {!simplified && expectedEnd !== undefined ? ( ) : null} - {!simplified && estEnd !== null ? ( + {estEnd !== null ? ( ) : null}
diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx index de7d132bce..48002e0282 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderPlannedStart.tsx @@ -23,8 +23,10 @@ export function RundownHeaderPlannedStart({ return (
+ {!simplified && expectedStart !== undefined ? ( - {!simplified && + ) : null} + { (playlist.startedPlayback !== undefined ? ( ) : ( From e55a39c17d1696de4e84f56db767fd23930e444f Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Fri, 13 Mar 2026 14:35:51 +0100 Subject: [PATCH 52/57] chore: Made sure the menu icon only changed state and was clickable when the menu was initiated from the icon. --- .../RundownHeader/RundownContextMenu.tsx | 12 +++++++++--- .../RundownView/RundownHeader/RundownHeader.scss | 7 ++++++- .../RundownView/RundownHeader/RundownHeader.tsx | 15 ++++++++++++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx index e7958a1fc7..3ab85520cc 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownContextMenu.tsx @@ -169,8 +169,10 @@ export function RundownHeaderContextMenuTrigger({ children }: Readonly void }>): JSX.Element { + onOpen, +}: Readonly<{ isOpen?: boolean; disabled?: boolean; onClose: () => void; onOpen?: () => void }>): JSX.Element { const { t } = useTranslation() const buttonRef = useRef(null) @@ -179,6 +181,8 @@ export function RundownHamburgerButton({ e.preventDefault() e.stopPropagation() + if (disabled) return + if (isOpen) { hideMenu({ id: RUNDOWN_CONTEXT_MENU_ID }) onClose() @@ -191,15 +195,17 @@ export function RundownHamburgerButton({ position: { x: rect.left, y: rect.bottom + 5 }, id: RUNDOWN_CONTEXT_MENU_ID, }) + if (onOpen) onOpen() } }, - [isOpen, onClose] + [isOpen, disabled, onClose, onOpen] ) return ( - - - +
+ + + + +
-
+ ) From 0034eecd47920ccd35c4c3f59fc8ce8da8c3ab58 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Mon, 16 Mar 2026 09:07:23 +0100 Subject: [PATCH 56/57] chore: Added visual hover indication on the menu and close buttons. --- .../RundownHeader/RundownHeader.scss | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss index e011b2cbee..5765a54dfb 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.scss @@ -379,11 +379,36 @@ justify-content: center; height: 100%; font-size: 1.2em; - transition: color 0.2s; + transition: + color 0.2s; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover:not(:disabled):not(.disabled) { + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible:not(:disabled):not(.disabled) { + svg, + i { + filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); + } + } &:disabled, &.disabled { cursor: default; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } } } @@ -513,7 +538,27 @@ color: #40b8fa; opacity: 0; flex-shrink: 0; - transition: opacity 0.2s; + transition: + opacity 0.2s; + + svg, + i { + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + } + + &:hover { + svg, + i { + filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.65)); + } + } + + &:focus-visible { + svg, + i { + filter: drop-shadow(0 0 2.5px rgba(255, 255, 255, 0.75)); + } + } } &:hover { From 7e06a089a77b8afedc5f09e7d42eb216cf6ca0a5 Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Mon, 16 Mar 2026 15:43:37 +0100 Subject: [PATCH 57/57] chore: Added a Bootstrap CSS customization to get around the faulty size rendering of the entire GUI only when the URL parameter 'zoom' is set to '100' or no localStorage variable 'uiZoomLevel' exists, that occurs when the user has a local, non-default font size rendering. --- packages/webui/src/client/styles/bootstrap-customize.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webui/src/client/styles/bootstrap-customize.scss b/packages/webui/src/client/styles/bootstrap-customize.scss index 2c53e0bf9b..011f2e273d 100644 --- a/packages/webui/src/client/styles/bootstrap-customize.scss +++ b/packages/webui/src/client/styles/bootstrap-customize.scss @@ -6,6 +6,7 @@ } :root { + --bs-body-font-size: 16px; -webkit-font-smoothing: antialiased; --color-dark-1: #{$dark-1}; --color-dark-2: #{$dark-2};