mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 00:13:53 +00:00
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
|
|
import { millisToString, validatePlayback } from 'ontime-utils';
|
|
|
|
import { TimerService } from '../TimerService.js';
|
|
import { logger } from '../../classes/Logger.js';
|
|
import { RestorePoint } from '../RestoreService.js';
|
|
|
|
import * as runtimeState from '../../stores/runtimeState.js';
|
|
|
|
import {
|
|
findNext,
|
|
findPrevious,
|
|
getEventAtIndex,
|
|
getEventWithCue,
|
|
getEventWithId,
|
|
getPlayableEvents,
|
|
} from '../rundown-service/rundownUtils.js';
|
|
import { integrationService } from '../integration-service/IntegrationService.js';
|
|
import { timerConfig } from '../../config/config.js';
|
|
|
|
/**
|
|
* Service manages runtime status of app
|
|
* Coordinating with necessary services
|
|
*/
|
|
class RuntimeService {
|
|
private eventTimer: TimerService | null = null;
|
|
private lastOnUpdate = -1;
|
|
|
|
/** Checks result of an update and notifies integrations as needed */
|
|
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
|
|
const newState = runtimeState.getState();
|
|
|
|
if (hasTimerFinished) {
|
|
integrationService.dispatch(TimerLifeCycle.onFinish);
|
|
|
|
// 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.timer.playback === Playback.Play && newState.eventNow) {
|
|
if (newState.eventNow.endAction === EndAction.Stop) {
|
|
setTimeout(this.stop, 0);
|
|
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
|
|
setTimeout(this.loadNext, 0);
|
|
} else if (newState.eventNow.endAction === EndAction.PlayNext) {
|
|
setTimeout(this.startNext, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// update normal cycle
|
|
if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) {
|
|
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
|
|
if (hasRunningTimer) {
|
|
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
|
}
|
|
|
|
integrationService.dispatch(TimerLifeCycle.onClock);
|
|
this.lastOnUpdate = newState.clock;
|
|
}
|
|
|
|
if (shouldCallRoll) {
|
|
// we dont call this.roll because we need to bypass the checks
|
|
const rundown = getPlayableEvents();
|
|
this.eventTimer.roll(rundown);
|
|
}
|
|
}
|
|
|
|
/** delay initialisation until we have a restore point */
|
|
init(resumable: RestorePoint | null) {
|
|
logger.info(LogOrigin.Server, 'Runtime service started');
|
|
// calculate at 30fps, refresh at 1fps
|
|
this.eventTimer = new TimerService({
|
|
refresh: timerConfig.updateRate,
|
|
updateInterval: timerConfig.notificationRate,
|
|
onUpdateCallback: this.checkTimerUpdate.bind(this),
|
|
});
|
|
|
|
if (resumable) {
|
|
this.resume(resumable);
|
|
}
|
|
}
|
|
|
|
shutdown() {
|
|
if (this.eventTimer) {
|
|
logger.info(LogOrigin.Server, 'Runtime service shutting down');
|
|
this.eventTimer.shutdown();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a list of IDs is in the current selection
|
|
*/
|
|
private affectsLoaded(affectedIds: string[]): boolean {
|
|
const state = runtimeState.getState();
|
|
const now = state.eventNow?.id;
|
|
const nowPublic = state.publicEventNow?.id;
|
|
const next = state.eventNext?.id;
|
|
const nextPublic = state.publicEventNext?.id;
|
|
return (
|
|
affectedIds.includes(now) ||
|
|
affectedIds.includes(nowPublic) ||
|
|
affectedIds.includes(next) ||
|
|
affectedIds.includes(nextPublic)
|
|
);
|
|
}
|
|
|
|
private isNewNext() {
|
|
const timedEvents = getPlayableEvents();
|
|
const state = runtimeState.getState();
|
|
const now = state.eventNow?.id;
|
|
const next = state.eventNext?.id;
|
|
|
|
// check whether the index of now and next are consecutive
|
|
const indexNow = timedEvents.findIndex((event) => event.id === now);
|
|
const indexNext = timedEvents.findIndex((event) => event.id === next);
|
|
|
|
if (indexNext - indexNow !== 1) {
|
|
return true;
|
|
}
|
|
// iterate through timed events and see if there are public events between nowPublic and nextPublic
|
|
const nowPublic = state.publicEventNow?.id;
|
|
const nextPublic = state.publicEventNext?.id;
|
|
|
|
let foundNew = false;
|
|
let isAfter = false;
|
|
for (const event of timedEvents) {
|
|
if (!isAfter) {
|
|
if (event.id === nowPublic) {
|
|
isAfter = true;
|
|
}
|
|
} else {
|
|
if (event.id === nextPublic) {
|
|
break;
|
|
}
|
|
if (event.isPublic) {
|
|
foundNew = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return foundNew;
|
|
}
|
|
|
|
/**
|
|
* Called when the underlying data has changed,
|
|
* we check if the change affects the runtime
|
|
*/
|
|
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
|
|
const state = runtimeState.getState();
|
|
const hasLoadedElements = state.eventNow || state.eventNext;
|
|
if (!hasLoadedElements) {
|
|
return;
|
|
}
|
|
|
|
// we need to reload in a few scenarios:
|
|
// 1. we are not confident that changes do not affect running event
|
|
const safeOption = typeof affectedIds === 'undefined';
|
|
// 2. the edited event is in memory (now or next) running
|
|
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
|
|
// 3. the edited event replaces next event
|
|
let isNext = false;
|
|
|
|
if (safeOption || eventInMemory) {
|
|
if (state.timer.playback === Playback.Roll) {
|
|
this.roll();
|
|
}
|
|
// load stuff again, but keep running if our events still exist
|
|
const eventNow = getEventWithId(state.eventNow.id);
|
|
if (eventNow) {
|
|
runtimeState.reload(eventNow);
|
|
}
|
|
return;
|
|
}
|
|
|
|
isNext = this.isNewNext();
|
|
if (isNext) {
|
|
// TODO: do i need to load here?
|
|
runtimeState.loadNext(playableEvents);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* makes calls for loading and starting given event
|
|
* @param {OntimeEvent} event
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
loadEvent(event: OntimeEvent): boolean {
|
|
if (event.skip) {
|
|
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
|
|
return false;
|
|
}
|
|
|
|
const timedEvents = getPlayableEvents();
|
|
const success = runtimeState.load(event, timedEvents);
|
|
|
|
if (success) {
|
|
integrationService.dispatch(TimerLifeCycle.onLoad);
|
|
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
|
}
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* starts event matching given ID
|
|
* @param {string} eventId
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
startById(eventId: string): boolean {
|
|
const event = getEventWithId(eventId);
|
|
if (!event) {
|
|
return false;
|
|
}
|
|
const success = this.loadEvent(event);
|
|
if (success) {
|
|
this.start();
|
|
}
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* starts an event at index
|
|
* @param {number} eventIndex
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
startByIndex(eventIndex: number): boolean {
|
|
const event = getEventAtIndex(eventIndex);
|
|
if (!event) {
|
|
return false;
|
|
}
|
|
const success = this.loadEvent(event);
|
|
if (success) {
|
|
this.start();
|
|
}
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* starts first event matching given cue
|
|
* @param {string} cue
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
startByCue(cue: string): boolean {
|
|
const event = getEventWithCue(cue);
|
|
if (!event) {
|
|
return false;
|
|
}
|
|
const success = this.loadEvent(event);
|
|
if (success) {
|
|
this.start();
|
|
}
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* loads event matching given ID
|
|
* @param {string} eventId
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
loadById(eventId: string): boolean {
|
|
const event = getEventWithId(eventId);
|
|
if (!event) {
|
|
return false;
|
|
}
|
|
return this.loadEvent(event);
|
|
}
|
|
|
|
/**
|
|
* loads event matching given ID
|
|
* @param {number} eventIndex
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
loadByIndex(eventIndex: number): boolean {
|
|
const event = getEventAtIndex(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
|
|
*/
|
|
loadByCue(cue: string): boolean {
|
|
const event = getEventWithCue(cue);
|
|
if (!event) {
|
|
return false;
|
|
}
|
|
return this.loadEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Loads event before currently selected
|
|
* @return {boolean} success - whether an event was loaded
|
|
*/
|
|
loadPrevious(): boolean {
|
|
const state = runtimeState.getState();
|
|
const previousEvent = findPrevious(state.eventNow?.id);
|
|
if (previousEvent) {
|
|
return this.loadEvent(previousEvent);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Loads event after currently selected
|
|
* @return {boolean} success
|
|
*/
|
|
loadNext(): boolean {
|
|
const state = runtimeState.getState();
|
|
const nextEvent = findNext(state.eventNow?.id);
|
|
if (nextEvent) {
|
|
return this.loadEvent(nextEvent);
|
|
}
|
|
|
|
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Starts playback on selected event
|
|
*/
|
|
start() {
|
|
const state = runtimeState.getState();
|
|
const canStart = validatePlayback(state.timer.playback).start;
|
|
if (!canStart) {
|
|
return false;
|
|
}
|
|
|
|
const didStart = this.eventTimer.start();
|
|
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
|
|
if (didStart) {
|
|
integrationService.dispatch(TimerLifeCycle.onStart);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts playback on next event
|
|
*/
|
|
startNext() {
|
|
const hasNext = this.loadNext();
|
|
if (!hasNext) {
|
|
return;
|
|
}
|
|
|
|
this.start();
|
|
}
|
|
|
|
/**
|
|
* Pauses playback on selected event
|
|
*/
|
|
pause() {
|
|
const state = runtimeState.getState();
|
|
const canPause = validatePlayback(state.timer.playback).pause;
|
|
if (!canPause) {
|
|
return;
|
|
}
|
|
this.eventTimer.pause();
|
|
const newState = state.timer.playback;
|
|
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
|
integrationService.dispatch(TimerLifeCycle.onPause);
|
|
}
|
|
|
|
/**
|
|
* Stops timer and unloads any events
|
|
*/
|
|
stop() {
|
|
const state = runtimeState.getState();
|
|
const canStop = validatePlayback(state.timer.playback).stop;
|
|
if (!canStop) {
|
|
return;
|
|
}
|
|
this.eventTimer.stop();
|
|
const newState = state.timer.playback;
|
|
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
|
integrationService.dispatch(TimerLifeCycle.onStop);
|
|
}
|
|
|
|
/**
|
|
* Reloads current event
|
|
*/
|
|
reload() {
|
|
const state = runtimeState.getState();
|
|
if (state.eventNow) {
|
|
runtimeState.reload();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets playback to roll
|
|
*/
|
|
roll() {
|
|
const beforeState = runtimeState.getState();
|
|
const canRoll = validatePlayback(beforeState.timer.playback).roll;
|
|
if (!canRoll) {
|
|
return;
|
|
}
|
|
|
|
const playableEvents = getPlayableEvents();
|
|
if (playableEvents.length === 0) {
|
|
logger.warning(LogOrigin.Server, 'Roll: no events found');
|
|
return;
|
|
}
|
|
|
|
this.eventTimer.roll(playableEvents);
|
|
|
|
const state = runtimeState.getState();
|
|
const newState = state.timer.playback;
|
|
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
|
}
|
|
|
|
/**
|
|
* @description resume playback state given a restore point
|
|
* @param restorePoint
|
|
*/
|
|
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 kow the reason for the crash, so we check anyway
|
|
const event = getEventWithId(selectedEventId);
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
const timedEvents = getPlayableEvents();
|
|
runtimeState.resume(restorePoint, event, timedEvents);
|
|
logger.info(LogOrigin.Playback, 'Resuming playback');
|
|
}
|
|
|
|
/**
|
|
* Adds time to current event
|
|
* @param {number} time - time to add in milliseconds
|
|
*/
|
|
addTime(time: number) {
|
|
if (this.eventTimer.addTime(time)) {
|
|
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const runtimeService = new RuntimeService();
|