diff --git a/apps/client/package.json b/apps/client/package.json index a53838cd9..ac345170f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -29,7 +29,7 @@ "react-router-dom": "^6.3.0", "typeface-open-sans": "^1.1.13", "web-vitals": "^3.1.1", - "zustand": "^4.4.7" + "zustand": "^4.5.0" }, "scripts": { "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", diff --git a/apps/client/src/common/components/view-params-editor/constants.ts b/apps/client/src/common/components/view-params-editor/constants.ts index 97d9449f5..68bedc485 100644 --- a/apps/client/src/common/components/view-params-editor/constants.ts +++ b/apps/client/src/common/components/view-params-editor/constants.ts @@ -247,7 +247,7 @@ export const LOWER_THIRD_OPTIONS: ParamField[] = [ title: 'Title', subtitle: 'Subtitle', presenter: 'Presenter', - lowerMsg: 'Lower Thrid Message', + lowerMsg: 'Lower Third Message', }, defaultValue: 'title', }, @@ -260,7 +260,7 @@ export const LOWER_THIRD_OPTIONS: ParamField[] = [ title: 'Title', subtitle: 'Subtitle', presenter: 'Presenter', - lowerMsg: 'Lower Thrid Message', + lowerMsg: 'Lower Third Message', }, defaultValue: 'subtitle', }, diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index a9e1cf9e8..6b1ad4a3b 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -68,3 +68,14 @@ export const runtimeStore = createWithEqualityFn( export const useRuntimeStore = (selector: (state: RuntimeStore) => T) => useStoreWithEqualityFn(runtimeStore, selector, deepCompare); + +/** + * Allows patching a property of the runtime store + * @param key + * @param value + */ +export function patchRuntime(key: K, value: RuntimeStore[K]): void { + const state = runtimeStore.getState(); + state[key] = value; + runtimeStore.setState({ ...state }); +} diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index b32018021..a7aeee43f 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -4,7 +4,7 @@ import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants'; import { ontimeQueryClient } from '../queryClient'; import { socketClientName } from '../stores/connectionName'; import { addLog } from '../stores/logger'; -import { runtimeStore } from '../stores/runtime'; +import { patchRuntime, runtimeStore } from '../stores/runtime'; export let websocket: WebSocket | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; @@ -12,6 +12,7 @@ const reconnectInterval = 1000; export let shouldReconnect = true; export let hasConnected = false; export let reconnectAttempts = 0; + export const connectSocket = (preferredClientName?: string) => { websocket = new WebSocket(websocketUrl); @@ -52,7 +53,6 @@ export const connectSocket = (preferredClientName?: string) => { return; } - // TODO: implement partial store updates switch (type) { case 'client-name': { socketClientName.getState().setName(payload); @@ -69,34 +69,54 @@ export const connectSocket = (preferredClientName?: string) => { } break; } - case 'ontime-playback': { - const state = runtimeStore.getState(); - state.timer.playback = payload; - runtimeStore.setState(state); + case 'ontime-clock': { + patchRuntime('clock', payload); + updateDevTools({ clock: payload }); break; } case 'ontime-timer': { - const state = runtimeStore.getState(); - state.timer = payload; - runtimeStore.setState(state); - break; - } - case 'ontime-runtime': { - const state = runtimeStore.getState(); - state.runtime = payload; - runtimeStore.setState(state); - break; - } - case 'ontime-message': { - const state = runtimeStore.getState(); - state.message = payload; - runtimeStore.setState(state); + patchRuntime('timer', payload); + updateDevTools({ timer: payload }); break; } case 'ontime-onAir': { - const state = runtimeStore.getState(); - state.onAir = payload; - runtimeStore.setState(state); + patchRuntime('onAir', payload); + updateDevTools({ onAir: payload }); + break; + } + case 'ontime-message': { + patchRuntime('message', payload); + updateDevTools({ message: payload }); + break; + } + case 'ontime-runtime': { + patchRuntime('runtime', payload); + updateDevTools({ runtime: payload }); + break; + } + case 'ontime-eventNow': { + patchRuntime('eventNow', payload); + updateDevTools({ eventNow: payload }); + break; + } + case 'ontime-publicEventNow': { + patchRuntime('publicEventNow', payload); + updateDevTools({ publicEventNow: payload }); + break; + } + case 'ontime-eventNext': { + patchRuntime('eventNext', payload); + updateDevTools({ eventNext: payload }); + break; + } + case 'ontime-publicEventNext': { + patchRuntime('publicEventNext', payload); + updateDevTools({ publicEventNext: payload }); + break; + } + case 'ontime-timer1': { + patchRuntime('timer1', payload); + updateDevTools({ timer1: payload }); break; } } @@ -125,3 +145,12 @@ export const socketSendJson = (type: string, payload?: unknown) => { }), ); }; + +function updateDevTools(newData: Partial) { + if (!isProduction) { + ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({ + ...oldData, + ...newData, + })); + } +} diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts b/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts index fdd65fe4b..5a8c2388d 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts +++ b/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts @@ -1,8 +1,17 @@ -export const cycles = [ +import { TimerLifeCycle } from 'ontime-types'; + +type CycleLabel = { + id: number; + label: string; + value: keyof typeof TimerLifeCycle; +}; + +export const cycles: CycleLabel[] = [ { id: 1, label: 'On Load', value: 'onLoad' }, { id: 2, label: 'On Start', value: 'onStart' }, { id: 3, label: 'On Pause', value: 'onPause' }, { id: 4, label: 'On Stop', value: 'onStop' }, - { id: 5, label: 'Every second', value: 'onUpdate' }, + { id: 5, label: 'Every second', value: 'onClock' }, + { id: 5, label: 'On Timer Update', value: 'onUpdate' }, { id: 6, label: 'On Finish', value: 'onFinish' }, ]; diff --git a/apps/server/package.json b/apps/server/package.json index dc8d8d5d3..03042bcaf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "express-session": "^1.17.3", "express-static-gzip": "^2.1.7", "express-validator": "^6.14.2", + "fast-equals": "^5.0.1", "google-auth-library": "^9.4.2", "got": "^14.0.0", "lowdb": "^7.0.1", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 16e10809f..7aef7db95 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -43,7 +43,8 @@ import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; import { populateDemo } from './modules/loadDemo.js'; import { getState, updateRundownData } from './stores/runtimeState.js'; -import { setRundown, getPlayableEvents } from './services/rundown-service/RundownService.js'; +import { setRundown } from './services/rundown-service/RundownService.js'; +import { getPlayableEvents } from './services/rundown-service/rundownUtils.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -284,11 +285,13 @@ export const shutdown = async (exitCode = 0) => { process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`)); process.on('unhandledRejection', async (error) => { + console.error('Error: unhandled rejection', error); logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`); await shutdown(1); }); process.on('uncaughtException', async (error) => { + console.error('Error: uncaught exception', error); logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`); await shutdown(1); }); diff --git a/apps/server/src/config/config.js b/apps/server/src/config/config.ts similarity index 64% rename from apps/server/src/config/config.js rename to apps/server/src/config/config.ts index 9a137c8f4..41f1c9225 100644 --- a/apps/server/src/config/config.js +++ b/apps/server/src/config/config.ts @@ -16,5 +16,7 @@ export const config = { }; export const timerConfig = { - timeSkipLimit: 1000, + 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 }; diff --git a/apps/server/src/controllers/integrationController.config.ts b/apps/server/src/controllers/integrationController.config.ts index b3fbdc0f8..4b6540b18 100644 --- a/apps/server/src/controllers/integrationController.config.ts +++ b/apps/server/src/controllers/integrationController.config.ts @@ -1,5 +1,6 @@ import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types'; -import { editEvent, getEventWithId } from '../services/rundown-service/RundownService.js'; +import { editEvent } from '../services/rundown-service/RundownService.js'; +import { getEventWithId } from '../services/rundown-service/rundownUtils.js'; import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js'; const whitelistedPayload = { @@ -48,21 +49,23 @@ export function updateEvent( newValue: OntimeEvent[typeof propertyName], ) { const event = getEventWithId(eventId); - if (event) { - if (!isOntimeEvent(event)) { - throw new Error('Can only update events'); - } - const propertiesToUpdate = { [propertyName]: newValue }; - - // Handles the special case for duration - // needs to be converted to milliseconds - if (propertyName === 'duration') { - propertiesToUpdate.duration = (newValue as number) * 1000; - propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration; - } - - editEvent({ id: eventId, ...propertiesToUpdate }); - } else { + if (!event) { throw new Error(`Event with ID ${eventId} not found`); } + + if (!isOntimeEvent(event)) { + throw new Error('Can only update events'); + } + + const propertiesToUpdate = { [propertyName]: newValue }; + + // Handles the special case for duration + // needs to be converted to milliseconds + if (propertyName === 'duration') { + propertiesToUpdate.duration = (newValue as number) * 1000; + propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration; + } + + const newEvent = editEvent({ id: eventId, ...propertiesToUpdate }); + return newEvent; } diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index f46d288dd..8dcaeab12 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -185,9 +185,10 @@ const actionHandlers: Record = { } } } - throw new Error('Invalid extratimer payload'); + throw new Error('Invalid extra-timer payload'); }, }; + /** * Returns a value of type number, converting if necessary * Otherwise throws diff --git a/apps/server/src/controllers/rundownController.ts b/apps/server/src/controllers/rundownController.ts index f0706fe7c..cbf0e0fcc 100644 --- a/apps/server/src/controllers/rundownController.ts +++ b/apps/server/src/controllers/rundownController.ts @@ -10,11 +10,10 @@ import { deleteAllEvents, deleteEvent, editEvent, - getRundown, reorderEvent, swapEvents, } from '../services/rundown-service/RundownService.js'; -import { get as getCachedRundown } from '../services/rundown-service/rundownCache.js'; +import { getNormalisedRundown, getRundown } from '../services/rundown-service/rundownUtils.js'; // Create controller for GET request to '/events' // Returns - @@ -26,7 +25,7 @@ export const rundownGetAll: RequestHandler = async (_req, res) => { // Create controller for GET request to '/events/cached' // Returns - export const rundownGetCached: RequestHandler = async (_req: Request, res: Response) => { - const cachedRundown = getCachedRundown(); + const cachedRundown = getNormalisedRundown(); res.json(cachedRundown); }; diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 2be17f3dd..43fb96a13 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,52 +1,88 @@ -import { EndAction, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types'; +import { OntimeEvent, RuntimeStore } from 'ontime-types'; + +import { deepEqual } from 'fast-equals'; -import * as runtimeState from '../stores/runtimeState.js'; -import { integrationService } from './integration-service/IntegrationService.js'; import { eventStore } from '../stores/EventStore.js'; +import * as runtimeState from '../stores/runtimeState.js'; +import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js'; + import { restoreService } from './RestoreService.js'; -import { runtimeService } from './runtime-service/RuntimeService.js'; -import { getPlayableEvents } from './rundown-service/RundownService.js'; /** * Service manages Ontime's main timer + * It is responsible for streaming the data to the event store */ export class TimerService { - private _interval: NodeJS.Timer; - private _updateInterval: number; - private _refreshInterval: number; + private readonly _interval: NodeJS.Timer; + /** how often we update the socket */ + static _updateInterval: number; + /** how often we recalculate */ + static _refreshInterval: number; + /** last time we updated the socket */ + static previousUpdate: number; + /** last known state */ + static previousState: RuntimeState; + + /** when timer will be finished */ + private endCallback: NodeJS.Timer; + + private onUpdateCallback: (updateResult: UpdateResult) => void; /** * @constructor - * @param {number} [timerConfig.refresh] - * @param {number} [timerConfig.updateInterval] + * @param {number} [timerConfig.refresh] how often we recalculate + * @param {number} [timerConfig.updateInterval] how often we update the socket + * @param {function} [timerConfig.onUpdateCallback] how often we update the socket */ - constructor(timerConfig: { refresh: number; updateInterval: number }) { - this._refreshInterval = timerConfig.refresh; - this._updateInterval = timerConfig.updateInterval; - this._interval = setInterval(this.update, 32); + constructor(timerConfig: { + refresh: number; + updateInterval: number; + onUpdateCallback: (updateResult: UpdateResult) => void; + }) { + TimerService.previousUpdate = -1; + TimerService.previousState = {} as RuntimeState; + + TimerService._updateInterval = timerConfig.updateInterval; + TimerService._refreshInterval = timerConfig.refresh; + + this.onUpdateCallback = timerConfig.onUpdateCallback; + this._interval = setInterval(() => { + this.update(); + this.onUpdateCallback; + }, TimerService._updateInterval); } @broadcastResult start() { - // TODO: when we start a timer, we schedule an update to its expected end - 16ms - // we need to cancel this timer on pause, stop and addTime - if (runtimeState.start()) { - integrationService.dispatch(TimerLifeCycle.onStart); + if (!runtimeState.start()) { + return false; } + + const state = runtimeState.getState(); + this.endCallback = setTimeout(this.update, state.timer.expectedFinish); + return true; } @broadcastResult pause() { - if (runtimeState.pause()) { - integrationService.dispatch(TimerLifeCycle.onPause); + if (!runtimeState.pause()) { + return false; } + + // cancel end callback + clearTimeout(this.endCallback); + return true; } @broadcastResult stop() { - if (runtimeState.stop()) { - integrationService.dispatch(TimerLifeCycle.onStop); + if (!runtimeState.stop()) { + return false; } + + // cancel end callback + clearTimeout(this.endCallback); + return true; } /** @@ -55,62 +91,40 @@ export class TimerService { */ @broadcastResult addTime(amount: number): boolean { - return runtimeState.addTime(amount); + if (!runtimeState.addTime(amount)) { + return false; + } + + // renew end callback + clearTimeout(this.endCallback); + const state = runtimeState.getState(); + this.endCallback = setTimeout(this.update, state.timer.expectedFinish); + return true; } /** * Update the app at regular intervals - * @param {boolean} force whether we should force a broadcast of state */ @broadcastResult - update(force = false) { - const { didUpdate, doRoll, isFinished, shouldNotify } = runtimeState.update(force, this._updateInterval); - if (didUpdate && shouldNotify) { - // TODO: can we distinguish between a clock update and a timer update? - integrationService.dispatch(TimerLifeCycle.onUpdate); - } + update() { + const updateResult = runtimeState.update(); - if (doRoll) { - // TODO: escalate to parent - const rundown = getPlayableEvents(); - runtimeState.roll(rundown); - } - - if (isFinished) { - integrationService.dispatch(TimerLifeCycle.onFinish); - const newState = runtimeState.getState(); - - // handle end action if there was a timer playing - if (newState.timer.playback === Playback.Play && newState.eventNow) { - if (newState.eventNow.endAction === EndAction.Stop) { - runtimeState.stop(); - } else if (newState.eventNow.endAction === EndAction.LoadNext) { - // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly - setTimeout(runtimeState.loadNext, 0); - } else if (newState.eventNow.endAction === EndAction.PlayNext) { - // TODO: avoid calling the runtime service here - runtimeService.startNext(); - } - } - } + // pass the result to the parent + this.onUpdateCallback(updateResult); } /** * Loads roll information into timer service - * @throws {Error} if rundown is empty * @param {OntimeEvent[]} rundown -- list of events to run */ @broadcastResult roll(rundown: OntimeEvent[]) { - if (rundown.length === 0) { - throw new Error('No events found'); - } - runtimeState.roll(rundown); } shutdown() { clearInterval(this._interval); + clearTimeout(this.endCallback); } } @@ -118,29 +132,64 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert 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(); - // TODO: compare datasets to see what needs to be emitted - eventStore.batchSet({ - clock: state.clock, - eventNow: state.eventNow, - publicEventNow: state.publicEventNow, - eventNext: state.eventNext, - publicEventNext: state.publicEventNext, - runtime: state.runtime, - timer: state.timer, - }); + // we do the comparison by explicitly for each property + // to apply custom logic for different datasets + + // some of the data, we only update at intervals + const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval; + + // some changes need an immediate update + const hasSkippedBack = state.clock < TimerService.previousUpdate; + const justStarted = !TimerService.previousState?.timer; + const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback; + const hasImmediateChanges = hasSkippedBack || justStarted || hasChangedPlayback; + + if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) { + eventStore.set('timer', state.timer); + TimerService.previousState.timer = { ...state.timer }; + } + + if (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime)) { + eventStore.set('runtime', state.runtime); + TimerService.previousState.runtime = { ...state.runtime }; + } + + // Update the events if they have changed + updateEventIfChanged('eventNow', state); + updateEventIfChanged('publicEventNow', state); + updateEventIfChanged('eventNext', state); + updateEventIfChanged('publicEventNext', state); + + if (isTimeToUpdate) { + TimerService.previousUpdate = state.clock; + eventStore.set('clock', state.clock); + saveRestoreState(state); + } + + // Helper function to update an event if it has changed + function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) { + if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) { + eventStore.set(eventKey, state[eventKey]); + TimerService.previousState[eventKey] = { ...state[eventKey] }; + } + } + + // Helper function to save the restore state + function saveRestoreState(state: RuntimeState) { + 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.runtime.actualStart, + }); + } - // we write to restore service if the underlying data changes - 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.runtime.actualStart, - }); return result; }; diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 9b8fb59bc..c508ecb3d 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -3,11 +3,11 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, - OntimeRundown, OntimeRundownEntry, isOntimeBlock, isOntimeDelay, isOntimeEvent, + OntimeRundown, } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; @@ -19,6 +19,7 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; +import { getPlayableEvents } from './rundownUtils.js'; function generateEvent(eventData: Partial | Partial | Partial) { // we discard any UI provided events and add our own @@ -44,7 +45,9 @@ function generateEvent(eventData: Partial | Partial | * @param {object} eventData * @return {OntimeRundownEntry} */ -export async function addEvent(eventData: Partial | Partial | Partial) { +export async function addEvent( + eventData: Partial | Partial | Partial, +): Promise { // if the user didnt provide an index, we add the event to start let atIndex = 0; if (eventData?.after !== undefined) { @@ -65,7 +68,7 @@ export async function addEvent(eventData: Partial | Partial | Partial} */ export async function deleteEvent(eventId: string) { const scopedMutation = cache.mutateCache(cache.remove); @@ -82,12 +84,11 @@ export async function deleteEvent(eventId: string) { notifyChanges({ timer: [eventId], external: true }); // notify event loader that rundown size has changed - updateChangeNumEvents(); + updateRuntimeOnChange(); } /** * deletes all events in database - * @returns {Promise} */ export async function deleteAllEvents() { const scopedMutation = cache.mutateCache(cache.removeAll); @@ -97,6 +98,10 @@ export async function deleteAllEvents() { notifyChanges({ external: true }); } +/** + * Apply patch to an element in rundown + * @param patch + */ export async function editEvent(patch: Partial | Partial | Partial) { if (isOntimeEvent(patch) && patch?.cue === '') { throw new Error('Cue value invalid'); @@ -111,6 +116,11 @@ export async function editEvent(patch: Partial | Partial) { const scopedMutation = cache.mutateCache(cache.batchEdit); await scopedMutation({ patch: data, eventIds: ids }); @@ -123,7 +133,6 @@ export async function batchEditEvents(ids: string[], data: Partial) * @param {string} eventId - ID of event from, for sanity check * @param {number} from - index of event from * @param {number} to - index of event to - * @returns {Promise} */ export async function reorderEvent(eventId: string, from: number, to: number) { const scopedMutation = cache.mutateCache(cache.reorder); @@ -158,7 +167,7 @@ export async function swapEvents(from: string, to: string) { * Forces update in the store * Called when we make changes to the rundown object */ -function updateChangeNumEvents() { +function updateRuntimeOnChange() { updateRundownData(getPlayableEvents()); } @@ -167,12 +176,13 @@ function updateChangeNumEvents() { */ 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 if (Array.isArray(options.timer)) { - runtimeService.update(options.timer); + runtimeService.maybeUpdate(playableEvents, options.timer); } - runtimeService.update(); + runtimeService.maybeUpdate(playableEvents); } if (options.external) { @@ -181,115 +191,11 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?: } } -/** - * returns entire unfiltered rundown - * @return {array} - */ -export function getRundown(): OntimeRundown { - return cache.getPersistedRundown(); -} - -/** - * returns all events of type OntimeEvent - * @return {array} - */ -export function getTimedEvents(): OntimeEvent[] { - return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[]; -} - -/** - * returns all events that can be loaded - * @return {array} - */ -export function getPlayableEvents(): OntimeEvent[] { - return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; -} - -/** - * returns number of events that can be loaded - * @return {number} - */ -export function getNumEvents(): number { - return getPlayableEvents().length; -} - -/** - * returns an event given its index after filtering for OntimeEvents - * @param {number} eventIndex - * @return {OntimeEvent | undefined} - */ -export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { - const timedEvents = getTimedEvents(); - return timedEvents.at(eventIndex); -} - -/** - * returns first event that matches a given ID - * @param {string} eventId - * @return {object | undefined} - */ -export function getEventWithId(eventId: string): OntimeEvent | undefined { - const timedEvents = getTimedEvents(); - return timedEvents.find((event) => event.id === eventId); -} - -/** - * returns first event that matches a given cue - * @param {string} cue - * @return {object | undefined} - */ -export function getEventWithCue(cue: string): OntimeEvent | undefined { - const timedEvents = getTimedEvents(); - return timedEvents.find((event) => event.cue.toLowerCase() === cue.toLowerCase()); -} - -/** - * finds the previous event - * @return {object | undefined} - */ -export function findPrevious(currentEventId?: string): OntimeEvent | null { - const timedEvents = getPlayableEvents(); - if (!timedEvents || !timedEvents.length) { - return null; - } - - // if there is no event running, go to first - if (!currentEventId) { - return timedEvents.at(0) ?? null; - } - - const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); - const newIndex = Math.max(currentIndex - 1, 0); - const previousEvent = timedEvents.at(newIndex) ?? null; - return previousEvent; -} - -/** - * finds the next event - * @return {object | undefined} - */ -export function findNext(currentEventId?: string): OntimeEvent | null { - const timedEvents = getPlayableEvents(); - if (!timedEvents || !timedEvents.length) { - return null; - } - - // if there is no event running, go to first - if (!currentEventId) { - return timedEvents.at(0) ?? null; - } - - const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); - const newIndex = (currentIndex + 1) % timedEvents.length; - const nextEvent = timedEvents.at(newIndex); - return nextEvent ?? null; -} - /** * Overrides the rundown with the given * @param rundown */ export async function setRundown(rundown: OntimeRundown) { - cache.init(rundown); + await cache.init(rundown); notifyChanges({ timer: true }); } diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts new file mode 100644 index 000000000..9c9e39b4c --- /dev/null +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -0,0 +1,111 @@ +import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached } from 'ontime-types'; + +import * as cache from './rundownCache.js'; + +export function getNormalisedRundown(): RundownCached { + return cache.get(); +} + +/** + * returns entire unfiltered rundown + * @return {array} + */ +export function getRundown(): OntimeRundown { + return cache.getPersistedRundown(); +} + +/** + * returns all events of type OntimeEvent + * @return {array} + */ +export function getTimedEvents(): OntimeEvent[] { + return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[]; +} + +/** + * returns all events that can be loaded + * @return {array} + */ +export function getPlayableEvents(): OntimeEvent[] { + return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; +} + +/** + * returns number of events that can be loaded + * @return {number} + */ +export function getNumEvents(): number { + return getPlayableEvents().length; +} + +/** + * returns an event given its index after filtering for OntimeEvents + * @param {number} eventIndex + * @return {OntimeEvent | undefined} + */ +export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { + const timedEvents = getTimedEvents(); + return timedEvents.at(eventIndex); +} + +/** + * returns first event that matches a given ID + * @param {string} eventId + * @return {object | undefined} + */ +export function getEventWithId(eventId: string): OntimeEvent | undefined { + const timedEvents = getTimedEvents(); + return timedEvents.find((event) => event.id === eventId); +} + +/** + * returns first event that matches a given cue + * @param {string} cue + * @return {object | undefined} + */ +export function getEventWithCue(cue: string): OntimeEvent | undefined { + const timedEvents = getTimedEvents(); + return timedEvents.find((event) => event.cue.toLowerCase() === cue.toLowerCase()); +} + +/** + * finds the previous event + * @return {object | undefined} + */ +export function findPrevious(currentEventId?: string): OntimeEvent | null { + const timedEvents = getPlayableEvents(); + if (!timedEvents || !timedEvents.length) { + return null; + } + + // if there is no event running, go to first + if (!currentEventId) { + return timedEvents.at(0) ?? null; + } + + const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const newIndex = Math.max(currentIndex - 1, 0); + const previousEvent = timedEvents.at(newIndex) ?? null; + return previousEvent; +} + +/** + * finds the next event + * @return {object | undefined} + */ +export function findNext(currentEventId?: string): OntimeEvent | null { + const timedEvents = getPlayableEvents(); + if (!timedEvents || !timedEvents.length) { + return null; + } + + // if there is no event running, go to first + if (!currentEventId) { + return timedEvents.at(0) ?? null; + } + + const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const newIndex = (currentIndex + 1) % timedEvents.length; + const nextEvent = timedEvents.at(newIndex); + return nextEvent ?? null; +} diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 9eb2681a5..6f07d8599 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -1,10 +1,12 @@ -import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; +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, @@ -12,7 +14,9 @@ import { getEventWithCue, getEventWithId, getPlayableEvents, -} from '../rundown-service/RundownService.js'; +} from '../rundown-service/rundownUtils.js'; +import { integrationService } from '../integration-service/IntegrationService.js'; +import { timerConfig } from '../../config/config.js'; /** * Service manages runtime status of app @@ -20,12 +24,55 @@ import { */ 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'); - // TODO: refresh at 32ms, slowing down now to keep UI responsive while we dont have granular updates // calculate at 30fps, refresh at 1fps - this.eventTimer = new TimerService({ refresh: 1000, updateInterval: 1000 }); + this.eventTimer = new TimerService({ + refresh: timerConfig.updateRate, + updateInterval: timerConfig.notificationRate, + onUpdateCallback: this.checkTimerUpdate.bind(this), + }); if (resumable) { this.resume(resumable); @@ -94,14 +141,11 @@ class RuntimeService { return foundNew; } - reset() { - runtimeState.clear(); - } - /** - * check whether underlying data of runtime has changed + * Called when the underlying data has changed, + * we check if the change affects the runtime */ - update(affectedIds?: string[]) { + maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) { const state = runtimeState.getState(); const hasLoadedElements = state.eventNow || state.eventNext; if (!hasLoadedElements) { @@ -131,7 +175,6 @@ class RuntimeService { isNext = this.isNewNext(); if (isNext) { // TODO: do i need to load here? - const playableEvents = getPlayableEvents(); runtimeState.loadNext(playableEvents); } } @@ -154,6 +197,7 @@ class RuntimeService { const success = event.id === state.eventNow?.id; if (success) { + integrationService.dispatch(TimerLifeCycle.onLoad); logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); } return success; @@ -220,8 +264,7 @@ class RuntimeService { if (!event) { return false; } - const success = this.loadEvent(event); - return success; + return this.loadEvent(event); } /** @@ -234,8 +277,7 @@ class RuntimeService { if (!event) { return false; } - const success = this.loadEvent(event); - return success; + return this.loadEvent(event); } /** @@ -248,8 +290,7 @@ class RuntimeService { if (!event) { return false; } - const success = this.loadEvent(event); - return success; + return this.loadEvent(event); } /** @@ -260,8 +301,7 @@ class RuntimeService { const state = runtimeState.getState(); const previousEvent = findPrevious(state.eventNow?.id); if (previousEvent) { - const success = this.loadEvent(previousEvent); - return success; + return this.loadEvent(previousEvent); } return false; } @@ -274,8 +314,7 @@ class RuntimeService { const state = runtimeState.getState(); const nextEvent = findNext(state.eventNow?.id); if (nextEvent) { - const success = this.loadEvent(nextEvent); - return success; + return this.loadEvent(nextEvent); } logger.info(LogOrigin.Playback, 'No next event found! Continuing playback'); @@ -288,9 +327,14 @@ class RuntimeService { start() { const state = runtimeState.getState(); const canStart = validatePlayback(state.timer.playback).start; - if (canStart) { - this.eventTimer.start(); - logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`); + 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); } } @@ -299,9 +343,11 @@ class RuntimeService { */ startNext() { const hasNext = this.loadNext(); - if (hasNext) { - this.start(); + if (!hasNext) { + return; } + + this.start(); } /** @@ -309,11 +355,14 @@ class RuntimeService { */ pause() { const state = runtimeState.getState(); - if (validatePlayback(state.timer.playback).pause) { - this.eventTimer.pause(); - const newState = state.timer.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); + 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); } /** @@ -321,11 +370,14 @@ class RuntimeService { */ stop() { const state = runtimeState.getState(); - if (validatePlayback(state.timer.playback).stop) { - this.eventTimer.stop(); - const newState = state.timer.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); + 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); } /** @@ -342,13 +394,20 @@ class RuntimeService { * Sets playback to roll */ roll() { - const playableEvents = getPlayableEvents(); - try { - this.eventTimer.roll(playableEvents); - } catch (error) { - logger.warning(LogOrigin.Server, `Roll: ${error}`); + 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()}`); diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index ae8a5dff1..f60d62854 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -22,14 +22,12 @@ export const eventStore = { }, set(key: T, value: RuntimeStore[T]) { store[key] = value; - // TODO: Partial updates seems to cause issues on the client - // socket.send({ - // type: `ontime-${key}`, - // payload: value, - // }); - this.broadcast(); + socket.sendAsJson({ + type: `ontime-${key}`, + payload: value, + }); }, - batchSet(values: Record) { + batchSet(values: Partial) { Object.entries(values).forEach(([key, value]) => { store[key] = value; }); diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 651dc2001..636a90853 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -35,7 +35,6 @@ const mockState = { }, _timer: { pausedAt: null, - lastUpdate: null, secondaryTarget: null, }, } as RuntimeState; diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 4cd93f9d5..2e9e17d2a 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,9 +1,9 @@ -import { Runtime, OntimeEvent, Playback, TimerState, TimerType, MaybeNumber } from 'ontime-types'; +import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types'; import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; -import { getPlayableEvents } from '../services/rundown-service/RundownService.js'; + import { getCurrent, getExpectedFinish, @@ -48,7 +48,6 @@ export type RuntimeState = { _timer: { pausedAt: MaybeNumber; finishedNow: boolean; - lastUpdate: MaybeNumber; secondaryTarget: MaybeNumber; }; }; @@ -63,7 +62,6 @@ const runtimeState: RuntimeState = { timer: { ...initialTimer }, _timer: { pausedAt: null, - lastUpdate: null, secondaryTarget: null, get finishedNow() { return this.current <= 0 && this.finishedAt === null; @@ -81,16 +79,18 @@ export function clear() { runtimeState.eventNext = null; runtimeState.publicEventNext = null; - runtimeState.runtime = { ...initialRuntime, actualStart: runtimeState.runtime.actualStart }; - // TODO: can we cleanup the initialisation of runtime state? - runtimeState.runtime.numEvents = fetchNumEvents(); + runtimeState.runtime = { + ...initialRuntime, + // persist session stuff + actualStart: runtimeState.runtime.actualStart, + numEvents: runtimeState.runtime.numEvents, + }; runtimeState.timer.playback = Playback.Stop; runtimeState.clock = clock.timeNow(); runtimeState.timer = { ...initialTimer }; runtimeState._timer = { pausedAt: null, - lastUpdate: null, secondaryTarget: null, finishedNow: false, }; @@ -108,18 +108,9 @@ function patchTimer(newState: Partial) { } } -/** - * Utility, getches number of events from EventLoader - * @param numEvents - */ -function fetchNumEvents(): number { - // TODO: could we avoid having this dependency? - return getPlayableEvents().length; -} - /** * Utility, allows updating data derived from the rundown - * @param numEvents + * @param playableRundown */ export function updateRundownData(playableRundown: OntimeEvent[]) { runtimeState.runtime.numEvents = playableRundown.length; @@ -342,61 +333,43 @@ export function addTime(amount: number) { return true; } -export function update(force: boolean, updateInterval: number) { - // TODO: should this logic be moved to consumer? - // force indicates whether the state change should be broadcast to socket - let _force = force; - let _didUpdate = false; - let _doRoll = false; - let _isFinished = false; - let _shouldNotify = false; +export type UpdateResult = { + hasTimerFinished: boolean; + shouldCallRoll: boolean; +}; + +export function update(): UpdateResult { + let hasTimerFinished = false; + let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished const previousTime = runtimeState.clock; runtimeState.clock = clock.timeNow(); - const hasSkippedBack = previousTime > runtimeState.clock; - - if (hasSkippedBack) { - _force = true; - } // update offset runtimeState.runtime.offset = getRuntimeOffset(runtimeState); // we call integrations if we update timers if (runtimeState.timer.playback === Playback.Roll) { - const result = roll(); - _shouldNotify = true; - _doRoll = result.doRoll; - _isFinished = result.isFinished; + const result = onRollUpdate(); + shouldCallRoll = result.doRoll; + hasTimerFinished = result.isFinished; } else if (runtimeState.timer.startedAt !== null) { // we only update timer if a timer has been started - const result = play(); - _shouldNotify = true; - _isFinished = result.isFinished; + const result = onPlayUpdate(); + hasTimerFinished = result.isFinished; } else if (runtimeState.eventNow?.timerType === TimerType.TimeToEnd) { // or if we are in a time-to-end timer runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.duration = runtimeState.timer.current; } - // we only update the store at the updateInterval - // side effects such as onFinish will still be triggered in the update functions - const isTimeToUpdate = runtimeState.clock > runtimeState._timer.lastUpdate + updateInterval; - if (_force || isTimeToUpdate) { - runtimeState._timer.lastUpdate = runtimeState.clock; - // TODO: can we simplify the didUpdate and shouldNotify - _didUpdate = true; - } - return { - didUpdate: _didUpdate, - doRoll: _doRoll, - isFinished: _isFinished, - shouldNotify: _shouldNotify, + hasTimerFinished, + shouldCallRoll, }; - function roll() { - const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.timeSkipLimit); + function onRollUpdate() { + const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit); if (hasSkippedOutOfEvent) { return { doRoll: true }; } @@ -408,7 +381,7 @@ export function update(force: boolean, updateInterval: number) { return { doRoll: doRollLoad, isFinished }; } - function play() { + function onPlayUpdate() { let isFinished = false; runtimeState.timer.current = getCurrent(runtimeState); diff --git a/apps/server/src/utils/assert.ts b/apps/server/src/utils/assert.ts index 2dedcbc82..ee9e525ad 100644 --- a/apps/server/src/utils/assert.ts +++ b/apps/server/src/utils/assert.ts @@ -1,6 +1,6 @@ export function isString(value: unknown): asserts value is string { if (typeof value !== 'string') { - throw new Error(`Unexpected payload type: ${value}`); + throw new Error(`Unexpected payload type: ${String(value)}`); } } @@ -12,12 +12,12 @@ export function isDefined(value: T | undefined): asserts value is T { export function isNumber(value: unknown): asserts value is number { if (typeof value !== 'string') { - throw new Error(`Unexpected payload type: ${value}`); + throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isObject(value: unknown): asserts value is object { if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(`Unexpected payload type: ${value}`); + throw new Error(`Unexpected payload type: ${String(value)}`); } } diff --git a/apps/server/src/utils/sheetsAuth.ts b/apps/server/src/utils/sheetsAuth.ts index a283e0068..e34457421 100644 --- a/apps/server/src/utils/sheetsAuth.ts +++ b/apps/server/src/utils/sheetsAuth.ts @@ -15,7 +15,7 @@ import { ensureDirectory } from './fileManagement.js'; import { cellRequestFromEvent, getA1Notation } from './sheetUtils.js'; import { parseExcel } from './parser.js'; import { parseRundown, parseUserFields } from './parserFunctions.js'; -import { getRundown } from '../services/rundown-service/RundownService.js'; +import { getRundown } from '../services/rundown-service/rundownUtils.js'; type ResponseOK = { data: Partial; diff --git a/packages/types/src/definitions/core/TimerLifecycle.type.ts b/packages/types/src/definitions/core/TimerLifecycle.type.ts index 98dc68e3c..d7ab012db 100644 --- a/packages/types/src/definitions/core/TimerLifecycle.type.ts +++ b/packages/types/src/definitions/core/TimerLifecycle.type.ts @@ -3,6 +3,7 @@ export enum TimerLifeCycle { onStart = 'onStart', onPause = 'onPause', onStop = 'onStop', + onClock = 'onClock', onUpdate = 'onUpdate', onFinish = 'onFinish', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9cdc5e26..302cb7952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,8 +126,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 zustand: - specifier: ^4.4.7 - version: 4.4.7(@types/react@18.0.26)(react@18.2.0) + specifier: ^4.5.0 + version: 4.5.0(@types/react@18.0.26)(react@18.2.0) devDependencies: '@sentry/vite-plugin': specifier: ^2.14.0 @@ -143,7 +143,7 @@ importers: version: 13.4.0(react-dom@18.2.0)(react@18.2.0) '@testing-library/user-event': specifier: ^14.1.1 - version: 14.4.3(@testing-library/dom@9.3.3) + version: 14.4.3(@testing-library/dom@9.3.4) '@types/color': specifier: ^3.0.3 version: 3.0.3 @@ -267,6 +267,9 @@ importers: express-validator: specifier: ^6.14.2 version: 6.14.2 + fast-equals: + specifier: ^5.0.1 + version: 5.0.1 google-auth-library: specifier: ^9.4.2 version: 9.4.2 @@ -694,8 +697,8 @@ packages: regenerator-runtime: 0.13.11 dev: false - /@babel/runtime@7.23.7: - resolution: {integrity: sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==} + /@babel/runtime@7.23.9: + resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 @@ -3061,12 +3064,12 @@ packages: pretty-format: 27.5.1 dev: true - /@testing-library/dom@9.3.3: - resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==} + /@testing-library/dom@9.3.4: + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} dependencies: '@babel/code-frame': 7.23.5 - '@babel/runtime': 7.23.7 + '@babel/runtime': 7.23.9 '@types/aria-query': 5.0.4 aria-query: 5.1.3 chalk: 4.1.2 @@ -3104,13 +3107,13 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true - /@testing-library/user-event@14.4.3(@testing-library/dom@9.3.3): + /@testing-library/user-event@14.4.3(@testing-library/dom@9.3.4): resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' dependencies: - '@testing-library/dom': 9.3.3 + '@testing-library/dom': 9.3.4 dev: true /@tootallnate/once@2.0.0: @@ -5688,6 +5691,11 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} dev: true @@ -9599,12 +9607,12 @@ packages: readable-stream: 3.6.2 dev: true - /zustand@4.4.7(@types/react@18.0.26)(react@18.2.0): - resolution: {integrity: sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==} + /zustand@4.5.0(@types/react@18.0.26)(react@18.2.0): + resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==} engines: {node: '>=12.7.0'} peerDependencies: '@types/react': '>=16.8' - immer: '>=9.0' + immer: '>=9.0.6' react: '>=16.8' peerDependenciesMeta: '@types/react':