diff --git a/apps/client/src/common/models/ViewSettings.type.ts b/apps/client/src/common/models/ViewSettings.type.ts index c04318548..0f1016a50 100644 --- a/apps/client/src/common/models/ViewSettings.type.ts +++ b/apps/client/src/common/models/ViewSettings.type.ts @@ -1,9 +1,10 @@ import { ViewSettings } from 'ontime-types'; export const viewsSettingsPlaceholder: ViewSettings = { - overrideStyles: false, - normalColor: '#ffffffcc', - warningColor: '#FFAB33', dangerColor: '#ED3333', endMessage: '', + freezeEnd: false, + normalColor: '#ffffffcc', + overrideStyles: false, + warningColor: '#FFAB33', }; diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 5554948c0..bcb0cc997 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -1,7 +1,7 @@ .corner { position: absolute; top: 1rem; - right: 1rem; + right: 2rem; z-index: 100; } diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index f4fb860e5..3542f23d4 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -118,7 +118,17 @@ export default function ViewSettingsForm() { - + + + + + ; -export function getTimerByType(timerObject?: TimerTypeParams): number | null { +export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null { if (!timerObject) { return null; } @@ -12,7 +12,10 @@ export function getTimerByType(timerObject?: TimerTypeParams): number | null { switch (timerObject.timerType) { case TimerType.CountDown: case TimerType.TimeToEnd: - return timerObject.current; + if (timerObject.current === null) { + return null; + } + return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current; case TimerType.CountUp: return Math.abs(timerObject.elapsed ?? 0); case TimerType.Clock: diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index 92fdf93c5..a5f307b56 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types'; -import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; +import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; import { overrideStylesURL } from '../../../common/api/constants'; import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu'; @@ -150,7 +150,7 @@ export default function MinimalTimer(props: MinimalTimerProps) { if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor; if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor; - const stageTimer = getTimerByType(time); + const stageTimer = getTimerByType(viewSettings.freezeEnd, time); let display = millisToString(stageTimer, { fallback: timerPlaceholder }); if (stageTimer !== null) { if (hideTimerSeconds) { @@ -158,7 +158,8 @@ export default function MinimalTimer(props: MinimalTimerProps) { } display = removeLeadingZero(display); // last unit rounds up in negative timers - const isNegative = (stageTimer ?? 0 < 0) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp; + const isNegative = + (stageTimer ?? 0 < MILLIS_PER_SECOND) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp; if (isNegative && display === '0') { display = '-1'; } diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index cf7c45cbe..192f82c2e 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -11,7 +11,7 @@ import { TimerType, ViewSettings, } from 'ontime-types'; -import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; +import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; import { overrideStylesURL } from '../../../common/api/constants'; import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; @@ -59,6 +59,7 @@ interface TimerProps { export default function Timer(props: TimerProps) { const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { getLocalizedString } = useTranslation(); const [searchParams] = useSearchParams(); @@ -127,7 +128,7 @@ export default function Timer(props: TimerProps) { if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor; if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor; - const stageTimer = getTimerByType(time); + const stageTimer = getTimerByType(viewSettings.freezeEnd, time); let display = millisToString(stageTimer, { fallback: timerPlaceholder }); if (stageTimer !== null) { if (hideTimerSeconds) { @@ -135,7 +136,8 @@ export default function Timer(props: TimerProps) { } display = removeLeadingZero(display); // last unit rounds up in negative timers - const isNegative = (stageTimer ?? 0 < 0) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp; + const isNegative = + (stageTimer ?? 0 < -MILLIS_PER_SECOND) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp; if (isNegative && display === '0') { display = '-1'; } @@ -215,7 +217,7 @@ export default function Timer(props: TimerProps) { {!userOptions.hideCards && ( <> - {eventNow && ( + {eventNow?.title && ( - {eventNext && ( + {eventNext?.title && ( { }, viewSettings: { overrideStyles: false, + freezeEnd: false, endMessage: 'existing endMessage', normalColor: '#ffffffcc', warningColor: '#FFAB33', diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts index f4a7be87e..f6d85900c 100644 --- a/apps/server/src/config/config.ts +++ b/apps/server/src/config/config.ts @@ -2,4 +2,5 @@ export const timerConfig = { skipLimit: 1000, // threshold of skip for recalculating updateRate: 32, // how often do we update the timer notificationRate: 1000, // how often do we notify clients and integrations + triggerAhead: 16, // how far ahead do we trigger the end event }; diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 8a665e1bd..11fd0071d 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -25,6 +25,7 @@ export const dbModel: DatabaseModel = { normalColor: '#ffffffcc', warningColor: '#FFAB33', dangerColor: '#ED3333', + freezeEnd: false, endMessage: '', }, urlPresets: [], diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 1197a9823..0b108eac0 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -48,7 +48,7 @@ export class TimerService { this.onUpdateCallback = timerConfig.onUpdateCallback; this._interval = setInterval(() => { this.update(); - }, TimerService._updateInterval); + }, TimerService._refreshInterval); } @broadcastResult @@ -58,7 +58,8 @@ export class TimerService { } const state = runtimeState.getState(); - this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish); + const endTime = state.timer.current - 10; + this.endCallback = setTimeout(() => this.update(), endTime); return true; } @@ -107,7 +108,6 @@ export class TimerService { @broadcastResult update() { const updateResult = runtimeState.update(); - // pass the result to the parent this.onUpdateCallback(updateResult); } @@ -143,6 +143,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert // some changes need an immediate update const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id; + const hasSkippedBack = state.clock < TimerService.previousUpdate; const justStarted = !TimerService.previousState?.timer; const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback; @@ -175,7 +176,27 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert // Helper function to update an event if it has changed function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) { + const previous = TimerService.previousState?.[eventKey]; + const now = state[eventKey]; + + // if there was nothing, and there is nothing, noop + if (!previous?.id && !now?.id) { + return; + } + + // if load status changed, save new + if (previous?.id !== now?.id) { + storeKey(eventKey); + return; + } + + // maybe the event itself has changed if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) { + storeKey(eventKey); + return; + } + + function storeKey(eventKey: keyof RuntimeStore) { eventStore.set(eventKey, state[eventKey]); TimerService.previousState[eventKey] = { ...state[eventKey] }; } diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 69c9fb7f3..6ec0f6b46 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1219,7 +1219,7 @@ describe('updateRoll()', () => { clock: 11, timer: { current: 10, - expectedFinish: 15, + expectedFinish: 100, secondaryTimer: null, startedAt: 1, }, @@ -1229,7 +1229,7 @@ describe('updateRoll()', () => { } as RuntimeState; const expected = { - updatedTimer: 15 - 11, + updatedTimer: 100 - 11, updatedSecondaryTimer: null, // usually clock - expectedFinish doRollLoad: false, isFinished: false, diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 677619131..528044a25 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -100,8 +100,8 @@ export async function deleteAllEvents() { // notify event loader that rundown has changed updateRuntimeOnChange(); - // no need to modify timer since we will reset - notifyChanges({ external: true }); + // notify timer and external services of change + notifyChanges({ timer: true, external: true }); } /** @@ -213,10 +213,15 @@ function updateRuntimeOnChange() { export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) { if (options.timer) { const playableEvents = getPlayableEvents(); - // notify timer service of changed events - // timer can be true or an array of changed IDs - const affected = Array.isArray(options.timer) ? options.timer : undefined; - runtimeService.maybeUpdate(playableEvents, affected); + + if (playableEvents.length === 0) { + runtimeService.stop(); + } else { + // notify timer service of changed events + // timer can be true or an array of changed IDs + const affected = Array.isArray(options.timer) ? options.timer : undefined; + runtimeService.maybeUpdate(playableEvents, affected); + } } if (options.external) { diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 0e0a41f00..febe909cb 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -70,7 +70,7 @@ class RuntimeService { this.eventTimer = new TimerService({ refresh: timerConfig.updateRate, updateInterval: timerConfig.notificationRate, - onUpdateCallback: () => this.checkTimerUpdate, + onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult), }); if (resumable) { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 177000e32..4c0c3764a 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,6 +1,7 @@ import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types'; import { dayInMs, sortArrayByProperty } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; +import { timerConfig } from '../config/config.js'; /** * handle events that span over midnight @@ -272,7 +273,7 @@ export const updateRoll = (state: RuntimeState) => { updatedTimer -= dayInMs; } - if (updatedTimer < 0) { + if (updatedTimer <= timerConfig.triggerAhead) { isPrimaryFinished = true; // we need a new event doRollLoad = true; diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 8a050227a..9a16c28df 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -407,7 +407,8 @@ export function update(): UpdateResult { function onPlayUpdate() { let isFinished = false; runtimeState.timer.current = getCurrent(runtimeState); - const finishedNow = runtimeState.timer.current <= 0 && runtimeState.timer.finishedAt === null; + const finishedNow = + runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null; if (runtimeState.timer.playback === Playback.Play && finishedNow) { runtimeState.timer.finishedAt = runtimeState.clock; diff --git a/packages/types/src/definitions/EndAction.type.ts b/packages/types/src/definitions/EndAction.type.ts index 36e63d799..7db64a69b 100644 --- a/packages/types/src/definitions/EndAction.type.ts +++ b/packages/types/src/definitions/EndAction.type.ts @@ -1,6 +1,6 @@ export enum EndAction { - None = 'none', - Stop = 'stop', LoadNext = 'load-next', + None = 'none', PlayNext = 'play-next', + Stop = 'stop', } diff --git a/packages/types/src/definitions/core/Views.type.ts b/packages/types/src/definitions/core/Views.type.ts index 732e64e16..98cffec0f 100644 --- a/packages/types/src/definitions/core/Views.type.ts +++ b/packages/types/src/definitions/core/Views.type.ts @@ -1,7 +1,8 @@ export type ViewSettings = { - overrideStyles: boolean; - endMessage: string; - normalColor: string; - warningColor: string; dangerColor: string; + endMessage: string; + freezeEnd: boolean; + normalColor: string; + overrideStyles: boolean; + warningColor: string; };