mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 05:13:32 +00:00
Alpha 5 (#1741)
* refactor: align header columns * refactor: improve scrollbar visibility * refactor: center align table elements * refactor: make param elements stateful * fix: issue with collapsed elements not loosing value * fix: prevent search params containing multiple alias references * fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744) * refactor: disable group action for elements in groups * refactor: move context menu items into the event element (#1747) * feat: sheet import new features for v4 (#1730) * import milestone * fixup! import milestone * test: milestone import * add entries to group stop on new group or on group-end type * fixup! add entries to group * cleanup * add event target duration * link start if undefined * add skip import type * extract some to the excel paresing functions * tweaks to presentation * move file --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * fix: notify runtimeStore of events bieng groupd * fix: improve authentication and stage detection in demo * chore: ship logo with project * fix: client is referenced by name * fix: prevent reflow in event editor * fix: stale render on selected event due to ref mismatch * Create/Load/Delete multiple rundowns (#1696) * refactor: restore last loaded rundown * refactor: initialise rundown in ProjectService * feat: allow switching rundowns ensure on coordination between the db object and the working object server provide list of rundowns implement switch in the UI implement delete implement new rundown button * fix: render order for floating button * refactor: appropriate names to service * refactor: rundown endpoints * refactor: save last loaded rundown ID * refactor: rundown management UI * refactor: emit refetch all on project load --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * feat: recover single event subscription * fixup! feat: sheet import new features for v4 (#1730) * fix: prevent dropping a group inside another * fixup! refactor: move context menu items into the event element (#1747) * fix: prevent stale references to custom fields * fix: propagate updates to all rundowns * refactor: client rundown metadata (#1728) * generate metadata in the hook * move test * ensure there is always a last element * use for-loop * update metadata in useEfect * fully extract metadata generation * use direct assignment * cleanup --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * refactor: small imporvement and tests for coerce functions (#1752) * refactor: small imporvement and tests for coerce functions add test `coerceString` add test `coerceBoolean` add test `coerceColour` * remove old todo * fix: consistent quick add behaviour * refactor: create flat rundown with metadata * fix: show add buttons on top * feat: allow editing milestones * refactor: style tweaks to rundown elements refactor: milestones are full width refactor: cuesheet header alignment fix: editor styling in cuesheet * refactor: virtualise table * refactor: improve overscan (#1758) * bump version to 4.0.0-alpha.5 --------- Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
import {
|
||||
EndAction,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
LogOrigin,
|
||||
OffsetMode,
|
||||
OntimeEvent,
|
||||
Playback,
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
} from 'ontime-types';
|
||||
import { millisToString, validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import * as runtimeState from '../../stores/runtimeState.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||
import { timerConfig } from '../../setup/config.js';
|
||||
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||
|
||||
import { EventTimer } from '../EventTimer.js';
|
||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||
|
||||
import {
|
||||
findNextPlayableId,
|
||||
findNextPlayableWithCue,
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from './runtime.utils.js';
|
||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
|
||||
/**
|
||||
* Service manages runtime status of app
|
||||
* Coordinating with necessary services
|
||||
*/
|
||||
class RuntimeService {
|
||||
private readonly eventTimer: EventTimer;
|
||||
private lastIntegrationClockUpdate = -1;
|
||||
private lastIntegrationTimerValue = -1;
|
||||
|
||||
/** last known state */
|
||||
static previousState: RuntimeState;
|
||||
|
||||
constructor(eventTimer: EventTimer) {
|
||||
this.eventTimer = eventTimer;
|
||||
RuntimeService.previousState = {} as RuntimeState;
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
setOffsetMode(mode: OffsetMode) {
|
||||
runtimeState.setOffsetMode(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks result of an update and notifies integrations as needed
|
||||
* This is the only exception of a private method that has broadcast result
|
||||
* */
|
||||
@broadcastResult
|
||||
private checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
|
||||
const newState = runtimeState.getState();
|
||||
// 1. find if we need to dispatch integrations related to the phase
|
||||
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
|
||||
if (timerPhaseChanged) {
|
||||
if (newState.timer.phase === TimerPhase.Warning) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onWarning, newState);
|
||||
});
|
||||
} else if (newState.timer.phase === TimerPhase.Danger) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onDanger, newState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. handle edge cases related to roll
|
||||
if (newState.timer.playback === Playback.Roll) {
|
||||
// check if we need to call any side effects
|
||||
const keepOffset = newState.offset.absolute;
|
||||
if (hasSecondaryTimerFinished) {
|
||||
// if the secondary timer has finished, we need to call roll
|
||||
// since event is already loaded
|
||||
this.rollLoaded(keepOffset);
|
||||
} else if (hasTimerFinished) {
|
||||
// if the timer has finished, we need to load next and keep rolling
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
||||
});
|
||||
this.handleLoadNext();
|
||||
this.rollLoaded(keepOffset);
|
||||
} else if (
|
||||
// if there is no previous clock, we could not have skipped
|
||||
RuntimeService.previousState?.clock &&
|
||||
skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit)
|
||||
) {
|
||||
// if we have skipped out of the event, we will recall roll
|
||||
// to push the playback to the right place
|
||||
// this comes with the caveat that we will lose our runtime data
|
||||
logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll');
|
||||
this.roll(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. find if we need to process actions related to the timer finishing
|
||||
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
||||
});
|
||||
|
||||
// handle end action if there was a timer playing
|
||||
// actions are added to the queue stack to ensure that the order of operations is maintained
|
||||
if (newState.eventNow) {
|
||||
if (newState.eventNow.endAction === EndAction.LoadNext) {
|
||||
setTimeout(this.loadNext.bind(this), 0);
|
||||
} else if (newState.eventNow.endAction === EndAction.PlayNext) {
|
||||
setTimeout(this.startNext.bind(this), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. find if we need to update the timer
|
||||
const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
if (shouldUpdateTimer) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onUpdate, newState);
|
||||
});
|
||||
|
||||
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
|
||||
}
|
||||
|
||||
// 5. find if we need to update the clock
|
||||
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
||||
if (shouldUpdateClock) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onClock, newState);
|
||||
});
|
||||
|
||||
this.lastIntegrationClockUpdate = newState.clock;
|
||||
}
|
||||
}
|
||||
|
||||
/** delay initialisation until we have a restore point */
|
||||
public init(resumable: RestorePoint | null) {
|
||||
logger.info(LogOrigin.Server, 'Runtime service started');
|
||||
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
|
||||
|
||||
if (resumable) {
|
||||
this.resume(resumable);
|
||||
}
|
||||
}
|
||||
|
||||
public shutdown() {
|
||||
if (this.eventTimer) {
|
||||
logger.info(LogOrigin.Server, 'Runtime service shutting down');
|
||||
this.eventTimer.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the underlying data has changed,
|
||||
* we check if the change affects the runtime
|
||||
*/
|
||||
public notifyOfChangedEvents(rundownMetadata: RundownMetadata) {
|
||||
const state = runtimeState.getState();
|
||||
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
|
||||
if (!hasLoadedElements) {
|
||||
return;
|
||||
}
|
||||
|
||||
// all events were deleted
|
||||
if (rundownMetadata.playableEventOrder.length === 0) {
|
||||
runtimeState.stop();
|
||||
}
|
||||
|
||||
const rundown = getCurrentRundown();
|
||||
const metadata = getRundownMetadata();
|
||||
runtimeState.updateAll(rundown, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* makes calls for loading and starting given event
|
||||
* @param {PlayableEvent} event
|
||||
* @param {Partial<TimerState & RestorePoint>} initialData
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
private loadEvent(event: OntimeEvent, initialData?: Partial<TimerState & RestorePoint>): boolean {
|
||||
if (!isPlayableEvent(event)) {
|
||||
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
|
||||
return false;
|
||||
}
|
||||
const previousState = runtimeState.getState();
|
||||
|
||||
// we can ignore events which are not playable
|
||||
const rundown = getCurrentRundown();
|
||||
const metadata = getRundownMetadata();
|
||||
const success = runtimeState.load(event, rundown, metadata, initialData);
|
||||
|
||||
if (success) {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
const newState = runtimeState.getState();
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
public getRuntimeState() {
|
||||
return { playback: runtimeState.getState().timer.playback };
|
||||
}
|
||||
|
||||
/**
|
||||
* starts event matching given ID
|
||||
* @param {string} eventId
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startById(eventId: string): boolean {
|
||||
const event = getEntryWithId(eventId);
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
const loaded = this.loadEvent(event);
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* starts an event at index
|
||||
* @param {number} eventIndex
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startByIndex(eventIndex: number): boolean {
|
||||
const rundown = getCurrentRundown();
|
||||
const { timedEventOrder } = getRundownMetadata();
|
||||
|
||||
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
|
||||
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loaded = this.loadEvent(event);
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* starts first event matching given cue
|
||||
* @param {string} cue
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startByCue(cue: string): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const rundown = getCurrentRundown();
|
||||
const { playableEventOrder } = getRundownMetadata();
|
||||
|
||||
const event = findNextPlayableWithCue(
|
||||
rundown,
|
||||
playableEventOrder,
|
||||
cue,
|
||||
state.rundown.selectedEventIndex ?? undefined,
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loaded = this.loadEvent(event);
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* loads event matching given ID
|
||||
* @param {string} eventId
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadById(eventId: string): boolean {
|
||||
const event = getEntryWithId(eventId);
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
return this.loadEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* loads event matching given ID
|
||||
* @param {number} eventIndex
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadByIndex(eventIndex: number): boolean {
|
||||
const rundown = getCurrentRundown();
|
||||
const { timedEventOrder } = getRundownMetadata();
|
||||
|
||||
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
|
||||
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
return this.loadEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* loads first event matching given cue
|
||||
* @param {string} cue
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadByCue(cue: string): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const rundown = getCurrentRundown();
|
||||
const { playableEventOrder } = getRundownMetadata();
|
||||
|
||||
const event = findNextPlayableWithCue(
|
||||
rundown,
|
||||
playableEventOrder,
|
||||
cue,
|
||||
state.rundown.selectedEventIndex ?? undefined,
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.loadEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for loading the previous event
|
||||
*
|
||||
* we need to isolate handleLoadPrevious so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
*/
|
||||
private handleLoadPrevious(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const { playableEventOrder } = getRundownMetadata();
|
||||
|
||||
const previousId = findPreviousPlayableId(playableEventOrder, state.eventNow?.id);
|
||||
if (previousId) {
|
||||
const previousEvent = getEntryWithId(previousId);
|
||||
if (previousEvent && isOntimeEvent(previousEvent)) {
|
||||
return this.loadEvent(previousEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads event before currently selected
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadPrevious(): boolean {
|
||||
return this.handleLoadPrevious();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for loading the next event
|
||||
*
|
||||
* we need to isolate handleLoadNext so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
* and pass on runtime offset in case of roll mode
|
||||
*/
|
||||
private handleLoadNext(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const { playableEventOrder } = getRundownMetadata();
|
||||
|
||||
const nextId = findNextPlayableId(playableEventOrder, state.eventNow?.id);
|
||||
if (nextId) {
|
||||
const nextEvent = getEntryWithId(nextId);
|
||||
if (!nextEvent || !isOntimeEvent(nextEvent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.timer.playback === Playback.Roll) {
|
||||
return this.loadEvent(nextEvent, { firstStart: state.rundown.actualStart });
|
||||
}
|
||||
return this.loadEvent(nextEvent);
|
||||
}
|
||||
|
||||
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Loads event after currently selected
|
||||
* @return {boolean} success
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadNext(): boolean {
|
||||
return this.handleLoadNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for starting selected event
|
||||
*
|
||||
* we need to isolate handleStart so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
*/
|
||||
private handleStart(): boolean {
|
||||
const previousState = runtimeState.getState();
|
||||
const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start;
|
||||
if (!canStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const didStart = this.eventTimer?.start() ?? false;
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
|
||||
if (didStart) {
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
||||
});
|
||||
}
|
||||
return didStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on selected event
|
||||
*/
|
||||
@broadcastResult
|
||||
public start(): boolean {
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on previous event
|
||||
*/
|
||||
@broadcastResult
|
||||
public startPrevious(): boolean {
|
||||
const hasPrevious = this.handleLoadPrevious();
|
||||
if (!hasPrevious) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on next event
|
||||
*/
|
||||
@broadcastResult
|
||||
public startNext(): boolean {
|
||||
const hasNext = this.handleLoadNext();
|
||||
if (!hasNext) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback on selected event
|
||||
*/
|
||||
@broadcastResult
|
||||
public pause() {
|
||||
const state = runtimeState.getState();
|
||||
const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause;
|
||||
if (!canPause) {
|
||||
return;
|
||||
}
|
||||
this.eventTimer?.pause();
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onPause, newState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops timer and unloads any events
|
||||
*/
|
||||
@broadcastResult
|
||||
public stop(): boolean {
|
||||
const previousState = runtimeState.getState();
|
||||
const canStop = validatePlayback(previousState.timer.playback, previousState.timer.phase).stop;
|
||||
if (!canStop) {
|
||||
return false;
|
||||
}
|
||||
const didStop = this.eventTimer?.stop();
|
||||
if (didStop) {
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onStop, newState);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads current event
|
||||
*/
|
||||
@broadcastResult
|
||||
public reload() {
|
||||
const state = runtimeState.getState();
|
||||
if (state.eventNow) {
|
||||
return this.loadEvent(state.eventNow);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles special case to call roll on a loaded event which we do not want to discard
|
||||
*/
|
||||
private rollLoaded(offset?: number) {
|
||||
const rundown = getCurrentRundown();
|
||||
const metadata = getRundownMetadata();
|
||||
|
||||
try {
|
||||
runtimeState.roll(rundown, metadata, offset);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets playback to roll
|
||||
*/
|
||||
@broadcastResult
|
||||
public roll(skipCheck: boolean = false) {
|
||||
const previousState = runtimeState.getState();
|
||||
if (!skipCheck) {
|
||||
const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll;
|
||||
if (!canRoll) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rundown = getCurrentRundown();
|
||||
const metadata = getRundownMetadata();
|
||||
const result = runtimeState.roll(rundown, metadata);
|
||||
|
||||
const newState = runtimeState.getState();
|
||||
if (result.eventId !== previousState.eventNow?.id) {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.didStart) {
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = runtimeState.getState();
|
||||
|
||||
if (previousState.timer.playback !== newState.timer.playback) {
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description resume playback state given a restore point
|
||||
* @param restorePoint
|
||||
*/
|
||||
@broadcastResult
|
||||
public resume(restorePoint: RestorePoint) {
|
||||
const { selectedEventId, playback } = restorePoint;
|
||||
if (playback === Playback.Roll) {
|
||||
this.roll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the db would have to change for the event not to exist
|
||||
// we do not know the reason for the crash, so we check anyway
|
||||
const event = getEntryWithId(selectedEventId);
|
||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rundown = getCurrentRundown();
|
||||
const metadata = getRundownMetadata();
|
||||
runtimeState.resume(restorePoint, event, rundown, metadata);
|
||||
|
||||
logger.info(LogOrigin.Playback, 'Resuming playback');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds time to current event
|
||||
* @param {number} time - time to add in milliseconds
|
||||
*/
|
||||
@broadcastResult
|
||||
public addTime(time: number) {
|
||||
if (this.eventTimer.addTime(time)) {
|
||||
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
const eventTimer = new EventTimer({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
* This should only be applied to functions that are exposed for consumption
|
||||
* ie: whenever an external service makes a request, we update the state with the mutation result
|
||||
*/
|
||||
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
// call the original method and get the state
|
||||
const result = originalMethod.apply(this, args);
|
||||
const state = runtimeState.getState();
|
||||
const batch = eventStore.createBatch();
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// Update the entry if they have changed
|
||||
let entryChanged = false;
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !RuntimeService.previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
|
||||
|
||||
// clock has changed by a second or more
|
||||
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
batch.add('clock', state.clock);
|
||||
RuntimeService.previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
// if any values have changed, values that have the possibility to tick are updated when the seconds roll over
|
||||
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
batch.add('timer', state.timer);
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate`
|
||||
const updateRuntime = getShouldOffsetUpdate(
|
||||
RuntimeService.previousState?.offset,
|
||||
state.offset,
|
||||
updateClock || hasImmediateChanges,
|
||||
);
|
||||
if (updateRuntime) {
|
||||
batch.add('offset', state.offset);
|
||||
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
// if any values have changed
|
||||
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
batch.add('rundown', state.rundown);
|
||||
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<
|
||||
K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
|
||||
>(key: K) {
|
||||
const previousEntry = RuntimeService.previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||
|
||||
// if they have the same id the check if the contents have changed
|
||||
if (previousEntry?.id === currentEntry?.id) {
|
||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
// save the restore state
|
||||
if (hasImmediateChanges) {
|
||||
restoreService
|
||||
.save({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
startedAt: state.timer.startedAt,
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
firstStart: state.rundown.actualStart,
|
||||
})
|
||||
.catch((_e) => {
|
||||
//we don't do anything with the error here
|
||||
});
|
||||
}
|
||||
|
||||
batch.send();
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
Reference in New Issue
Block a user