* Refactor/ts sockets (#210)

* refactor(ts): create socket subscription service
* refactor(ts-sockets): useSubscription hook
* refactor(ts-sockets): extract colour selection to utility

* Refactor/socket controller (#212)

* refactor(socket): dictionary cleanup
* refactor(socket): detangle socket from EventTimer
* refactor(socket): poll object
* refactor(socket): wip, extract data responsibilities to provider
* fix: issue with onAir
* refactor: create data provider and validation utils
* refactor: remove deprecated endpoint
* refactor: detangle and validate ontime controller
* refactor: detangle and validate events controller
* refactor: validate routers
* refactor: handle post failure in modals

* chore: upgrade electron (#215)

* Fix: issue with text colour (#214)
* fix: adjust text colour from context
* fix: issue with wrong proptype
* chore: upgrade electron
* chore: declare node version
* chore: upgrade node actions

* Feat/55 v2 (#225)

* chore: upgrade relevant libraries
* feat(skip): add skip styling to paginated items
* chore: upgrade relevant libraries
* Fix: issue with text colour (#214)
* fix: adjust text colour from context
* fix: issue with wrong proptype
* fix issue with vite migration (#217)
* hotfix: 1.8.2 issues vite migration
* fix: file import options
* feat(55): styling
* refactor: remove onhover option for entry block
* refactor: relocate logging provider
* refactor: convert to typescript
* feat(55): add event editor
* refactor: extract event actions
* fix: bad import
* feat(55): redesign components
* fix: issues with not awaiting async
* refactor: replace socket with subscription
* refactor: remove unused
* style: small tweaks
* refactor: extract event actions
* refactor: cleanup debug
* refactor: chakra imports
* fix: optimistic mutations
* refactor: handle promise rejections
* refactor: validation
* fix: rq optimistic mutations
* feat: add playback feedback to block
* refactor: no optimistic adding of events
* refactor: simplify cursor state
* styles: cleanup editor style
* refactor: cleanup duration update
* fix: revert package upgrade (issues with vitest)
* fix: prevent cyclic imports
* refactor: extract data fetcher
* refactor: typescript migration
* chore: update tests

* fix: remove problematic (#228)

* styles: v2 (#229)

* style: revise selected style
* style: revise editor styles
* style: revise viewer styles
* chore: update tests

* v2:feat (#231)

* style: element relationship
* feat: utility to add first element
* refactor: organise dir
* ux: deleting element closes drawer
* fix: issue with escaped characters
* fix: issue with overflow in events list
* fix: prevent event timer from receiving non event
* style: prevent small shift on class change
* style: visually detach event editor
* fix: entry block knows last event
* fix: skip event timer updates on non events
* fix: prevent electron OPENGL error
* style: cleanup text styles
* style: unify input styles
* refactor: simplify style and composition
* refactor: tweaks on style + ts
* refactor: add validation to events post
* fix: handle non events in event timer
* refactor: cleanup unused

* V2 styles part2 (#233)

* style: create tag to display network messages
* style: create button style for playback
* style: review style for QuickEntry
* style: review style for Info panel
* style: review style for Event blocks
* style: review style for Message control panel
* style: review style for Playback control
* style: review application styles
* style: review block styles
* refactor: TapButton has active state
* style: small style tweaks
* refactor: type checks

* V2 message manager (#234)

* refactor: server run script
* refactor: extract message control feature
* refactor: socket usage for message control feature
* refactor: migrate e2e testing to playwright
* chore: add feature tests
* refactor: add validation on socket controller
* chore: jest tests server logic
* chore: update tests
* chore: run playwright tests on pull_requests

* V2 rundown manager (#237)

* refactor: migrate eventlist > rundown

* hotfix: fix object naming error on excel import (#252)

* feat: V2 ontime menu (#259)

* V2 socket store (#254)

* refactor: create socket utility
* refactor: ensure a single subscription to topics
* refactor: replace usages of socket object with subscription store handler

* refactor: v2 event loader (#260)

* refactor: extract jest config

* refactor: rename params in reoder endpoint

* refactor: avoid app exports

* refactor: small code smells

* refactor: run development server

* refactor: extract rundown service and event loader logic

* refactor: rename events > rundown

* refactor: code style

* fix: handle external change of title

* refactor: cleanup dictionary

* refactor: rename MessageService

* refactor: use eventID for operations

* refactor: migrate OSC controller to service

* refactor: migrate Socket controller to service

* refactor: migrate HTTP controller to service

* refactor: extract logic into discrete services

* refactor: remove rundown from event timer

* refactor: remove duplicate

* refactor: remove unused

* chore: update tests

* refactor: run test CI on master or manual run

* lint: reorder imports

* refactor: ensure consistent routing

* V2 event block (#261)

* feat: implement progress bar on running event

* refactor: use event action hook

* refactor: align backend data

* v2 styling (#263)

* feat: implement progress bar on running event

* refactor: use event action hook

* refactor: align backend data

* style: v2 style tweaks

* style: v2 style tweaks + ts <Info>

* fix: fix delay increment calls

* style: v2 style tweaks + ts <Playback>

* style: v2 style tweaks + ts <EventEditor>

* style: v2 style tweaks + ts <MessageControl>

* fix: naming issue with message control socket endpoint

* chore: upgrade style dependencies

* style: v2 style tweaks + ts <MenuBar>

* style: v2 style tweaks + ts <RundownMenu>

* style: v2 style tweaks + ts <Playback>

* fix: add selected and next information to EventTimer

* style: v2 style tweaks + ts <EventBlock>

* style: v2 style tweaks + ts <DelayBlock>

* style: v2 style tweaks + ts <BlockBlock>

* style: v2 style tweaks + ts <QuickEntry>

* refactor: extract <TextInput> logic

* chore: upgrade build dependencies

* chore: folder structure refactor

* fix: issue with dependency path in electron

* chore: update version in demo db

* v2: refactor (#264)

* chore: upgrade dependencies

* chore: remove unused

* fix: issue with missing index data

* fix: discrepancy on counting events

* fix: swapped classes

* fix: prevent attempting load in empty list

* chore: cleanup completed

* chore: remove unused

* refactor: convert to typescript

* refactor: migrate to new endpoints

* refactor: convert to typescript

* fix: prevent issue with undefined process

* fix: small code smells

* fix: issue with missing version variable on build

* refactor: configure retries on data fetching

* style: design review

* fix: prevent cursor out of range

* lint: react query linting

* style: checkbox styles

* v2 event editor (#265)

* style: add cursor indicator in all blocks

* style: prevent reflows on width

* style: harmonise white range

* ux: focus elements on cursor nav

* ux: prevent focus on indicators

* refactor: convert to typescript

* fix: missing dependency

* chore: add linter rules for dependency arrays

* style: style editable component

* style: tweak input styles

* style: tweak switch styles

* style: style tweaks

* V2 sentry (#267)

* chore: add sentry to frontend

* chore: add sentry to server

* chore: report only in production

* chore: add sentry source maps

* v2 alpha 3 (#272)

* style: correct background colours

* chore: add folder resolutions to vite

* refactor: add auto refetch to HTTP APIs

* fix: view settings override endpoint

* refactor: memoize callbacks

* fix: reorder reaching wrong data adapter

* refactor: memoize callbacks

* style: presentation cleanup

* fix(delete): prevent flow with deleted event in playback

* refactor: convert to typescript

* refactor: small fixes and typescript conversion

* ux: improve feedback on local changes

* refactor: cleanup props

* v2 event timer (#274)

* lint: cleanup unused

* refactor: rename component to avoid conflict

* style: gap in element row

* refactor: rename playstate -> playback

* refactor: cleanup usages of timer and prepare integration manager

* style: re-arrange button order

* refactor: improve event loading

* feat(timer-service): hot reload

* fix: issue with duration input

* chore: cleanup debug

* refactor: resolve poll from runtime store

* refactor: cleanup merge

* refactor: small improvements in timer hot-reload

* v2: views (#278)

* config(sentry): instrumentation only in production

* chore: upgrade deps

* refactor: simplify and convert to ts

* refactor: composition and styles

* chore: remove deprecated deps

* ux: close menu on click outside

* style: small tweaks and clarifications

* refactor: retire PiP view

* ux: small tweaks

* style: cleanup deprecated styles

* chore: remove unused

* refactor: remove usages of deprecated fields and improve null timer presentation

* refactor: improve composition of countdown

* style: cleanup overridable styles

* ux: show event overtime status

* style: correct logic in paused timers

* v2: roll (#280)

* ux: delete flow

* style: small tweaks in interface

* refactor: gracefully quit on error

* fix: logic around updating events

* chore: cleanup dictionary

* refactor: improve DX on creating aux files

* fix: safe destructure function return

* refactor: safe handling of falsy timer values

* fix: improve ux on stopping roll mode

* chore: upgrade deps

* feat: roll mode

* refactor: remove unused

* V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Fix: #279 End Message in Minimal Timer (#286)

* fix: Minimal View #279 end message
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* hotfix: resolve data directory (#288)

* Feature/#276/qrcodes (#289)

* bugfix: wrong format when on 12h format

* update: website link in placeholder

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>

* V2 types (#291)

* V2 integration (#293)

* feat: integrations and lifecycles

* refactor: prevent issues with start order

* V2 fix offline (#294)

* fix: resolve path to UI build in dev

* fix: configure offline fetching

* V2 fix socket (#296)

* refactor: rename timer endpoint

* refactor: typescript

* refactor: rename eventData and viewSettings

* refactor: message manager uses event store

* style: alpha4 style tweaks (#299)

* fix: backgroundcolor for .blockNotes (#300)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Feat/countdown style (#290)

* feat: Countdown Style

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* fix: wrong parameter (#302)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 fix timer (#304)

* Feat/update check (#303)

* feat: show latest version in settings modal

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improve time input (#307)

* add the ability to parse time like 2h20m

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 ws store (#310)

* Update TimerService.ts

* refactor: message service publishes to store

* refactor: several type improvements

* V2 ws store wss (#309)

* refactor: shared logging types

* refactor: simplify message service consumption

* refactor: create discrete logging system

* refactor: move socket.io > websocket

* Feat/end action (#308)

* feat: end action

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* V2 osc (#313)

* chore: upgrade relevant deps

* fix: electron app to tray

* chore: cleanup dictionary

* feat: handle several messages in a event

* ux: disable irrelevant buttons in browser

* feat: parse and validate subscriptions

* feat: create UI for OSC Integration

* fix: cleanup logger behaviour

* feat: allow OSC settings to be changed at runtime

* v2 alpha5 (#315)

* fix: fetch in offline environments (#295)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* ux: remove duplicate showing of selected event

* several small fixes and UX improvements
---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* V2 docker (#312)

* Create docker-ci.yml

* docker build

* add build app

* path changes

* delete double workflow

* update docker run template

* cleanup

* resolve wierd path issue

* wrong path

* again wrong path

* rename docker releases

* move styles population and change to function call

* re-add old build pipeline

* rename buld file

* feractor init function

* refactor: startDb to initAssets

* refactor: startDB to initAssets

* fix: docker.cjs building whole app

* fix: docker environment

* update docker paths

* update: build command

* cleanup

* cleanup: commented code

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* v2 lazy rundown (#317)

* chore: update related deps

* refactor: memoise entrypoints

* refactor: store selectors

* refactor: improve component performance

* chore: remove unused dependencies

* refactor: improve component dnd

* V2 build (#319)

* chore: build pipeline for v2

* chore: run unit tests

* chore: configure sentry build

* Update test_v2.yml

* Update test_v2.yml

* V2 e2e (#320)

* chore: cleanup handling of a bad read

* chore: configure e2e testing

* chore: version bump

* V2 align (#321)

* fix: fetch in offline environments (#295)

* Add arm platforms to docker build (#297)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* Timer: fix too many renders error when using ?progress

* version bump (#314)

* style: small tweaks

* fix: prevent actions in roll mode

* chore: update external assets

* style: show shortcuts in quick blocks

* refactor: revert optimisations on callbacks

* fun: industry dictionary

* style: indicators for event features

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* refactor: ensure start order (#322)

* Update build_docker_v2.yml

* V2 fix roll (#323)

* chore: profile node

* fix: always roll

* Add translations for views (#325) (#328)

* Add translations for views (#325)

---------

Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>

* feat: notify ws clients on rundown changes (#327)

* V2 beta1 (#326)

* style: labels on added time

* style: remove mentions of PiP

* refactor: unify usage of ms for timers

* refactor: create events with 0 duration

* style: several small tweaks

* refactor: keep block when applying delays

* feat: blocks have titles

* style: improvements in time entry warnings

* style: override progress bar styles

* style: prevent overflow

* feat: show character count in editor

* refactor: provide initial payload

* refactor: lower test boundary

* refactor: get colour from swatches

* style: rename title block

* chore: version bump

* style: fix info scroll

* refactor: performance tweaks to event editor

* chore: upgrade deps

* refactor: prevent iterating on undefined

* style: small style tweaks

* fix: close editor on event delete

* refactor: improve performance on EventBlock

* style: small tweaks on blocks

* chore: upgrade deps

* chore: upgrade deps

* style: ensure events are always visible

* Fix/events enpoint (#329)

* fix: sentry hangs node process

* add onClick handler (#335)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix docker on different port (#337)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 beta3 (#338)

* chore: upgrade local build documentation

* style: keep scrolling event in screen

* feat: delay is time entry

* refactor: remove unused

* refactor: remove unused

* refactor: batch store updates

* refactor: virtually remove cap on events

* refactor: style and behaviour tweaks to event block

* style: tweaks on schedules

* chore: remove sentry from server

* style: reorder menu

* chore: update docs

* fix: resolve to static port in dev mode

* V2 fix delay (#340)

* fix: avoid modifying original objects

* refactor: serve react as compressed br (#339)

* refactor: changes in time input

* V2 e2e playwright (#343)

* test: create utility for uploading test-db

* chore: upgrade deps

* refactor: remove unused functions

* chore: create testing boilerplate

* test: upload showfile

* refactor: parse time input (#351)

* chore: vite upgrade (#354)

* chore: upgrade vite

* feat: edit mode (#344)

* style: add mode selection to menu

* style: rename rundown functions

* feat: edit mode

* fix: osc modal (#352)

* refactor: parse time input

* fix: form dirty flag on dynamic elements

* style: cuesheet block (#355)

* style: cuesheet show block title

* style: cuesheet show full delay string

* v2 about (#362)

* chore: remove unused assets

* feat: about ontime

* refactor: cleanup unused

* v2 beta 4 (#361)

* ux: rename end action

* chore: update demo db

* style: tweaks on extracted rundown

* chore: prevent console logs in production code

* feat: go mode

* style: remove window size limits

* style: rename delete action

* style: small tweaks on icons

* fix: style override on params

* chore: update test db

* refactor: small code quality improvements

* refactor: prevent circular imports

* fix v2 roll timer (#363)

* fix: roll update skips elapsed time

* refactor: remove unused data

* style: fix style in roll state

* ux: content aware playback (#364)

* v2 beta 4 bump (#367)

* chore: update links to documentation

* chore: version bump

* Feature: quick start (#369)

* feat: quick start modal

* refactor: end message is part of view settings

* refactor: small tweaks and type improvements

* Feat/timer/stylechange (#341)

* feat: multi color progress bar

* add color support to minimal timer

Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* v2 beta5 modals (#377)

* refactor: folder structure

* style: modal wrapper

* feat: add nordic translations

* v2 beta 5 (#375)

* chore: update documentation links

* fix: sentry has no access to error context

* style: clarify event history

* fix: issue with clipboard write in safari

* style: clarify event history

* refactor: improvements to follow logic in rundown

* refactor: improvements in go mode

* several style tweaks and small improvements

* Ux language (#378)

* ux: language from menu

* feat: add extra support for languages

german
portuguese
spanish

* refactor: type improvements

* v2 beta6 (#379)

* feat: operator key

* chore: update demo

* chore: version bump

* fix: prevent stale keys

* refactor: add new fields to validation

* style: tweaks to modal arrangement

* refactor: prevent parsing http

* ux: click anywhere in event to edit

* refactor: performance improvements to time input

* refactor: performance improvements menu

* V2 beta 6 (#381)

* fix: fetch in offline environments (#295)

* Add arm platforms to docker build (#297)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* Timer: fix too many renders error when using ?progress

* version bump (#314)

* chore: remove unused assets

* refactor: improve rundown loading time

* style: rename project file

* chore: update demo files

* refactor: improve loading time

* chore: upgrade upload packages

* refactor: modal redesign and ux improvements

* refactor: type and ux improvements on translations

* refactor: prevent flash on suspense

* refactor: remove unnecessary import

* chore: update tests

* refactor: cleanup imports

* fix: prevent flash on suspense

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* style views (#390)

* style: animate transition from left side

* refactor: improve schedule relationship

* refactor: prevent passing unhandled value

* style: remove inner radius in borders

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>
Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>
This commit is contained in:
Carlos Valente
2023-05-18 14:02:22 +02:00
committed by GitHub
parent 974a239b67
commit c8e979bc9a
698 changed files with 31752 additions and 34071 deletions
+57
View File
@@ -0,0 +1,57 @@
enum Source {
System = 'system',
MIDI = 'MIDI',
}
class Clock {
private static instance: Clock;
private readonly source: Source;
constructor(source?: Source) {
if (Clock.instance) {
return Clock.instance;
}
Clock.instance = this;
this.source = source || Source.System;
}
/**
* Get current time from source
*/
timeNow(): number {
switch (this.source) {
case Source.System:
return this.getSystemTime();
case Source.MIDI:
// @ts-expect-error -- not implemented
return this.getMidiTime();
default:
throw new Error('Invalid time source');
}
}
/**
* Get current time from system
*/
getSystemTime() {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Get current time from MIDI
*/
getMidiTime() {
throw new Error('Not implemented');
}
}
export const clock = new Clock();
+227
View File
@@ -0,0 +1,227 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventStore } from '../stores/EventStore.js';
import { eventTimer } from './TimerService.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @return {boolean} success
*/
static loadEvent(event: OntimeEvent): boolean {
let success = false;
if (!event) {
logger.error('PLAYBACK', 'No event found');
} else if (event.skip) {
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
success = true;
}
eventStore.broadcast();
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
}
}
}
/**
* Loads event after currently selected
* @param {string} [fallbackAction] - 'stop', 'pause'
* @return {boolean} success
*/
static loadNext(fallbackAction?: 'stop' | 'pause'): boolean {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
return true;
}
} else if (fallbackAction === 'stop') {
logger.info('PLAYBACK', 'No next event found! Stopping playback');
PlaybackService.stop();
return false;
} else if (fallbackAction === 'pause') {
logger.info('PLAYBACK', 'No next event found! Pausing playback');
PlaybackService.pause();
return false;
} else {
logger.info('PLAYBACK', 'No next event found! Continuing playback');
return false;
}
}
/**
* Starts playback on selected event
*/
static start() {
if (eventTimer.playback === Playback.Armed || eventTimer.playback === Playback.Pause) {
eventTimer.start();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Starts playback on next event
* @param {string} [fallbackAction] - 'stop', 'pause'
*/
static startNext(fallbackAction?: 'stop' | 'pause') {
const success = PlaybackService.loadNext(fallbackAction);
if (success) {
PlaybackService.start();
}
}
/**
* Pauses playback on selected event
*/
static pause() {
if (eventTimer.playback === Playback.Play) {
eventTimer.pause();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (eventTimer.playback !== Playback.Stop) {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Reloads current event
*/
static reload() {
if (eventTimer.loadedTimerId) {
this.loadById(eventTimer.loadedTimerId);
}
}
/**
* Sets playback to roll
*/
static roll() {
if (EventLoader.getPlayableEvents()) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
// nothing to play
if (rollTimers === null) {
logger.warning('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent } = rollTimers;
if (!currentEvent && !nextEvent) {
logger.warning('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
eventTimer.roll(currentEvent, nextEvent);
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Adds delay to current event
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime: number) {
if (eventTimer.loadedTimerId) {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
delayInMs > 0
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
}
}
}
+274
View File
@@ -0,0 +1,274 @@
import {
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
SupportedEvent,
} from 'ontime-types';
import { generateId } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import {
block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js';
import { MAX_EVENTS } from '../settings.js';
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer } from './TimerService.js';
import { sendRefetch } from '../adapters/websocketAux.js';
/**
* Checks if a list of IDs is in the current selection
*/
const affectedLoaded = (affectedIds: string[]) => {
const now = eventLoader.loaded.selectedEventId;
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const next = eventLoader.loaded.nextEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
/**
* Checks if timer replaces the loaded next
*/
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.loaded.selectedEventId;
const next = eventLoader.loaded.nextEventId;
// 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 = eventLoader.loaded.selectedPublicEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
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;
};
/**
* Updates timer object
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.loaded.selectedEventId;
if (runningEventId === null) {
return false;
}
// 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 : affectedLoaded(affectedIds);
// 3. the edited event replaces next event
const isNext = isNewNext();
if (safeOption) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
if (eventInMemory) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
if (!loadedEvent) {
eventTimer.stop();
} else {
eventTimer.hotReload(loadedEvent);
}
return true;
}
if (isNext) {
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
return false;
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {unknown[]}
*/
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
const numEvents = DataProvider.getRundownLength();
if (numEvents > MAX_EVENTS) {
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
}
let newEvent: Partial<OntimeBaseEvent> = {};
const id = generateId();
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id } as Partial<OntimeEvent>;
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id } as Partial<OntimeDelay>;
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id } as Partial<OntimeBlock>;
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
}
} catch (error) {
throw new Error(error);
}
updateTimer([id]);
updateChangeNumEvents();
sendRefetch();
return newEvent;
}
export async function editEvent(eventData) {
const eventId = eventData.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
throw new Error('No event with ID found');
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
sendRefetch();
return newEvent;
}
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
updateTimer([eventId]);
updateChangeNumEvents();
sendRefetch();
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
updateChangeNumEvents();
sendRefetch();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
if (index !== from) {
throw new Error('ID not found at index');
}
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setRundown(rundown);
updateTimer();
sendRefetch();
return reorderedItem;
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId: string) {
const rundown = DataProvider.getRundown();
let delayIndex = null;
let delayValue = 0;
for (const [index, event] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (event.id === eventId && event.type === SupportedEvent.Delay) {
delayValue = event.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (event.type === SupportedEvent.Event) {
event.timeStart = Math.max(0, event.timeStart + delayValue);
event.timeEnd = Math.max(event.duration, event.timeStart + delayValue);
event.revision += 1;
} else if (event.type === SupportedEvent.Block) {
break;
}
}
}
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
sendRefetch();
}
/**
* Forces update in the store
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
eventLoader.updateNumEvents();
}
+374
View File
@@ -0,0 +1,374 @@
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState } from 'ontime-types';
import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { DAY_TO_MS } from '../utils/time.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getElapsed, getExpectedFinish } from './timerUtils.js';
import { clock } from './Clock.js';
export class TimerService {
private readonly _interval: NodeJS.Timer;
playback: Playback;
timer: TimerState;
loadedTimerId: string | null;
private pausedTime: number;
private pausedAt: number | null;
private secondaryTarget: number | null;
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
*/
constructor(timerConfig?) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
}
/**
* Clears internal state
* @private
*/
_clear() {
this.playback = Playback.Stop;
this.timer = {
clock: clock.timeNow(),
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
};
this.loadedTimerId = null;
this.pausedTime = 0;
this.pausedAt = null;
this.secondaryTarget = null;
}
/**
* Reloads information for currently running timer
* @param timer
*/
hotReload(timer) {
if (typeof timer === 'undefined') {
this.stop();
return;
}
if (timer?.id !== this.loadedTimerId) {
// event timer only concerns itself with current event
return;
}
if (timer?.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
// update relevant information and force update
this.timer.duration = timer.duration;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
// this might not be ideal
this.timer.finishedAt = null;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
);
if (this.timer.startedAt === null) {
this.timer.current = timer.duration;
}
this.update();
}
/**
* Loads given timer to object
* @param {object} timer
* @param {number} timer.id
* @param {number} timer.timeStart
* @param {number} timer.timeEnd
* @param {number} timer.duration
* @param {string} timer.timerBehaviour
* @param {string} timer.timerType
* @param {boolean} timer.skip
*/
load(timer) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
this._clear();
this.loadedTimerId = timer.id;
this.timer.duration = timer.duration;
this.timer.current = timer.duration;
this.playback = Playback.Armed;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.pausedTime = 0;
this.pausedAt = 0;
this._onLoad();
}
/**
* Handles side effects related to onLoad event
* @private
*/
_onLoad() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onLoad);
}
start() {
if (!this.loadedTimerId) {
return;
}
if (this.playback === Playback.Play) {
return;
}
this.timer.clock = clock.timeNow();
// add paused time
if (this.pausedTime) {
this.timer.addedTime += this.pausedTime;
this.pausedAt = null;
this.pausedTime = 0;
} else {
this.timer.startedAt = this.timer.clock;
}
this.playback = Playback.Play;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
);
this._onStart();
}
/**
* Handles side effects related to onStart event
* @private
*/
_onStart() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStart);
}
pause() {
if (this.playback !== Playback.Play) {
return;
}
this.playback = Playback.Pause;
this.timer.clock = clock.timeNow();
this.pausedAt = this.timer.clock;
this._onPause();
}
_onPause() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onPause);
}
stop() {
if (this.playback === Playback.Stop) {
return;
}
this._clear();
this._onStop();
}
_onStop() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStop);
}
/**
* Delays running timer by given amount
* @param {number} amount
*/
delay(amount: number) {
if (!this.loadedTimerId) {
return;
}
this.timer.addedTime += amount;
// handle edge cases
if (amount < 0 && Math.abs(amount) > this.timer.current) {
if (this.timer.finishedAt === null) {
// if we will make the clock negative
this.timer.finishedAt = clock.timeNow();
}
} else if (this.timer.current < 0 && this.timer.current + amount > 0) {
// clock will go from negative to positive
this.timer.finishedAt = null;
}
// force an update
this.update();
}
update() {
this.timer.clock = clock.timeNow();
if (this.playback === Playback.Roll) {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
// safeguard on midnight rollover
_finishAt:
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + DAY_TO_MS,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
secondaryTarget: this.secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
if (isFinished) {
this.timer.selectedEventId = null;
this.loadedTimerId = null;
this._onFinish();
}
// to load the next event we have to escalate to parent service
if (doRollLoad) {
PlaybackService.roll();
}
} else {
// we only update timer if a timer has been started
if (this.timer.startedAt !== null) {
if (this.playback === Playback.Pause) {
this.pausedTime = this.timer.clock - this.pausedAt;
}
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
);
}
this.timer.current = getCurrent(
this.timer.startedAt,
this.timer.duration,
this.timer.addedTime,
this.pausedTime,
this.timer.clock,
);
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
}
}
this._onUpdate();
}
_onUpdate() {
eventStore.set('timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
_onFinish() {
eventStore.set('timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onFinish);
if (this.playback === Playback.Play) {
if (this.timer.endAction === EndAction.Stop) {
PlaybackService.stop();
} else if (this.timer.endAction === EndAction.LoadNext) {
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
setTimeout(() => {
PlaybackService.loadNext();
}, 0);
} else if (this.timer.endAction === EndAction.PlayNext) {
PlaybackService.startNext();
}
}
}
/**
* Loads roll information into timer service
* @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
*/
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
this._clear();
this.timer.clock = clock.timeNow();
if (currentEvent) {
// there is something running, load
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// when we load a timer in roll, we do the same things as before
// but also pre-populate some data as to the running state
this.load(currentEvent);
this.timer.startedAt = currentEvent.timeStart;
this.timer.expectedFinish = currentEvent.timeEnd;
} else if (nextEvent) {
// account for day after
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + DAY_TO_MS : nextEvent.timeStart;
// nothing now, but something coming up
this.timer.secondaryTimer = nextStart - this.timer.clock;
this.secondaryTarget = nextStart;
}
this.playback = Playback.Roll;
this._onRoll();
this.update();
}
_onRoll() {
eventStore.set('playback', this.playback);
}
shutdown() {
clearInterval(this._interval);
}
}
export const eventTimer = new TimerService();
@@ -0,0 +1,587 @@
import {
DAY_TO_MS,
getRollTimers,
normaliseEndTime,
sortArrayByProperty,
updateRoll,
} from '../rollUtils.ts';
// test sortArrayByProperty()
describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => {
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
it('sort array 1-5 with null', () => {
const arr1 = [
{ timeStart: 1 },
{ timeStart: 5 },
{ timeStart: 3 },
{ timeStart: 2 },
{ timeStart: 4 },
{ timeStart: null },
];
const arr1Expected = [
{ timeStart: null },
{ timeStart: 1 },
{ timeStart: 2 },
{ timeStart: 3 },
{ timeStart: 4 },
{ timeStart: 5 },
];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
});
// test getRollTimers()
describe('test that roll loads selection in right order', () => {
const eventlist = [
{
id: 1,
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: 3,
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: 4,
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: 5,
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: 6,
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: 7,
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: 8,
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[4],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 5', () => {
const now = 5;
const expected = {
nowIndex: 0,
nowId: eventlist[0].id,
publicIndex: null,
nextIndex: 1,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[1],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[0],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: null,
nextIndex: 2,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[2],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[1],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: null,
nextIndex: 3,
publicNextIndex: 4,
timeToNext: 10,
nextEvent: eventlist[3],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[2],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 49', () => {
const now = 49;
const expected = {
nowIndex: 4,
nowId: eventlist[4].id,
publicIndex: 4,
nextIndex: 5,
publicNextIndex: 6,
timeToNext: 1,
nextEvent: eventlist[5],
nextPublicEvent: eventlist[6],
currentEvent: eventlist[4],
currentPublicEvent: eventlist[4],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 63', () => {
const now = 63;
const expected = {
nowIndex: 6,
nowId: eventlist[6].id,
publicIndex: 6,
nextIndex: 7,
publicNextIndex: null,
timeToNext: 7,
nextEvent: eventlist[7],
nextPublicEvent: null,
currentEvent: eventlist[6],
currentPublicEvent: eventlist[6],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 75', () => {
const now = 75;
const expected = {
nowIndex: 7,
nowId: eventlist[7].id,
publicIndex: 6,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[7],
currentPublicEvent: eventlist[6],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 100', () => {
const now = 100;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timeToNext: DAY_TO_MS - now + eventlist[0].timeStart,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[4],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList = [
{
id: 1,
timeStart: 36000000, // 10:00
timeEnd: 39600000, // 11:00
isPublic: true,
},
];
const now = 64800000; // 18:00
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: DAY_TO_MS - now + singleEventList[0].timeStart,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
// test getRollTimers()
describe('test that roll behaviour with overlapping times', () => {
const eventlist = [
{
id: 1,
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: 3,
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 1,
timeToNext: 10,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[1],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10', () => {
const now = 10;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timeToNext: 0,
nextEvent: eventlist[2],
nextPublicEvent: null,
currentEvent: eventlist[1],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timeToNext: -5,
nextEvent: eventlist[2],
nextPublicEvent: null,
currentEvent: eventlist[1],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[2],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[2],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test getRollTimers() on issue #58
describe('test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30
const eventlist = [
{
id: 1,
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
},
];
const expected = {
nowIndex: 0,
nowId: 1,
publicIndex: null,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[0],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34
const eventlist = [
{
id: 1,
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
},
];
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: null,
timeToNext: eventlist[0].timeStart - now,
nextEvent: eventlist[0],
nextPublicEvent: null,
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test normaliseEndTime() on issue #58
test('test typical scenarios', () => {
const t1 = {
start: 10,
end: 20,
};
const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
const t2 = {
start: 10 + DAY_TO_MS,
end: 20,
};
const t2_expected = 20 + DAY_TO_MS;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
const t3 = {
start: 10,
end: 10,
};
const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
// test updateRoll()
describe('typical scenarios', () => {
it('it updates running events correctly', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 15,
clock: 11,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers._finishAt = 1000;
timers.clock = 600;
expected.updatedTimer = timers._finishAt - timers.clock;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 11,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 11,
clock: 12,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: -1,
updatedSecondaryTimer: null,
doRollLoad: true,
isFinished: true,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('secondary events do not trigger event ends', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 16,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 15,
secondaryTimer: 0,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
@@ -0,0 +1,143 @@
import { getCurrent, getElapsed, getExpectedFinish } from '../timerUtils.js';
describe('getExpectedFinish()', () => {
it('is null if we havent started', () => {
const startedAt = null;
const finishedAt = null;
const duration = 10;
const pausedTime = 0;
const addedTime = 0;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(null);
});
it('is finishedAt if defined', () => {
const startedAt = 10;
const finishedAt = 20;
const duration = 10;
const pausedTime = 0;
const addedTime = 0;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(finishedAt);
});
it('calculates the finish time', () => {
const startedAt = 1;
const finishedAt = null;
const duration = 10;
const pausedTime = 0;
const addedTime = 0;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(11);
});
it('adds paused and added times', () => {
const startedAt = 1;
const finishedAt = null;
const duration = 10;
const pausedTime = 10;
const addedTime = 10;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(31);
});
it('added time could be negative', () => {
const startedAt = 1;
const finishedAt = null;
const duration = 10;
const pausedTime = 10;
const addedTime = -10;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(11);
});
it('user could add enough time for it to be negative', () => {
const startedAt = 1;
const finishedAt = null;
const duration = 10;
const pausedTime = 0;
const addedTime = -100;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(1);
});
it('timer can have no duration', () => {
const startedAt = 1;
const finishedAt = null;
const duration = 0;
const pausedTime = 0;
const addedTime = 0;
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
expect(calculatedFinish).toBe(1);
});
});
describe('getCurrent()', () => {
it('is null if it hasnt started', () => {
const startedAt = null;
const duration = 0;
const pausedTime = 0;
const addedTime = 0;
const clock = 0;
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
expect(current).toBe(null);
});
it('is the remaining time in clock', () => {
const startedAt = 0;
const duration = 10;
const pausedTime = 0;
const addedTime = 0;
const clock = 1;
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
expect(current).toBe(9);
});
it('accounts for added times', () => {
const startedAt = 0;
const duration = 10;
const pausedTime = 5;
const addedTime = 5;
const clock = 1;
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
expect(current).toBe(19);
});
});
describe('getElapsedTime()', () => {
it('time since we started', () => {
const startedAt = 0;
const clock = 5;
const elapsed = getElapsed(startedAt, clock);
expect(elapsed).toBe(5);
});
it('clock cannot be lower than started time', () => {
const startedAt = 10;
const clock = 5;
expect(() => getElapsed(startedAt, clock)).toThrow();
});
});
describe('getExpectedFinish() getElapsedTime() and getCurrentTime() combined', () => {
it('without added times, they combine to be duration', () => {
const startedAt = 0;
const duration = 10;
const finishedAt = null;
const pausedTime = 0;
const addedTime = 0;
const clock = 0;
const expectedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
const elapsed = getElapsed(startedAt, clock);
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
expect(expectedFinish).toBe(10);
expect(elapsed).toBe(0);
expect(current).toBe(10);
expect(elapsed + current).toBe(10);
});
it('added times influence expected finish', () => {
const startedAt = 0;
const duration = 10;
const finishedAt = null;
const pausedTime = 1;
const addedTime = 2;
const clock = 5;
const expectedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
const elapsed = getElapsed(startedAt, clock);
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
expect(expectedFinish).toBe(13);
expect(elapsed).toBe(5);
expect(current).toBe(8);
});
});
@@ -0,0 +1,24 @@
import { TimerLifeCycle, OscSubscription } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration {
subscriptions: OscSubscription;
init: (config: unknown) => OperationReturn;
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
emit: (...args: unknown[]) => unknown;
shutdown: () => void;
}
// either went well, or explain what failed
type OperationReturn = ReturnOnSuccess | ReturnOnError;
type ReturnOnSuccess = {
success: true;
message?: string;
};
type ReturnOnError = {
success: false;
message: string;
};
@@ -0,0 +1,34 @@
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { eventStore } from '../../stores/EventStore.js';
class IntegrationService {
private integrations: IIntegration[];
constructor() {
this.integrations = [];
}
register(integrationService: IIntegration) {
this.integrations.push(integrationService);
}
unregister(integrationService: IIntegration) {
this.integrations = this.integrations.filter((int) => int !== integrationService);
}
dispatch(action: TimerLifeCycleKey) {
const state = eventStore.poll();
this.integrations.forEach((integration) => {
integration.dispatch(action, state);
});
}
shutdown() {
console.log('Shutdown integrations');
this.integrations.forEach((integration) => {
integration.shutdown();
});
}
}
export const integrationService = new IntegrationService();
@@ -0,0 +1,130 @@
import { ArgumentType, Client, Message } from 'node-osc';
import { OSCSettings, OscSubscription } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { validateOscSubscription } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration {
protected oscClient: null | Client;
subscriptions: OscSubscription;
constructor() {
this.oscClient = null;
this.subscriptions = dbModel.osc.subscriptions;
}
/**
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions } = config;
this.initSubscriptions(subscriptions);
// runtime validation
const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number';
const validateNull = !targetIP || !portOut;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try {
// this allows re-calling the init function during runtime
this.oscClient?.close();
this.oscClient = new Client(targetIP, portOut);
return {
success: true,
message: `OSC integration client connected to ${targetIP}:${portOut}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
}
}
initSubscriptions(subscriptionOptions: OscSubscription) {
if (validateOscSubscription(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
}
dispatch(action: Action, state?: object) {
if (!this.oscClient) {
return {
success: false,
message: 'Client not initialised',
};
}
if (!action) {
return {
success: false,
message: 'OSC called with no action',
};
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
this.emit(parsedMessage);
}
});
}
emit(path: string, payload?: ArgumentType) {
const message = new Message(path);
if (payload) {
try {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
} catch (error) {
console.log('OSC ERROR', error, payload);
}
}
this.oscClient.send(message, (error) => {
if (error) {
return {
success: false,
message: `Error sending message: ${JSON.stringify(error)}`,
};
}
return {
success: true,
message: 'OSC Message sent',
};
});
}
shutdown() {
console.log('Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
}
}
}
export const oscIntegration = new OscIntegration();
@@ -0,0 +1,96 @@
import { parseTemplate, parseTemplateNested } from './integrationUtils.js';
describe('parseTemplate()', () => {
it('correctly parses a given string', () => {
const mockState = { test: 'this' };
const testString = 'That should replace {{test}}';
const expected = `That should replace ${mockState.test}`;
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('parses string with multiple variables', () => {
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}';
const expected = `${mockState.test1} should replace ${mockState.test2}`;
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('correctly parses a string without templates', () => {
const testString = 'That should replace {test}';
const result = parseTemplate(testString, {});
expect(result).toStrictEqual(testString);
});
it('handles scenarios with missing variables', () => {
// by failing to provide a value, we give visibility to
// potential issues in the given string
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('doesnt yet handle nested variables', () => {
const mockState = {
timer: {
time: '10',
},
enabled: 'is',
};
const testString = 'Timer {{enabled}} enabled with {{timer.time}}ms interval';
const expected = 'Timer is enabled with {{timer.time}}ms interval';
const result = parseTemplate(testString, mockState);
expect(result).toStrictEqual(expected);
});
});
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
const store = { timer: 10 };
const templateString = '/test/{{timer}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/test/10');
});
it('parses string with a nested variable name', () => {
const store = { timer: { clock: 10 } };
const templateString = '/timer/{{timer.clock}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/timer/10');
});
it('parses string with multiple variables', () => {
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}';
const expected = `${mockState.test1} should replace ${mockState.test2}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('correctly parses a string without templates', () => {
const testString = 'That should replace {test}';
const result = parseTemplateNested(testString, {});
expect(result).toStrictEqual(testString);
});
it('handles scenarios with missing variables', () => {
// by failing to provide a value, we give visibility to
// potential issues in the given string
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
});
@@ -0,0 +1,37 @@
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
/**
* Parses a templated string
*/
export function parseTemplate(template: string, state: object): string {
let parsedTemplate = template;
let match;
while ((match = placeholderRegex.exec(template)) !== null) {
const variableName = match[1];
if (Object.hasOwn(state, variableName)) {
parsedTemplate = parsedTemplate.replace(match[0], state[variableName]);
}
}
return parsedTemplate;
}
/**
* Parses a templated string to values in a nested object
*/
export function parseTemplateNested(template: string, state: object): string {
let parsedTemplate = template;
let match;
while ((match = placeholderRegex.exec(template)) !== null) {
const variableName = match[1];
const variableParts = variableName.split('.');
// iterate through variable parts, and look for the property in the state object
const value = variableParts.reduce((obj, key) => obj && obj[key], state);
if (value !== undefined) {
parsedTemplate = parsedTemplate.replace(match[0], value);
}
}
return parsedTemplate;
}
@@ -0,0 +1,119 @@
import { Message } from 'ontime-types';
import { eventStore } from '../../stores/EventStore.js';
let instance;
class MessageService {
timerMessage: Message;
publicMessage: Message;
lowerMessage: Message;
onAir: boolean;
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.timerMessage = {
text: '',
visible: false,
};
this.publicMessage = {
text: '',
visible: false,
};
this.lowerMessage = {
text: '',
visible: false,
};
this.onAir = false;
}
/**
* @description sets message on stage timer screen
*/
setTimerText(payload: string) {
this.timerMessage.text = payload;
eventStore.set('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
*/
setTimerVisibility(status: boolean) {
this.timerMessage.visible = status;
eventStore.set('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description sets message on public screen
*/
setPublicText(payload: string) {
this.publicMessage.text = payload;
eventStore.set('publicMessage', this.publicMessage);
return this.getAll();
}
/**
* @description sets message visibility on public screen
*/
setPublicVisibility(status: boolean) {
this.publicMessage.visible = status;
eventStore.set('publicMessage', this.publicMessage);
return this.getAll();
}
/**
* @description sets message on lower third screen
*/
setLowerText(payload: string) {
this.lowerMessage.text = payload;
eventStore.set('lowerMessage', this.lowerMessage);
return this.getAll();
}
/**
* @description sets message visibility on lower third screen
*/
setLowerVisibility(status: boolean) {
this.lowerMessage.visible = status;
eventStore.set('lowerMessage', this.lowerMessage);
return this.getAll();
}
/**
* @description set state of onAir, toggles if parameters are offered
*/
setOnAir(status?: boolean) {
if (typeof status === 'undefined') {
this.onAir = !this.onAir;
} else {
this.onAir = status;
}
eventStore.set('onAir', this.onAir);
return this.getAll();
}
/**
* @description Returns feature data
*/
getAll() {
return {
timerMessage: this.timerMessage,
publicMessage: this.publicMessage,
lowerMessage: this.lowerMessage,
onAir: this.onAir,
};
}
}
export const messageService = new MessageService();
+205
View File
@@ -0,0 +1,205 @@
import { OntimeEvent } from 'ontime-types';
/**
* Utility variable: 24 hour in milliseconds .
*/
export const DAY_TO_MS = 86400000;
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + DAY_TO_MS : end);
/**
* @description Sorts an array of objects by given property
* @param {array} arr - array to be sorted
* @param {string} property - property to compare
* @returns {array} copy of array sorted in ascending order
*/
export const sortArrayByProperty = <T>(arr: T[], property: string): T[] => {
return [...arr].sort((a, b) => {
return a[property] - b[property];
});
};
type Timer = {
_startedAt: number;
_finishAt: number;
duration: number;
current: number;
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
const lastEvent = orderedEvents[orderedEvents.length - 1];
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
let nextPublicEvent: OntimeEvent | null = null;
let currentEvent: OntimeEvent | null = null;
let currentPublicEvent: OntimeEvent | null = null;
if (timeNow > lastNormalEnd) {
// we are past last end
// preload first and find next
const firstEvent = orderedEvents[0];
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + DAY_TO_MS - timeNow;
if (firstEvent.isPublic) {
nextPublicEvent = firstEvent;
publicNextIndex = 0;
} else {
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
if (normalEnd <= timeNow) {
// event ran already
if (event.isPublic && normalEnd > publicTime) {
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
// we already know whats next and next-public
if (nextIndex !== null && publicNextIndex !== null) {
continue;
}
// look for next events
// check how far the start is from now
const timeToEventStart = event.timeStart - timeNow;
// we don't have a next or this one starts sooner than current next
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
// if we don't have a public next or this one start sooner than assigned next
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
type CurrentTimers = {
selectedEventId: string | null;
current: number | null;
_finishAt: number | null;
clock: number | null;
secondaryTimer: number | null;
secondaryTarget: number | null;
};
/**
* @description Implements update functions for roll mode
* @param {CurrentTimers} currentTimers
* @returns {object} object with selection variables
*/
export const updateRoll = (currentTimers: CurrentTimers) => {
const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called: force reload of events
let doRollLoad = false;
// whether finished event should trigger
let isPrimaryFinished = false;
if (selectedEventId && current !== null) {
// if we have something selected and a timer, we are running
updatedTimer = _finishAt - clock;
if (updatedTimer < 0) {
isPrimaryFinished = true;
// we need a new event
doRollLoad = true;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
updatedSecondaryTimer = secondaryTarget - clock;
if (updatedSecondaryTimer <= 0) {
// we need a new event
doRollLoad = true;
}
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};
+48
View File
@@ -0,0 +1,48 @@
type MaybeNumber = number | null;
/**
* Calculates expected finish time of a running timer
*/
export function getExpectedFinish(
startedAt: MaybeNumber,
finishedAt: MaybeNumber,
duration: number,
pausedTime: number,
addedTime: number,
) {
if (startedAt === null) {
return null;
}
if (finishedAt !== null) {
return finishedAt;
}
return Math.max(startedAt + duration + pausedTime + addedTime, startedAt);
}
/**
* Calculates running countdown
*/
export function getCurrent(
startedAt: MaybeNumber,
duration: number,
addedTime: number,
pausedTime: number,
clock: number,
) {
if (startedAt === null) {
return null;
}
return startedAt + duration + addedTime + pausedTime - clock;
}
/**
* Calculates elapsed time
*/
export function getElapsed(startedAt: number, clock: number) {
if (startedAt > clock) {
throw new Error('clock cannot be higher than startedAt');
}
return clock - startedAt;
}