Compare commits

..

35 Commits

Author SHA1 Message Date
Carlos Valente a26aa9e01c bump version to 3.5.0-beta.2 2024-08-04 21:37:01 +02:00
Carlos Valente da11ef64c2 refactor: roll mode
* chore: escalate stack trace to console

* refactor: event finding in roll

* docs: initial roll specification

* refactor: call to roll

* refactor: normalise index

* refactor: roll into next event

* refactor: hot reload on waiting to roll

* refactor: wait to roll next
2024-08-04 21:34:01 +02:00
Carlos Valente 3012d73285 feat: timeline view 2024-08-04 21:32:25 +02:00
Alex Christoffer Rasmussen bad491063d fix: getShouldClockUpdate test (#1165)
* useFakeTimers

* now can never be negative
2024-07-31 16:58:02 +02:00
Alex Christoffer Rasmussen a103f8b347 show time in block (#1158) 2024-07-31 14:00:40 +02:00
Alex Christoffer Rasmussen dffccd258f prevent rare case of old customFields (#1163)
as part of subscriptions in operator
2024-07-30 15:42:37 +02:00
Alex Christoffer Rasmussen ce8b0d9850 FIX: remove '/' from host in client-control(#1156) 2024-07-30 12:36:31 +02:00
Alex Christoffer Rasmussen e500fa6b5e about in electron help menu (#1155) 2024-07-29 18:06:10 +02:00
Alex Christoffer Rasmussen 67be4a5631 restore rundownGetAll (#1162) 2024-07-29 13:29:56 +02:00
Alex Christoffer Rasmussen c7893938eb remove old l3 options (#1160) 2024-07-27 09:44:41 +02:00
Alex Christoffer Rasmussen 158ef05ff0 add relevantBlock function (#1100)
* feat: current block

* pass full rundown to functions and use filter utils
2024-07-23 12:22:47 +02:00
Carlos Valente 20838c038a chore: evaluate react 19 migration 2024-07-23 09:02:44 +02:00
Carlos Valente 1ddcec993a chore: upgrade sentry to react version 2024-07-22 22:27:10 +02:00
Alex Christoffer Rasmussen 91d6adf8e0 useFakeTimers for initRundown in tests (#1150) 2024-07-22 14:15:09 +02:00
Carlos Valente a7fe5eceef chore: upgrade sentry 2024-07-20 16:25:46 +02:00
jwetzell e6e00c0d4a add freeze feature to rundown (#1106)
* add frozen flag to runtime store

* add middleware for when frozen

* prevent rundown editing when frozen

* add endpoint to router to set frozen state

* add frozen property to rundown placeholder in client
2024-07-19 14:14:59 +02:00
Carlos Valente edc7f20d7a refactor: timer update 2024-07-19 14:11:12 +02:00
Carlos Valente cb773ded9f refactor: improve pending detection 2024-07-19 14:11:12 +02:00
Carlos Valente 2f65711078 refactor: dev label 2024-07-19 14:11:12 +02:00
jwetzell 3056b75960 clean out unused demo and test db.json files (#1148)
* remove demo and test db JSON

* remove demo-db reference in Dockerfile

* remove preloaded-db folder in server

* cleanup package.json scripts

* remove db.json references from server
2024-07-18 22:27:24 +02:00
jwetzell dd9423abf4 remove unused dependency 2024-07-18 22:26:58 +02:00
jwetzell dee6a67018 Merge pull request #1146 from cpvalente/chore/pnpm-catalogs
convert shared dependencies to pnpm catalog
2024-07-17 22:23:47 -05:00
jwetzell a79fb4ecbb convert shared dependencies to pnpm catalog 2024-07-17 07:53:43 -05:00
jwetzell a53a1f87be Merge pull request #1145 from cpvalente/chore/remove-husky
remove husky and pre-commit scripts
2024-07-17 07:41:17 -05:00
jwetzell ef9afcbf3e remove husky and pre-commit scripts 2024-07-16 17:57:20 -05:00
Carlos Valente 33b04e01c9 chore: cleanup unused 2024-07-16 21:34:21 +02:00
Carlos Valente c5e821df35 bump version to 3.4.0 2024-07-16 19:21:56 +02:00
jwetzell 0fd71c9a00 align docker-compose example with docker image layout 2024-07-16 19:20:05 +02:00
jwetzell 53b453abc8 change docker data path from /external to /data 2024-07-16 19:20:05 +02:00
Carlos Valente fe0665506e chore: update typescript 2024-07-16 13:18:20 +02:00
Carlos Valente 9bd600c4b1 chore: add watch mode in dev 2024-07-16 13:18:20 +02:00
jwetzell bbd4254637 Merge pull request #1138 from cpvalente/chore/bump-version
bump version to 3.4.0-beta.3
2024-07-15 08:23:16 -05:00
jwetzell 2603c777ef bump version to 3.4.0-beta.3 2024-07-15 07:23:00 -05:00
jwetzell 8942a4ba4f Merge pull request #1137 from cpvalente/fix/buildx-platforms
drop arm/v6 image
2024-07-15 07:20:32 -05:00
jwetzell f97be749dc drop arm/v6 image 2024-07-15 07:18:06 -05:00
98 changed files with 3391 additions and 2928 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
@@ -41,7 +41,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+1 -2
View File
@@ -13,7 +13,7 @@ FROM node:20-alpine
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/external/
ENV ONTIME_DATA=/data/
WORKDIR /app/
@@ -22,7 +22,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/
# Export default ports
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.4.1-beta",
"version": "3.5.0-beta.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -20,8 +20,8 @@
"server"
],
"devDependencies": {
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
}
}
+11 -12
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.4.1-beta",
"version": "3.5.0-beta.2",
"private": true,
"type": "module",
"dependencies": {
@@ -13,7 +13,7 @@
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
@@ -22,9 +22,9 @@
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
@@ -42,7 +42,6 @@
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -70,13 +69,13 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,9 +83,9 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^3.3.1",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "^5.4.3",
"typescript": "catalog:",
"vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
+57 -30
View File
@@ -1,24 +1,36 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -28,41 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<Suspense fallback={null}>
<Routes>
<React.Suspense fallback={null}>
<SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} />
<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/timeline' element={<STimeline />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -99,7 +126,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
</Suspense>
</SentryRoutes>
</React.Suspense>
);
}
@@ -38,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose();
};
const host = `${window.location.origin}/`;
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
return (
@@ -54,7 +54,6 @@ function Overlay() {
};
}, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
@@ -51,6 +51,7 @@ interface EditFormDrawerProps {
viewOptions: ViewOption[];
}
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
@@ -12,9 +12,7 @@ export const useClientPath = () => {
// notify of client path changes
useEffect(() => {
//remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
socketSendJson('set-client-path', pathname + search);
}, [pathname, search]);
// navigate to new path when received from server
+12
View File
@@ -115,6 +115,7 @@ export const setAuxTimer = {
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
@@ -179,6 +180,17 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
+5
View File
@@ -38,6 +38,10 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null,
expectedEnd: null,
},
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
@@ -48,6 +52,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
duration: 0,
playback: SimplePlayback.Stop,
},
frozen: false,
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
+7 -1
View File
@@ -36,7 +36,8 @@ export const connectSocket = () => {
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
socketSendJson('set-client-path', location.pathname + location.search);
};
websocket.onclose = () => {
@@ -150,6 +151,11 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
@@ -34,3 +34,16 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
+29 -6
View File
@@ -1,5 +1,5 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
@@ -9,17 +9,17 @@ import { ontimeQueryClient } from '../queryClient';
* Returns current time in milliseconds
* @returns {number}
*/
export const nowInMillis = () => {
export function nowInMillis(): number {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
let elapsed = now.getHours() * MILLIS_PER_HOUR;
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
elapsed += now.getMilliseconds();
return elapsed;
};
}
/**
* @description Resolves format from url and store
@@ -95,3 +95,26 @@ export const formatTime = (
const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`;
};
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}m`;
}
return result;
}
@@ -21,11 +21,11 @@ interface CuesheetProps {
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
columnOrder,
@@ -114,11 +114,16 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
@@ -128,9 +133,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) {
return null;
@@ -107,6 +107,7 @@ export default function CuesheetWrapper() {
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
);
@@ -170,9 +170,12 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = subscriptions
? subscriptions.map((id) => {
? subscriptions.flatMap((id) => {
if (!customFields[id]) {
return [];
}
const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] };
return [{ id, label, colour, value: entry.custom[id] }];
})
: null;
@@ -30,7 +30,7 @@
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
background-color: var(--operator-running-bg-override, $active-red);
}
&.past {
@@ -99,7 +99,6 @@
display: flex;
flex-wrap: wrap;
.field {
font-weight: 600;
padding-inline: 0.25rem;
@@ -45,7 +45,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
@@ -33,6 +33,7 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
@@ -94,6 +95,14 @@ function TitlesOverview() {
);
}
function CurrentBlockOverview() {
const { currentBlock, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
}
function TimerOverview() {
const { current } = useTimer();
@@ -14,18 +14,6 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
});
return [
{ section: 'View behaviour' },
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{ section: 'Data sources' },
{
id: 'top-src',
@@ -0,0 +1,111 @@
@use '../../../theme/viewerDefs' as *;
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;
.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
}
.timelineEvents {
position: relative;
height: 100%;
}
.column {
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);
// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}
.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}
.timeOverview {
opacity: 0;
}
}
.hide {
// hide text elements
& > div {
display: none;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 0.25rem;
padding-inline-start: 0.25rem;
overflow: hidden;
line-height: 1rem;
background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
}
.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
.status {
color: $active-red;
}
}
&[data-status='future'] {
.status {
color: $green-500;
}
}
}
.cross {
text-decoration: line-through;
}
@@ -0,0 +1,107 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs, getFirstEvent, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import ProgressBar from './timeline-progress-bar/TimelineProgressBar';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
function useTimeline(rundown: OntimeEvent[]) {
const { firstEvent } = getFirstEvent(rundown);
const { lastEvent } = getLastEvent(rundown);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
// we make sure the end accounts for delays
const accumulatedDelay = lastEvent?.delay ?? 0;
// timeline is padded to nearest hours (floor and ceil)
const startHour = getStartHour(firstStart);
const endHour = getEndHour(normalisedLastEnd + accumulatedDelay);
return {
rundown: rundown,
startHour,
endHour,
};
}
interface TimelineProps {
selectedEventId: string | null;
rundown: OntimeEvent[];
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { selectedEventId, rundown: baseRundown } = props;
const { width: screenWidth } = useViewportSize();
const timelineData = useTimeline(baseRundown);
if (timelineData === null) {
return null;
}
const { rundown, startHour, endHour } = timelineData;
let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<ProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}
if (!hasTimelinePassedMidnight) {
// we need to offset the start to account for midnight
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
}
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);
return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // solve issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
export type ProgressStatus = 'done' | 'live' | 'future';
interface TimelineEntryProps {
colour: string;
delay: number;
duration: number;
left: number;
status: ProgressStatus;
start: number;
title: string;
width: number;
}
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntryProps) {
const { colour, delay, duration, left, status, start, title, width } = props;
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const delayedStart = start + delay;
const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25;
return (
<div
className={columnClasses}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
}}
>
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{showTitle && <div>{title}</div>}
</div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && (
<>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</>
)}
</div>
</div>
);
}
interface TimelineEntryStatusProps {
status: ProgressStatus;
start: number;
}
// we isolate this component to avoid isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { status, start } = props;
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
}
return <div className={style.status}>{statusText}</div>;
}
@@ -0,0 +1,24 @@
.timeline {
width: 100vw;
height: 100vh;
background-color: $ui-black;
color: $ui-white;
display: flex;
flex-direction: column;
gap: 2rem;
}
.title {
padding-inline: 2rem;
font-size: 3.5rem;
}
.sections {
padding-inline: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
column-gap: 3rem;
}
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
import style from './TimelinePage.module.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
events: OntimeEvent[];
general: ProjectData;
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
}
/**
* since we inherit from viewPage
* which refreshes at least once a second
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { events, general, selectedId, settings, time } = props;
const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock);
const scopedRundown = useMemo(() => {
return getScopedRundown(events, selectedId);
}, [events, selectedId]);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat);
const titleNow = now?.title ?? '-';
const dueText = getLocalizedString('timeline.due');
const nextText = next !== null ? `${next.title} · ${getFormattedTimeToStart(next, time.clock, dueText)}` : '-';
const followedByText =
followedBy !== null ? `${followedBy.title} · ${getFormattedTimeToStart(followedBy, time.clock, dueText)}` : '-';
return (
<div className={style.timeline}>
<ViewParamsEditor viewOptions={progressOptions} />
<div className={style.title}>{general.title}</div>
<div className={style.sections}>
<Section title={getLocalizedString('common.time_now')} content={clock} category='now' />
<Section title={getLocalizedString('common.next')} content={nextText} category='next' />
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
<Section title={getLocalizedString('timeline.followedby')} content={followedByText} category='next' />
</div>
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
</div>
);
}
@@ -0,0 +1,66 @@
import { dayInMs } from 'ontime-utils';
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
});
});
describe('makeTmelineSections', () => {
it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17);
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
});
it('wraps around midnight', () => {
const result = makeTimelineSections(22, 26);
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
});
});
@@ -0,0 +1,16 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
margin-bottom: 0.25rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid $white-7;
height: 100vh;
}
}
@@ -0,0 +1,22 @@
import { makeTimelineSections } from '../timeline.utils';
import style from './TimelineMarkers.module.scss';
interface TimelineMarkersProps {
startHour: number;
endHour: number;
}
export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props;
const elements = makeTimelineSections(startHour, endHour);
return (
<div className={style.markers}>
{elements.map((tag) => {
return <span key={tag}>{tag}</span>;
})}
</div>
);
}
@@ -0,0 +1,13 @@
.progressBar {
width: 100%;
height: 1rem;
transition-duration: 0.3s;
transition-property: left;
background-color: $gray-1000;
.progress {
height: 100%;
background-color: $active-red;
}
}
@@ -0,0 +1,24 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useClock } from '../../../../common/hooks/useSocket';
import { getRelativePositionX } from '../timeline.utils';
import style from './TimelineProgressBar.module.scss';
interface ProgressBarProps {
startHour: number;
endHour: number;
}
export default function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
const { clock } = useClock();
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
return (
<div className={style.progressBar}>
<div className={style.progress} style={{ width: `${width}%` }} />
</div>
);
}
@@ -0,0 +1,25 @@
.sectionTitle {
line-height: 1.2em;
font-size: 1.5rem;
text-transform: uppercase;
}
.sectionContent {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
&.now {
color: $red-500;
}
&.next {
color: $green-500;
}
&.subdue {
opacity: $opacity-disabled;
}
}
@@ -0,0 +1,23 @@
import { MaybeString } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TimelineSection.module.scss';
interface SectionProps {
category: 'now' | 'next';
content: MaybeString;
title: string;
}
export default function Section(props: SectionProps) {
const { category, content, title } = props;
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
return (
<div>
<div className={style.sectionTitle}>{title}</div>
<div className={contentClasses}>{content ?? '-'}</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -0,0 +1,154 @@
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types';
import {
dayInMs,
getEventWithId,
getFirstEvent,
getNextEvent,
MILLIS_PER_HOUR,
millisToString,
removeSeconds,
} from 'ontime-utils';
import { clamp } from '../../../common/utils/math';
import { formatDuration } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
import type { ProgressStatus } from './TimelineEntry';
type CSSPosition = {
left: number;
width: number;
};
/**
* Calculates the position (in %) of an element relative to a schedule
*/
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
}
/**
* Calculates an absolute position of an element based on a schedule
*/
export function getElementPosition(
scheduleStart: number,
scheduleEnd: number,
eventStart: number,
eventDuration: number,
containerWidth: number,
): CSSPosition {
const normalEnd = scheduleEnd < scheduleStart ? scheduleEnd + dayInMs : scheduleEnd;
const totalDuration = normalEnd - scheduleStart;
const width = (eventDuration * containerWidth) / totalDuration;
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
return { left, width };
}
/**
* Gets rounded down hour for a given time
*/
export function getStartHour(startTime: number): number {
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
return hours;
}
/**
* Gets rounded up hour for a given time
*/
export function getEndHour(endTime: number): number {
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
return hours;
}
/**
* converts a time span into an array of hours
*/
export function makeTimelineSections(firstHour: number, lastHour: number) {
const timelineSections = [];
for (let i = firstHour; i < lastHour; i++) {
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
}
return timelineSections;
}
/**
* Returns a formatted label for a progress status
*/
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
if (status === 'done' || status === 'live') {
return status;
}
if (timeToStart < 0) {
return 'pending';
}
return formatDuration(timeToStart);
}
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
if (rundown.length === 0) {
return [];
}
const params = new URL(document.location.href).searchParams;
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
const hidePast = isStringBoolean(params.get('hidePast'));
let scopedRundown = [...rundown];
if (hidePast && selectedEventId) {
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
if (currentIndex >= 0) {
scopedRundown = scopedRundown.slice(currentIndex);
}
}
if (hideBackstage) {
scopedRundown = scopedRundown.filter((event) => event.isPublic);
}
return scopedRundown;
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent;
if (!isOntimeEvent(now)) {
return { now: null, next: null, followedBy: null };
}
const next = getNextEvent(events, now.id)?.nextEvent;
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
// Return the titles, handling nulls appropriately
return {
now,
next,
followedBy,
};
}
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
const timeToStart = event.timeStart - now;
if (timeToStart < 0) {
return dueText;
}
return `T - ${formatDuration(timeToStart)}`;
}
-38
View File
@@ -1,51 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import App from './App';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import './index.scss';
const container = document.getElementById('root');
const root = createRoot(container as Element);
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
Sentry.init({
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
root.render(
<StrictMode>
<App />
+25
View File
@@ -0,0 +1,25 @@
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
export const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
export const sentryDsn = 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360';
@@ -7,6 +7,7 @@ $white-9: rgba(255, 255, 255, 0.09);
$white-10: rgba(255, 255, 255, 0.10);
$white-13: rgba(255, 255, 255, 0.13);
$white-20: rgba(255, 255, 255, 0.20);
$white-40: rgba(255, 255, 255, 0.40);
$white-60: rgba(255, 255, 255, 0.60);
$white-90: rgba(255, 255, 255, 0.90);
+1
View File
@@ -15,6 +15,7 @@ $ontime-color: #ff7597;
$error-red: $red-500;
$warning-orange: $orange-500;
$opacity-disabled: 0.4;
$active-red: $red-700;
// playback colours
$playback-start: $green-600;
@@ -19,4 +19,8 @@ export const langDe: TranslationObject = {
'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
'countdown.overtime': 'überfällig',
'timeline.live': 'live',
'timeline.done': 'Beendet',
'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von',
};
@@ -17,6 +17,10 @@ export const langEn = {
'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start',
'countdown.overtime': 'in overtime',
'timeline.live': 'live',
'timeline.done': 'done',
'timeline.due': 'due',
'timeline.followedby': 'Followed by',
};
export type TranslationObject = Record<keyof typeof langEn, string>;
@@ -19,4 +19,8 @@ export const langEs: TranslationObject = {
'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento',
'countdown.overtime': 'en tiempo extra',
'timeline.live': 'live',
'timeline.done': 'Terminado',
'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por',
};
@@ -19,4 +19,8 @@ export const langFr: TranslationObject = {
'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement',
'timeline.live': 'live',
'timeline.done': 'Terminé',
'timeline.due': 'dû',
'timeline.followedby': 'Suivi de',
};
@@ -19,4 +19,8 @@ export const langIt: TranslationObject = {
'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento",
'countdown.overtime': 'in ritardo',
'timeline.live': 'live',
'timeline.done': 'Terminato',
'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da',
};
@@ -19,4 +19,8 @@ export const langNo: TranslationObject = {
'countdown.to_start': 'Tid til start',
'countdown.waiting': 'Venter på start',
'countdown.overtime': 'i overtiden',
'timeline.live': 'live',
'timeline.done': 'Ferdig',
'timeline.due': 'Venter',
'timeline.followedby': 'Etterfulgt av',
};
@@ -19,4 +19,8 @@ export const langPl: TranslationObject = {
'countdown.to_start': 'Do rozpoczęcia',
'countdown.waiting': 'Oczekiwanie na start',
'countdown.overtime': 'ponad czasem',
'timeline.live': 'live',
'timeline.done': 'Zakończony',
'timeline.due': 'termin',
'timeline.followedby': 'Następnie',
};
@@ -19,4 +19,8 @@ export const langPt: TranslationObject = {
'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento',
'countdown.overtime': 'em tempo extra',
'timeline.live': 'live',
'timeline.done': 'Concluído',
'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por',
};
@@ -19,4 +19,8 @@ export const langSv: TranslationObject = {
'countdown.to_start': 'Tid till start',
'countdown.waiting': 'Väntar på att evenemanget ska starta',
'countdown.overtime': 'i övertid',
'timeline.live': 'live',
'timeline.done': 'Avslutad',
'timeline.due': 'Väntande',
'timeline.followedby': 'Följt av',
};
+1
View File
@@ -3,6 +3,7 @@ export const navigatorConstants = [
{ url: '/clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' },
{ url: '/timeline', label: 'Timeline' },
{ url: '/public', label: 'Public' },
{ url: '/lower', label: 'Lower Thirds' },
{ url: '/studio', label: 'Studio Clock' },
+4 -1
View File
@@ -1,6 +1,7 @@
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
const path = require('path');
const electronConfig = require('./electron.config');
const { version } = require('./package.json');
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
const env = process.env.NODE_ENV || 'production';
@@ -187,7 +188,9 @@ app.whenReady().then(() => {
? electronConfig.reactAppUrl.production(port)
: electronConfig.reactAppUrl.development(port);
const template = getApplicationMenu(isMac, askToQuit, clientUrl);
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
win.loadURL(`${clientUrl}/${path}`);
});
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.4.1-beta",
"version": "3.5.0-beta.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -14,15 +14,14 @@
"devDependencies": {
"electron": "^31.2.0",
"electron-builder": "^24.13.3",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.0.3",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:",
"wait-on": "^7.2.0"
},
"scripts": {
"postinstall": "",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
+24 -9
View File
@@ -4,7 +4,7 @@ const { shell } = require('electron');
* @param {boolean} isMac - Whether the target platform is mac
* @param {function} askToQuit - function for quitting process
*/
function getApplicationMenu(isMac, askToQuit, urlBase) {
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
return [
...(isMac
? [
@@ -59,6 +59,19 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{
label: 'Ontime Views (opens in browser)',
submenu: [
{
label: 'Public',
click: async () => {
await shell.openExternal(`${urlBase}/public`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
},
},
{ type: 'separator' },
{
label: 'Timer',
accelerator: 'CmdOrCtrl+V',
@@ -85,15 +98,9 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
},
},
{
label: 'Public',
label: 'Timeline',
click: async () => {
await shell.openExternal(`${urlBase}/public`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
await shell.openExternal(`${urlBase}/timeline`);
},
},
{
@@ -150,6 +157,14 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{
role: 'help',
submenu: [
{
label: 'About',
click: () => redirectWindow('editor?settings=about'),
},
{
label: version,
click: () => redirectWindow('editor?settings=about'),
},
{
label: 'See on github',
click: async () => {
+17 -21
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.4.1-beta",
"version": "3.5.0-beta.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -27,41 +27,37 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.10",
"@types/node": "catalog:",
"@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"esbuild": "^0.19.10",
"eslint": "^8.56.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint": "catalog:",
"eslint-plugin-prettier": "catalog:",
"ontime-types": "workspace:*",
"prettier": "^3.3.1",
"prettier": "catalog:",
"server-timing": "^3.3.3",
"shx": "^0.3.4",
"ts-essentials": "^9.4.1",
"tsx": "^4.16.2",
"typescript": "^5.4.3",
"typescript": "catalog:",
"vitest": "^1.6.0"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json",
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject",
"dev": "cross-env NODE_ENV=development tsx ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx --inspect ./src/index.ts",
"postinstall": "pnpm addversion",
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "pnpm set:demoproject",
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
"build:local": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
"build:localdocker": "cross-env NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
"build": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:electron": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:local": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:docker": "esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
"build:localdocker": "cross-env NODE_ENV=local esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
"build:debug": "esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run",
"typecheck": "tsc --noEmit",
@@ -1,4 +1,11 @@
import { ErrorResponse, MessageResponse, OntimeRundownEntry, RundownCached, RundownPaginated } from 'ontime-types';
import {
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
@@ -12,6 +19,7 @@ import {
deleteEvent,
editEvent,
reorderEvent,
setFrozenState,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import {
@@ -21,6 +29,11 @@ import {
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
@@ -114,6 +127,17 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
}
}
export async function rundownFrozenPost(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { frozen } = req.body;
setFrozenState(frozen);
res.status(200).send({ message: 'Rundown frozen state updated.' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
@@ -0,0 +1,9 @@
import { eventStore } from '../../stores/EventStore.js';
export const preventIfFrozen = function (req, res, next) {
if (eventStore.get('frozen')) {
res.status(403).send({ message: 'Rundown is frozen' });
} else {
next();
}
};
@@ -5,6 +5,8 @@ import {
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownFrozenPost,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
rundownGetPaginated,
@@ -17,27 +19,31 @@ import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownFrozenPostValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
import { preventIfFrozen } from './rundown.middleware.js';
export const router = express.Router();
router.get('/', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
router.post('/frozen', rundownFrozenPostValidator, rundownFrozenPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/reorder/', rundownReorderValidator, preventIfFrozen, rundownReorder);
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
router.delete('/all', preventIfFrozen, rundownDelete);
@@ -21,6 +21,15 @@ export const rundownPutValidator = [
},
];
export const rundownFrozenPostValidator = [
body('frozen').isBoolean().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
+13 -1
View File
@@ -19,7 +19,7 @@ import {
resolvePublicDirectoy,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -179,6 +179,10 @@ export const startServer = async (
message: messageService.getState(),
runtime: state.runtime,
eventNow: state.eventNow,
currentBlock: {
block: null,
startedAt: null,
},
publicEventNow: state.publicEventNow,
eventNext: state.eventNext,
publicEventNext: state.publicEventNext,
@@ -188,6 +192,7 @@ export const startServer = async (
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
frozen: false,
});
// initialise logging service, escalateErrorFn is only exists in electron
@@ -263,6 +268,7 @@ export const shutdown = async (exitCode = 0) => {
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
@@ -279,12 +285,18 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
@@ -13,24 +13,19 @@ import {
import type { Low } from 'lowdb';
import { JSONFilePreset } from 'lowdb/node';
import { isProduction, isTest } from '../../setup/index.js';
import { isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
import { safeMerge } from './DataProvider.utils.js';
import { shouldCrashDev } from '../../utils/development.js';
type ReadonlyPromise<T> = Promise<Readonly<T>>;
let db = {} as Low<DatabaseModel>;
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
if (!isProduction) {
if (!isPath(filePath)) {
consoleError(filePath);
consoleError(new Error('initPersistence should be called with a path').stack);
process.exit(0);
}
}
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
// Read the database to initialize it
-1
View File
@@ -1 +0,0 @@
This directory holds the demo file shipped with Ontime
@@ -1,21 +1,23 @@
import { OntimeEvent } from 'ontime-types';
import { OntimeRundown } from 'ontime-types';
import * as runtimeState from '../stores/runtimeState.js';
import type { UpdateResult } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
type UpdateCallbackFn = (updateResult: UpdateResult) => void;
/**
* Service manages Ontime's main timer
*/
export class TimerService {
export class EventTimer {
private readonly _interval: NodeJS.Timeout;
/** how often we recalculate */
static _refreshInterval: number;
/** when timer will be finished */
private endCallback: NodeJS.Timeout;
private endCallback: NodeJS.Timeout | undefined = undefined;
private onUpdateCallback: (updateResult: UpdateResult) => void;
private onUpdateCallback: UpdateCallbackFn | undefined = undefined;
/**
* @constructor
@@ -23,17 +25,18 @@ export class TimerService {
* @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;
onUpdateCallback: (updateResult: UpdateResult) => void;
}) {
TimerService._refreshInterval = timerConfig.refresh;
this.onUpdateCallback = timerConfig.onUpdateCallback;
constructor(timerConfig: { refresh: number; updateInterval: number }) {
EventTimer._refreshInterval = timerConfig.refresh;
this._interval = setInterval(() => {
this.update();
}, TimerService._refreshInterval);
}, EventTimer._refreshInterval);
}
/**
* Allows setting a callback for when the timer updates
*/
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
this.onUpdateCallback = callback;
}
start() {
@@ -69,7 +72,6 @@ export class TimerService {
/**
* Adds time to running timer by given amount
* @param {number} amount
*/
addTime(amount: number): boolean {
if (!runtimeState.addTime(amount)) {
@@ -79,6 +81,12 @@ export class TimerService {
// renew end callback
clearTimeout(this.endCallback);
const state = runtimeState.getState();
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.timer.expectedFinish === null) {
throw new Error('TimerService.addTime: expectedFinish is negative');
}
}
this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
return true;
}
@@ -89,15 +97,14 @@ export class TimerService {
update() {
const updateResult = runtimeState.update();
// pass the result to the parent
this.onUpdateCallback(updateResult);
this.onUpdateCallback?.(updateResult);
}
/**
* Loads roll information into timer service
* @param {OntimeEvent[]} rundown -- list of events to run
*/
roll(rundown: OntimeEvent[]) {
runtimeState.roll(rundown);
roll(rundown: OntimeRundown) {
return runtimeState.roll(rundown);
}
shutdown() {
@@ -0,0 +1,448 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
};
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
describe('loadRoll()', () => {
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,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('should roll to the day after if timer is at 100', () => {
const now = 100;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should be waiting to start if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the first event if timer is at 5', () => {
const now = 5;
const expected = {
event: timedEvents[0],
index: 0,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the second event if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the third event if timer is at 10', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the fifth event if timer is at 49', () => {
const now = 49;
const expected = {
event: timedEvents[4],
index: 4,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the seventh event if timer is at 63', () => {
const now = 63;
const expected = {
event: timedEvents[6],
index: 6,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the eight event if timer is at 75', () => {
const now = 75;
const expected = {
event: timedEvents[7],
index: 7,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with midnight', () => {
it('should find an event that crosses midnight', () => {
const now = 23 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '1',
timeStart: 20 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '3',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
isPublic: true,
},
{
id: '4',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should not skip to the second day', () => {
/**
* NOTE: this is a potentially contentious decision
*
* The idea here is that it makes no sense for us to jump to the second / third day on activating roll
* if the user wants to skip a portion of the rundown, they can manually jump to the event and activate roll
*
* On our side, this simplifies logic and makes behaviour more predictable
*/
const now = 8 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 21 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
},
{
id: '1',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 3 * MILLIS_PER_HOUR,
},
{
id: '2',
timeStart: 3 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with before and after start', () => {
it('should prepare first event, if we are not yet in the rundown start', () => {
const now = 7 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should prepare first event, if we are over the rundown end', () => {
const now = 18 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should account for a rundown that goes through midnight', () => {
const now = 1 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
};
const state = loadRoll(singleEventList, now);
expect(state.isPending).toBeUndefined();
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const now = 6000; // 00:01
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() 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,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10, it ignores events with 0 duration', () => {
const now = 10;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
// issue #58
describe('loadRoll() test that roll behaviour multi day event edge cases', () => {
it('should recognise a playing event where its schedule spans over midnight', () => {
const now = 66600000; // 19:30
const eventlist = [
makeOntimeEvent({
id: '1',
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(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 = [
makeOntimeEvent({
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
@@ -1,16 +1,14 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
updateRoll,
} from '../timerUtils.js';
import { RuntimeState } from '../../stores/runtimeState.js';
@@ -697,520 +695,6 @@ describe('skippedOutOfEvent()', () => {
});
});
describe('getRollTimers()', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 100 we roll to day after', () => {
const now = 100;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timeToNext: dayInMs - now + eventlist[0].timeStart!,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[4],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
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: dayInMs - now + singleEventList[0].timeStart!,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 36000000, // 10:00
timeEnd: 3600000, // 01:00
isPublic: true,
},
];
const now = 60000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 36000000, // 10:00
timeEnd: 3600000, // 01:00
isPublic: true,
},
];
const now = 60000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: 72000000 - now,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 60000, // 00:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
describe('getRollTimers() test that roll behaviour with overlapping times', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], 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 as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
// issue #58
describe('getRollTimers() 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: Partial<OntimeEvent>[] = [
{
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 as OntimeEvent[], 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: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
},
];
const expected = {
currentEvent: {
id: '1',
isPublic: false,
timeEnd: 66900000,
timeStart: 67200000,
},
currentPublicEvent: null,
nextEvent: null,
nextIndex: null,
nextPublicEvent: null,
nowId: '1',
nowIndex: 0,
publicIndex: null,
publicNextIndex: null,
timeToNext: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
test('normaliseEndTime()', () => {
const t1 = {
start: 10,
@@ -1237,196 +721,6 @@ test('normaliseEndTime()', () => {
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
describe('updateRoll()', () => {
it('it updates running events correctly', () => {
const timers = {
eventNow: {
id: '1',
},
clock: 11,
timer: {
current: 10,
expectedFinish: 100,
secondaryTimer: null,
startedAt: 1,
},
_timer: {
secondaryTarget: null,
},
} as RuntimeState;
const expected = {
updatedTimer: 100 - 11,
updatedSecondaryTimer: null, // usually clock - expectedFinish
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers.timer.expectedFinish = 1000;
timers.clock = 600;
expected.updatedTimer = 1000 - 600;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
eventNow: null,
clock: 11,
timer: {
current: null,
expectedFinish: null,
secondaryTimer: 1,
},
_timer: {
secondaryTarget: 15,
},
} as RuntimeState;
const expected = {
updatedTimer: null,
updatedSecondaryTimer: 15 - 11, // countdown to secondary
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
eventNow: {
id: '1',
},
clock: 12,
timer: {
startedAt: 0,
current: 10,
expectedFinish: 11,
secondaryTimer: null,
},
_timer: {
secondaryTarget: null,
},
} as RuntimeState;
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 = {
eventNow: null,
clock: 16,
timer: {
startedAt: null,
current: null,
expectedFinish: null,
secondaryTimer: 1,
},
_timer: {
secondaryTarget: 15,
},
} as RuntimeState;
const expected = {
updatedTimer: null,
updatedSecondaryTimer: -1,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
eventNow: null,
clock: 15,
timer: {
current: null,
expectedFinish: null,
secondaryTimer: 0,
},
_timer: {
secondaryTarget: 15,
},
} as RuntimeState;
const expected = {
updatedTimer: null,
updatedSecondaryTimer: 0,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('counts over midnight', () => {
const timers = {
eventNow: {
id: '1',
},
clock: dayInMs - 10,
timer: {
current: 25,
expectedFinish: 10,
startedAt: 1000,
secondaryTimer: null,
},
_timer: {
secondaryTarget: null,
},
} as RuntimeState;
const expected = {
updatedTimer: 20,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('rolls over midnight', () => {
const timers = {
eventNow: {
id: '1',
},
clock: 10,
timer: {
current: dayInMs,
expectedFinish: 10,
startedAt: 1000,
secondaryTimer: null,
},
_timer: {
secondaryTarget: null,
},
} as RuntimeState;
const expected = {
updatedTimer: dayInMs,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
describe('getRuntimeOffset()', () => {
it('is the difference between scheduled and when we actually started', () => {
const state = {
@@ -1554,7 +848,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null,
startedAt: null,
},
_timer: { pausedAt: null, secondaryTarget: null },
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
@@ -1595,7 +889,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null,
startedAt: null,
},
_timer: { pausedAt: null, secondaryTarget: null },
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
@@ -1630,7 +924,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: null,
offset: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
@@ -1647,7 +941,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null,
startedAt: 78000000,
},
_timer: { pausedAt: null, secondaryTarget: null },
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
@@ -1682,7 +976,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: null,
offset: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
@@ -1699,7 +993,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null,
startedAt: 78000000,
},
_timer: { pausedAt: null, secondaryTarget: null },
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
@@ -1722,7 +1016,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: null,
offset: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 82000000, // 22:46:40 <--- started now
@@ -1739,7 +1033,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null,
startedAt: 82000000, // <--- started now
},
_timer: { pausedAt: null, secondaryTarget: null },
_timer: { pausedAt: null },
} as RuntimeState;
const updateCurrent = getCurrent(state);
@@ -1887,7 +1181,7 @@ describe('getTimerPhase()', () => {
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: null,
offset: 0,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
@@ -1909,7 +1203,6 @@ describe('getTimerPhase()', () => {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: 55860000,
},
} as RuntimeState;
@@ -1927,7 +1220,7 @@ describe('getTimerPhase()', () => {
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: null,
offset: 0,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
@@ -1949,7 +1242,6 @@ describe('getTimerPhase()', () => {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: 55860000,
},
} as RuntimeState;
@@ -1,9 +1,9 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
import { appStatePath, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
import { shouldCrashDev } from '../../utils/development.js';
interface AppState {
lastLoadedProject?: string;
@@ -27,13 +27,8 @@ export async function getLastLoadedProject(): Promise<string | undefined> {
export async function setLastLoadedProject(filename: string): Promise<void> {
if (isTest) return;
if (!isProduction) {
if (isPath(filename)) {
consoleError(filename);
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
process.exit(0);
}
}
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: shouldCrashDev(isPath(filename), 'setLastLoadedProject should not be called with a path');
config.data.lastLoadedProject = filename;
await config.write();
+93
View File
@@ -0,0 +1,93 @@
import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types';
import { normaliseEndTime } from './timerUtils.js';
/**
* Finds current event in a rolling rundown
*/
export function loadRoll(
timedEvents: OntimeEvent[],
timeNow: number,
): {
event: PlayableEvent | null;
index: MaybeNumber;
isPending?: boolean;
} {
const { firstEvent } = getFirstEvent(timedEvents);
const { lastEvent } = getLastEvent(timedEvents);
if (!firstEvent || !lastEvent) {
return { event: null, index: null };
}
// check that the rundown wraps around midnight
const wrapsAroundMidnight = firstEvent.timeStart > lastEvent.timeEnd;
if (!wrapsAroundMidnight) {
// check whether we are before or after the rundown
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
const isAfterRundown = timeNow > lastNormalEnd;
const isBeforeRundown = timeNow < firstEvent.timeStart && !isAfterRundown;
if (isAfterRundown || isBeforeRundown) {
return { event: firstEvent, index: 0, isPending: true };
}
}
// we know we are in the middle of the rundown and we need to find the current event
// account for number of times we went over midnight
let daySpan = 0;
for (let i = 0; i < timedEvents.length; i++) {
const event = timedEvents[i];
if (!isPlayableEvent(event)) {
continue;
}
// we check if event crosses midnight
if (event.timeStart > event.timeEnd) {
daySpan++;
}
const correctedDays = dayInMs * daySpan;
const correctedStart = event.timeStart + correctedDays;
const correctedEnd = event.timeEnd + correctedDays;
/**
* there are 3 possible states for an event
* 1. event is already finished
* 2. event is running
* 3. event is in the future
*/
// 1. event is already finished
// when does the event end (handle midnight)
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
if (normalEnd <= timeNow) {
continue;
}
// 2. event is running and is the first event in our time slot
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (hasStarted) {
return { event, index: i };
}
// 3. event will run in the future
// we set the isPending flag to indicate that the event is currently playing
return { event, index: i, isPending: true };
}
// in case we were unable to find anything, we load the first event
return { event: firstEvent, index: 0, isPending: true };
}
/**
* Utility function, checks whether the event start is the day after
*/
export function normaliseRollStart(start: number, clock: number) {
return start < clock ? start + dayInMs : start;
}
@@ -20,17 +20,19 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents } from './rundownUtils.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
import { eventStore } from '../../stores/EventStore.js';
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
type CompleteEntry<T> = T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
@@ -213,8 +215,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateRuntimeOnChange() {
const playableEvents = getPlayableEvents();
const numEvents = playableEvents.length;
const timedEvents = getTimedEvents();
const numEvents = timedEvents.length;
const metadata = cache.getMetadata();
// schedule an update for the end of the event loop
@@ -239,7 +241,7 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
// notify timer service of changed events
// timer can be true or an array of changed IDs
const affected = Array.isArray(options.timer) ? options.timer : undefined;
runtimeService.maybeUpdate(playableEvents, affected);
runtimeService.maybeUpdate(affected);
}
}
@@ -262,3 +264,7 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
// notify timer of change
notifyChanges({ timer: true });
}
export async function setFrozenState(state: boolean) {
eventStore.set('frozen', state);
}
@@ -1,14 +1,17 @@
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types';
import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types';
import { filterPlayable, filterTimedEvents } from 'ontime-utils';
import * as cache from './rundownCache.js';
/**
* returns the normalised rundown
*/
export function getNormalisedRundown(): RundownCached {
return cache.get();
}
/**
* returns entire unfiltered rundown
* @return {array}
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
@@ -16,32 +19,20 @@ export function getRundown(): OntimeRundown {
/**
* returns all events of type OntimeEvent
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
return filterTimedEvents(getRundown());
}
/**
* 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;
export function getPlayableEvents(): PlayableEvent[] {
return filterPlayable(getRundown());
}
/**
* 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();
@@ -50,8 +41,6 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
/**
* returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeRundownEntry | undefined {
const rundown = getRundown();
@@ -60,17 +49,14 @@ export function getEventWithId(eventId: string): OntimeRundownEntry | undefined
/**
* returns first event that matches a given cue
* @param {string} targetCue
* @param {number} currentEventIndex
* @return {object | undefined}
*/
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const timedEvents = getPlayableEvents();
const playableEvents = getPlayableEvents();
const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < timedEvents.length; i++) {
const event = timedEvents.at(i);
if (event && event.cue.toLowerCase() === lowerCaseCue) {
for (let i = currentEventIndex; i < playableEvents.length; i++) {
const event = playableEvents.at(i);
if (event?.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
@@ -78,43 +64,41 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
/**
* finds the previous event
* @return {object | undefined}
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
const playableEvents = getPlayableEvents();
if (!playableEvents || !playableEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
return playableEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = timedEvents.at(newIndex) ?? null;
const previousEvent = playableEvents.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) {
export function findNext(currentEventId?: string): PlayableEvent | null {
const playableEvents = getPlayableEvents();
if (!playableEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
return playableEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const newIndex = currentIndex + 1;
const nextEvent = timedEvents.at(newIndex);
const nextEvent = playableEvents.at(newIndex);
return nextEvent ?? null;
}
@@ -1,6 +1,7 @@
import {
EndAction,
isOntimeEvent,
isPlayableEvent,
LogOrigin,
MaybeNumber,
OntimeEvent,
@@ -19,7 +20,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { timerConfig } from '../../config/config.js';
import { eventStore } from '../../stores/EventStore.js';
import { TimerService } from '../TimerService.js';
import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import {
findNext,
@@ -27,28 +28,34 @@ import {
getEventAtIndex,
getNextEventWithCue,
getEventWithId,
getPlayableEvents,
getRundown,
getTimedEvents,
} from '../rundown-service/rundownUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
/**
* Service manages runtime status of app
* Coordinating with necessary services
*/
class RuntimeService {
private eventTimer: TimerService | null = null;
private eventTimer: EventTimer;
private lastIntegrationClockUpdate = -1;
private lastIntegrationTimerValue = -1;
/** last time we updated the socket */
static previousTimerUpdate: number;
static previousTimerValue: MaybeNumber;
static previousTimerValue: MaybeNumber; // previous timer value, could be null
static previousClockUpdate: number;
/** last known state */
static previousState: RuntimeState;
constructor() {
constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer;
RuntimeService.previousTimerUpdate = -1;
RuntimeService.previousTimerValue = -1;
RuntimeService.previousClockUpdate = -1;
@@ -57,16 +64,55 @@ class RuntimeService {
/** Checks result of an update and notifies integrations as needed */
@broadcastResult
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState();
if (hasTimerFinished) {
// 1. find if we need to dispatch integrations related to the phase
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
if (timerPhaseChanged) {
if (newState.timer.phase === TimerPhase.Warning) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onWarning);
});
} else if (newState.timer.phase === TimerPhase.Danger) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onDanger);
});
}
}
// 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) {
// check if we need to call any side effects
if (hasSecondaryTimerFinished) {
// if the secondary timer has finished, we need to call roll
// since event is already loaded
this.rollLoaded();
} else if (hasTimerFinished) {
// if the timer has finished, we need to load next and keep rolling
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
this.loadNext();
this.rollLoaded();
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
// if we have skipped out of the event, we will recall roll
// to push the playback to the right place
// this comes with the caveat that we will lose our runtime data
this.roll(true);
}
}
// 3. find if we need to process actions related to the timer finishing
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
process.nextTick(() => {
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) {
if (newState.eventNow.endAction === EndAction.Stop) {
setTimeout(this.stop.bind(this), 0);
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
@@ -77,10 +123,8 @@ class RuntimeService {
}
}
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
const shouldUpdateTimer =
hasRunningTimer && getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
// 4. find if we need to update the timer
const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
if (shouldUpdateTimer) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onUpdate);
@@ -89,6 +133,7 @@ class RuntimeService {
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
}
// 5. find if we need to update the clock
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
if (shouldUpdateClock) {
process.nextTick(() => {
@@ -97,42 +142,12 @@ class RuntimeService {
this.lastIntegrationClockUpdate = newState.clock;
}
if (shouldCallRoll) {
// we dont call this.roll because we need to bypass the checks
const rundown = getPlayableEvents();
this.eventTimer.roll(rundown);
}
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
if (timerPhaseChanged) {
switch (newState.timer.phase) {
case TimerPhase.Warning:
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onWarning);
});
break;
case TimerPhase.Danger:
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onDanger);
});
break;
default:
break;
}
}
}
/** delay initialisation until we have a restore point */
init(resumable: RestorePoint | null) {
logger.info(LogOrigin.Server, 'Runtime service started');
// calculate at 30fps, refresh at 1fps
this.eventTimer = new TimerService({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult),
});
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
if (resumable) {
this.resume(resumable);
@@ -164,7 +179,7 @@ class RuntimeService {
}
private isNewNext() {
const timedEvents = getPlayableEvents();
const timedEvents = getTimedEvents();
const state = runtimeState.getState();
const now = state.eventNow?.id;
const next = state.eventNext?.id;
@@ -205,7 +220,7 @@ class RuntimeService {
* Called when the underlying data has changed,
* we check if the change affects the runtime
*/
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
maybeUpdate(affectedIds?: string[]) {
const state = runtimeState.getState();
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
if (!hasLoadedElements) {
@@ -220,45 +235,47 @@ class RuntimeService {
// 3. the edited event replaces next event
let isNext = false;
// TODO: review logic
if (safeOption || eventInMemory) {
if (state.timer.playback === Playback.Roll) {
this.roll();
}
// load stuff again, but keep running if our events still exist
const eventNow = getEventWithId(state.eventNow.id);
if (!isOntimeEvent(eventNow)) {
if (state.eventNow !== null) {
// load stuff again, but keep running if our events still exist
const eventNow = getEventWithId(state.eventNow.id);
if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) {
return;
}
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
const rundown = getRundown();
runtimeState.reloadAll(rundown);
}
return;
}
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
runtimeState.reloadAll(eventNow, playableEvents);
}
return;
}
// Maybe the event will become the next
isNext = this.isNewNext();
if (isNext) {
runtimeState.loadNext(playableEvents);
const timedEvents = getTimedEvents();
runtimeState.loadNext(timedEvents);
}
}
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @param {PlayableEvent} event
* @return {boolean} success - whether an event was loaded
*/
@broadcastResult
loadEvent(event: OntimeEvent): boolean {
if (event.skip) {
if (!isPlayableEvent(event)) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false;
}
const timedEvents = getPlayableEvents();
const success = runtimeState.load(event, timedEvents);
const rundown = getRundown();
const success = runtimeState.load(event, rundown);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -392,14 +409,16 @@ class RuntimeService {
*/
@broadcastResult
start(): boolean {
const state = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start;
const previousState = runtimeState.getState();
const canStart = validatePlayback(previousState.timer.playback).start;
if (!canStart) {
return false;
}
const didStart = this.eventTimer?.start() ?? false;
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
if (didStart) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
@@ -490,28 +509,56 @@ class RuntimeService {
}
}
/**
* Handles special case to call roll on a loaded event which we do not want to discard
*/
rollLoaded() {
const rundown = getRundown();
try {
this.eventTimer.roll(rundown);
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
}
}
/**
* Sets playback to roll
*/
@broadcastResult
roll() {
const beforeState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll;
if (!canRoll) {
roll(skipCheck: boolean = false) {
const previousState = runtimeState.getState();
if (!skipCheck) {
const canRoll = validatePlayback(previousState.timer.playback).roll;
if (!canRoll) {
return;
}
}
try {
const rundown = getRundown();
const result = this.eventTimer.roll(rundown);
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
if (result.didStart) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
});
}
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
return;
}
const playableEvents = getPlayableEvents();
if (playableEvents.length === 0) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
return;
const newState = runtimeState.getState();
if (previousState.timer.playback !== newState.timer.playback) {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
}
this.eventTimer.roll(playableEvents);
const state = runtimeState.getState();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
/**
@@ -531,14 +578,14 @@ class RuntimeService {
}
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
// we do not know the reason for the crash, so we check anyway
const event = getEventWithId(selectedEventId);
if (!event || !isOntimeEvent(event)) {
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return;
}
const timedEvents = getPlayableEvents();
runtimeState.resume(restorePoint, event, timedEvents);
const rundown = getRundown();
runtimeState.resume(restorePoint, event, rundown);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
@@ -553,8 +600,16 @@ class RuntimeService {
}
}
export const runtimeService = new RuntimeService();
// calculate at 30fps, refresh at 1fps
const eventTimer = new EventTimer({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
});
export const runtimeService = new RuntimeService(eventTimer);
/**
* Decorator manages side effects from updating the runtime
*/
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
@@ -600,6 +655,11 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (!deepEqual(RuntimeService?.previousState.currentBlock, state.currentBlock)) {
eventStore.set('currentBlock', state.currentBlock);
RuntimeService.previousState.currentBlock = { ...state.currentBlock };
}
if (shouldUpdateClock) {
RuntimeService.previousClockUpdate = state.clock;
eventStore.set('clock', state.clock);
@@ -1,17 +1,26 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { getShouldClockUpdate, getShouldTimerUpdate } from '../rundownService.utils.js';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getShouldClockUpdate()', () => {
it('should return true when we slid forwards', () => {
const previousUpdate = Date.now() - 2000; // 2 seconds ago
const now = Date.now();
const previousUpdate = Date.now(); // 2 seconds ago
const now = Date.now() + 2000;
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(true);
});
it('should return true when we slid backwards', () => {
const previousUpdate = Date.now();
const now = Date.now() - 2000; // 2 seconds ago
const previousUpdate = Date.now() + 2000;
const now = Date.now(); // 2 seconds ago
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(true);
});
@@ -24,8 +33,8 @@ describe('getShouldClockUpdate()', () => {
});
it('should return false when clock is not a second ahead and force update is not required', () => {
const previousUpdate = Date.now() - 32;
const now = Date.now();
const previousUpdate = Date.now();
const now = Date.now() + 32;
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(false);
});
+23 -198
View File
@@ -1,7 +1,6 @@
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types';
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
/**
* handle events that span over midnight
@@ -56,6 +55,12 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/
export function getCurrent(state: RuntimeState): number {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received');
}
}
const { startedAt, duration, addedTime } = state.timer;
const { timerType, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
@@ -88,6 +93,11 @@ export function getCurrent(state: RuntimeState): number {
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we havent started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false;
}
const { startedAt, expectedFinish } = state.timer;
const { clock } = state;
@@ -101,199 +111,6 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
}
type RollTimers = {
nowIndex: MaybeNumber;
nowId: MaybeString;
publicIndex: MaybeNumber;
nextIndex: MaybeNumber;
publicNextIndex: MaybeNumber;
timeToNext: MaybeNumber;
nextEvent: OntimeEvent | null;
nextPublicEvent: OntimeEvent | null;
currentEvent: OntimeEvent | null;
currentPublicEvent: OntimeEvent | null;
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
let nowIndex: MaybeNumber = null; // index of event now
let nowId: MaybeString = null; // id of event now
let publicIndex: MaybeNumber = null; // index of public event now
let nextIndex: MaybeNumber = null; // index of next event
let publicNextIndex: MaybeNumber = null; // index of next public event
let timeToNext: MaybeNumber = null; // counter: time for next event
let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === rundown.length - 1;
const filteredRundown = canFilter ? rundown.slice(currentIndex) : rundown;
const lastEvent = filteredRundown.at(-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 = filteredRundown.at(0);
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - 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 filteredRundown) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = filteredRundown.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 filteredRundown) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
const hasNotEnded = normalEnd > timeNow;
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (normalEnd <= timeNow) {
// event ran already
if (event.isPublic && normalEnd > publicTime) {
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = filteredRundown.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 = filteredRundown.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 = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* @description Implements update functions for roll mode
* @param {RuntimeState}
* @returns object with selection variables
*/
export const updateRoll = (state: RuntimeState) => {
const { current, expectedFinish, startedAt, secondaryTimer } = state.timer;
const { secondaryTarget } = state._timer;
const { clock } = state;
const selectedEventId = state.eventNow?.id ?? null;
// 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
const finishAt = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
updatedTimer = finishAt - clock;
if (updatedTimer > dayInMs) {
updatedTimer -= dayInMs;
}
if (updatedTimer <= timerConfig.triggerAhead) {
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 };
};
/**
* Calculates difference between the runtime and the schedule of an event
* Positive offset is time ahead
@@ -305,6 +122,14 @@ export function getRuntimeOffset(state: RuntimeState): number {
return 0;
}
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
// we know current exists as long as eventNow exists
if (state.timer.current === null) {
throw new Error('timerUtils.calculate: current must be set');
}
}
const { clock } = state;
const { timeStart, timerType } = state.eventNow;
const { addedTime, current, startedAt } = state.timer;
@@ -356,7 +181,7 @@ export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: n
*/
export function getExpectedEnd(state: RuntimeState): MaybeNumber {
// there is no expected end if we havent started
if (state.runtime.actualStart === null) {
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
return null;
}
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
@@ -367,7 +192,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
* @param state
* @returns
*/
function isPlaybackActive(state: RuntimeState): boolean {
export function isPlaybackActive(state: RuntimeState): boolean {
return (
state.timer.playback === Playback.Play ||
state.timer.playback === Playback.Pause ||
@@ -386,7 +211,7 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
const current = state.timer.current;
if (current === null || state.eventNow === null) {
if (current === null || state.eventNow === null || state.timer.secondaryTimer != null) {
return TimerPhase.Pending;
}
-1
View File
@@ -5,7 +5,6 @@ export const config = {
demoProject: 'demo project.json',
newProject: 'new project.json',
database: {
testdb: 'test-db',
directory: 'db',
filename: 'db.json',
},
-8
View File
@@ -73,7 +73,6 @@ export const resolvedPath = (): string => {
export const resolvePublicDirectoy = getAppDataPath();
ensureDirectory(resolvePublicDirectoy);
const testDbStartDirectory = isTest ? '../' : resolvePublicDirectoy;
export const externalsStartDirectory = isProduction ? resolvePublicDirectoy : join(srcDirectory, 'external');
// TODO: we only need one when they are all in the same folder
export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirectoy : srcDirectory, 'external');
@@ -82,13 +81,6 @@ export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirect
export const appStatePath = join(resolvePublicDirectoy, config.appState);
export const uploadsFolderPath = join(resolvePublicDirectoy, config.uploads);
// path to public db
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
export const pathToStartDb = isTest
? join(srcDirectory, '..', config.database.testdb, config.database.filename)
: join(srcDirectory, '/preloaded-db/', config.database.filename);
// path to public styles
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
@@ -1,4 +1,4 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { PlayableEvent, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
@@ -11,7 +11,8 @@ const mockEvent = {
timeStart: 0,
timeEnd: 1000,
duration: 1000,
} as OntimeEvent;
skip: false,
} as PlayableEvent;
const mockState = {
clock: 666,
@@ -36,7 +37,6 @@ const mockState = {
},
_timer: {
pausedAt: null,
secondaryTarget: null,
},
} as RuntimeState;
@@ -84,7 +84,7 @@ describe('mutation on runtimeState', () => {
vi.clearAllMocks();
});
describe('playback operations', () => {
describe('playback operations', async () => {
it('refuses if nothing is loaded', () => {
let success = start(mockState);
expect(success).toBe(false);
@@ -99,6 +99,7 @@ describe('mutation on runtimeState', () => {
expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.timer.playback).toBe(Playback.Armed);
expect(newState.clock).not.toBe(666);
expect(newState.currentBlock.block).toBeNull();
// 2. Start event
let success = start();
@@ -144,6 +145,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event
success = stop();
newState = getState();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({
@@ -160,14 +162,19 @@ describe('mutation on runtimeState', () => {
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
// force update
initRundown([event1, event2], {});
test('runtime offset', () => {
vi.useFakeTimers();
await initRundown([event1, event2], {});
vi.runAllTimers();
vi.useRealTimers();
test('runtime offset', async () => {
// 1. Load event
load(event1, [event1, event2]);
let newState = getState();
expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.plannedStart).toBe(0);
expect(newState.runtime.plannedEnd).toBe(1500);
expect(newState.currentBlock.block).toBeNull();
// 2. Start event
start();
@@ -199,6 +206,7 @@ describe('mutation on runtimeState', () => {
expect(newState.runtime.offset).toBe(delayBefore);
// finish is the difference between the runtime and the schedule
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset);
expect(newState.currentBlock.block).toBeNull();
// 4. Add time
addTime(10);
+342 -167
View File
@@ -1,63 +1,79 @@
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import {
CurrentBlockState,
isPlayableEvent,
MaybeNumber,
MaybeString,
OntimeEvent,
OntimeRundown,
PlayableEvent,
Playback,
Runtime,
TimerPhase,
TimerState,
} from 'ontime-types';
import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getRelevantBlock } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js';
import {
getCurrent,
getExpectedEnd,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
skippedOutOfEvent,
updateRoll,
isPlaybackActive,
} from '../services/timerUtils.js';
import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset
} as const;
const initialTimer: TimerState = {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
} as const;
export type RuntimeState = {
clock: number; // realtime clock
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
eventNow: PlayableEvent | null;
currentBlock: CurrentBlockState;
publicEventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
publicEventNext: PlayableEvent | null;
runtime: Runtime;
timer: TimerState;
// private properties of the timer calculations
_timer: {
forceFinish: MaybeNumber;
forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time
totalDelay: number; // this value comes from rundown service
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
};
_prevCurrentBlock: CurrentBlockState;
};
const runtimeState: RuntimeState = {
clock: clock.timeNow(),
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
publicEventNow: null,
eventNext: null,
@@ -70,16 +86,35 @@ const runtimeState: RuntimeState = {
pausedAt: null,
secondaryTarget: null,
},
_prevCurrentBlock: {
block: null,
startedAt: null,
},
};
export function getState(): Readonly<RuntimeState> {
return runtimeState;
// create a shallow copy of the state
return {
...runtimeState,
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
publicEventNow: runtimeState.publicEventNow ? { ...runtimeState.publicEventNow } : null,
publicEventNext: runtimeState.publicEventNext ? { ...runtimeState.publicEventNext } : null,
runtime: { ...runtimeState.runtime },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
};
}
export function clear() {
runtimeState.eventNow = null;
runtimeState.publicEventNow = null;
runtimeState.eventNext = null;
runtimeState._prevCurrentBlock = { ...runtimeState.currentBlock };
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
runtimeState.publicEventNext = null;
runtimeState.runtime.offset = 0;
@@ -93,7 +128,6 @@ export function clear() {
// we maintain the total delay
runtimeState._timer.pausedAt = null;
runtimeState._timer.secondaryTarget = null;
}
/**
@@ -109,7 +143,7 @@ function patchTimer(newState: Partial<TimerState>) {
}
type RundownData = {
numEvents: number;
numEvents: number; // length of rundown filtered for timed events
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
totalDelay: number;
@@ -125,35 +159,40 @@ export function updateRundownData(rundownData: RundownData) {
runtimeState.runtime.numEvents = rundownData.numEvents;
runtimeState.runtime.plannedStart = rundownData.firstStart;
runtimeState.runtime.plannedEnd = rundownData.firstStart + rundownData.totalDuration;
runtimeState.runtime.plannedEnd =
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
}
/**
* Loads a given event into state
* @param event
* @param rundown
* @param initialData
*/
export function load(
event: OntimeEvent,
rundown: OntimeEvent[],
event: PlayableEvent,
rundown: OntimeRundown,
initialData?: Partial<TimerState & RestorePoint>,
): boolean {
clear();
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
// filter rundown
const timedEvents = filterTimedEvents(rundown);
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
runtimeState.runtime.selectedEventIndex = eventIndex;
if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) {
return false;
}
loadNow(event, rundown);
loadNext(rundown);
// load events in memory along with their data
loadNow(timedEvents, eventIndex);
loadNext(timedEvents, eventIndex);
runtimeState.clock = clock.timeNow();
// update state
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.runtime.numEvents = timedEvents.length;
// patch with potential provided data
if (initialData) {
patchTimer(initialData);
@@ -168,8 +207,28 @@ export function load(
return event.id === runtimeState.eventNow?.id;
}
export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) {
/**
* Loads current event and its public counterpart
*/
export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex) {
if (eventIndex === null) {
// reset the state to indicate there is no selection
runtimeState.runtime.selectedEventIndex = null;
runtimeState.eventNow = null;
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
return;
}
const event = timedEvents[eventIndex] as PlayableEvent;
runtimeState.runtime.selectedEventIndex = eventIndex;
runtimeState.eventNow = event;
runtimeState.currentBlock.block = getRelevantBlock(timedEvents, event.id);
// if we are still in the same block keep the startedAt time
if (runtimeState._prevCurrentBlock.block?.id === runtimeState.currentBlock.block?.id) {
runtimeState.currentBlock.startedAt = runtimeState._prevCurrentBlock.startedAt;
}
// check if current is also public
if (event.isPublic) {
@@ -179,63 +238,81 @@ export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) {
runtimeState.publicEventNow = null;
// if there is nothing before, return
if (!runtimeState.runtime.selectedEventIndex) {
if (!eventIndex) {
return;
}
// iterate backwards to find it
for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) {
if (playableEvents[i].isPublic) {
runtimeState.publicEventNow = playableEvents[i];
for (let i = eventIndex; i >= 0; i--) {
const event = timedEvents[i];
// we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
if (event.isPublic) {
runtimeState.publicEventNow = event;
break;
}
}
}
}
export function loadNext(playableEvents: OntimeEvent[]) {
// assume there are no next events
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
if (runtimeState.runtime.selectedEventIndex === null) {
/**
* Loads the next event and its public counterpart
*/
export function loadNext(
timedEvents: OntimeEvent[],
eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex,
) {
if (eventIndex === null) {
// reset the state to indicate there is no future event
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
return;
}
const numEvents = playableEvents.length;
for (let i = eventIndex + 1; i < timedEvents.length; i++) {
const event = timedEvents[i];
// we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
if (runtimeState.runtime.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextProduction = false;
// the private event is the one immediately after the current event
if (runtimeState.eventNext === null) {
runtimeState.eventNext = event;
}
for (let i = runtimeState.runtime.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextProduction) {
runtimeState.eventNext = playableEvents[i];
nextProduction = true;
}
// if event is public
if (event.isPublic) {
runtimeState.publicEventNext = event;
}
// if event is public
if (playableEvents[i].isPublic) {
runtimeState.publicEventNext = playableEvents[i];
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextProduction) break;
// Stop if both are set
if (runtimeState.eventNext !== null && runtimeState.publicEventNext !== null) {
return;
}
}
}
export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) {
/**
* Resume from restore point
*/
export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) {
load(event, rundown, restorePoint);
}
/**
* We only pass an event if we are hot reloading
* @param {OntimeEvent} event only passed if we are changing the data if a playing timer
* @param {PlayableEvent} event only passed if we are changing the data if a playing timer
*/
export function reload(event?: OntimeEvent) {
export function reload(event?: PlayableEvent): string | undefined {
// if there is no event loaded, nothing to do
if (runtimeState.eventNow === null) {
return;
}
// we only pass an event for hot reloading, ie: the event has changed
if (event) {
runtimeState.eventNow = event;
@@ -244,33 +321,53 @@ export function reload(event?: OntimeEvent) {
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
// if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) {
if (
runtimeState.eventNow.timeStart < runtimeState.clock &&
runtimeState.clock < runtimeState.eventNow.timeEnd
) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
}
}
}
return runtimeState.eventNow.id;
}
// reset changes to timer progress
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = null;
runtimeState.timer.startedAt = null;
runtimeState.timer.finishedAt = null;
runtimeState._timer.pausedAt = null;
runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null;
// this could be looked after by the timer
runtimeState.timer.elapsed = null;
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.currentBlock.startedAt = null;
return runtimeState.eventNow.id;
}
/**
* Used in situations when we want to reload all events
* without interrupting timer
* @param eventNow
* @param playableEvents
* Used in situations when we want to hot-reload all events without interrupting timer
*/
export function reloadAll(eventNow: OntimeEvent, playableEvents: OntimeEvent[]) {
loadNow(eventNow, playableEvents);
loadNext(playableEvents);
reload(eventNow);
export function reloadAll(rundown: OntimeRundown) {
const timedEvents = filterTimedEvents(rundown);
loadNow(timedEvents);
loadNext(timedEvents);
reload(runtimeState.eventNow ?? undefined);
}
export function start(state: RuntimeState = runtimeState): boolean {
@@ -282,7 +379,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
}
state.clock = clock.timeNow();
state.timer.secondaryTimer = null;
state._timer.secondaryTarget = null;
// add paused time if it exists
if (state._timer.pausedAt) {
@@ -295,6 +391,10 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.startedAt = state.clock;
}
if (state.currentBlock.startedAt === null) {
state.currentBlock.startedAt = state.clock;
}
state.timer.playback = Playback.Play;
state.timer.expectedFinish = getExpectedFinish(state);
state.timer.elapsed = 0;
@@ -336,11 +436,22 @@ export function stop(state: RuntimeState = runtimeState): boolean {
return true;
}
/**
* Exposes functionality to add user time to the timer externally
*/
export function addTime(amount: number) {
if (runtimeState.timer.current === null) {
return false;
}
// as long as there is a timer, we need an expected finish
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.timer.expectedFinish === null) {
throw new Error('runtimeState.addTime: invalid state received');
}
}
// handle edge cases
// !!! we need to handle side effects before updating the state
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
@@ -370,114 +481,178 @@ export function addTime(amount: number) {
export type UpdateResult = {
hasTimerFinished: boolean;
shouldCallRoll: boolean;
hasSecondaryTimerFinished: boolean;
};
export function update(): UpdateResult {
let hasTimerFinished = false;
let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished
// 0. there are some things we always do
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
const previousTime = runtimeState.clock;
runtimeState.clock = clock.timeNow();
// we call integrations if we update timers
if (runtimeState.timer.playback === Playback.Roll) {
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 = 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;
// 1. is playback idle?
if (!isPlaybackActive(runtimeState)) {
return updateIfIdle();
}
// update timer phase
runtimeState.timer.phase = getTimerPhase(runtimeState);
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
return updateIfWaitingToRoll();
}
// update offset
// 3. at this point we know that we are playing an event
// reset data
runtimeState.timer.secondaryTimer = null;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (!runtimeState.timer.duration) {
throw new Error('runtimeState.update: invalid state received');
}
}
// update timer state
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.timer.phase = getTimerPhase(runtimeState);
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
// update runtime, needs up-to-date timer state
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
return {
hasTimerFinished,
shouldCallRoll,
};
const finishedNow =
Boolean(runtimeState._timer.forceFinish) ||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
function onRollUpdate() {
const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit);
if (hasSkippedOutOfEvent) {
return { doRoll: true };
}
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(runtimeState);
runtimeState.timer.current = updatedTimer;
runtimeState.timer.secondaryTimer = updatedSecondaryTimer;
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
return { doRoll: doRollLoad, isFinished };
if (finishedNow) {
// reset state
runtimeState._timer.forceFinish;
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
} else {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
}
function onPlayUpdate() {
let isFinished = false;
runtimeState.timer.current = getCurrent(runtimeState);
const shouldForceFinish = runtimeState._timer.forceFinish !== null;
const finishedNow =
shouldForceFinish ||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
if (runtimeState.timer.playback === Playback.Play && finishedNow) {
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
isFinished = true;
} else {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
function updateIfIdle() {
// if nothing is running, nothing to do
return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
}
function updateIfWaitingToRoll() {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
}
}
if (shouldForceFinish) {
runtimeState._timer.forceFinish = null;
}
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
return { isFinished };
runtimeState.timer.phase = TimerPhase.Pending;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer < 0 };
}
}
export function roll(rundown: OntimeEvent[]) {
const selectedEventIndex = runtimeState.runtime.selectedEventIndex;
clear();
runtimeState.runtime.numEvents = rundown.length;
export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: boolean } {
// 1. if an event is running, we simply take over the playback
if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll;
return { eventId: runtimeState.eventNow?.id ?? null, didStart: false };
}
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock, selectedEventIndex);
if (currentEvent) {
// there is something running, load
runtimeState.timer.secondaryTimer = null;
runtimeState._timer.secondaryTarget = null;
// 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed && runtimeState.eventNow !== null) {
runtimeState.timer.playback = Playback.Roll;
// account for event that finishes the day after
const endTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
const normalisedEndTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime;
// 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
load(currentEvent, rundown, {
startedAt: currentEvent.timeStart,
expectedFinish: currentEvent.timeEnd,
current: endTime - runtimeState.clock,
});
} else if (nextEvent) {
if (nextEvent.isPublic) {
runtimeState.publicEventNext = nextEvent;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0;
// check if the event is ready to start or if needs to be waited
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, runtimeState.clock);
if (isNow) {
runtimeState.timer.startedAt = runtimeState.clock;
// update runtime
if (!runtimeState.runtime.actualStart) {
runtimeState.runtime.actualStart = runtimeState.clock;
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
runtimeState.timer.phase = TimerPhase.Pending;
}
runtimeState.eventNext = nextEvent;
// account for day after
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up
runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock;
runtimeState._timer.secondaryTarget = nextStart;
return { eventId: runtimeState.eventNow.id, didStart: isNow };
}
// 3. if there is no event running, we need to find the next event
const timedEvents = filterTimedEvents(rundown);
if (timedEvents.length === 0) {
throw new Error('No playable events found');
}
clear();
const { index, isPending } = loadRoll(timedEvents, runtimeState.clock);
// load events in memory along with their data
loadNow(timedEvents, index);
loadNext(timedEvents, index);
// update roll state
runtimeState.timer.playback = Playback.Roll;
runtimeState.runtime.numEvents = timedEvents.length;
// in roll mode spec, there should always be something to load
// as long as playableEvents is not empty
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null) {
throw new Error('runtimeState.roll: invalid state received');
}
}
if (isPending) {
// there is nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
return { eventId: runtimeState.eventNow.id, didStart: false };
}
// there is something to run, load event
// event will finish on time
// account for event that finishes the day after
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = runtimeState.clock;
runtimeState.timer.expectedFinish = endTime;
// we add time to allow timer to catch up
runtimeState.timer.addedTime = -(runtimeState.clock - runtimeState.eventNow.timeStart);
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0;
// update runtime
runtimeState.runtime.actualStart = runtimeState.clock;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
+20
View File
@@ -0,0 +1,20 @@
import { isProduction } from '../setup/index.js';
import { consoleError } from '../utils/console.js';
/**
* Milestone checker for dev environment
* will terminate process if check returns true
* Ideally we would like to remove the call to this function on build
*/
export function shouldCrashDev(check: boolean, reason: string) {
if (isProduction) {
return;
}
if (!check) {
return;
}
consoleError(new Error(reason).stack ?? '');
process.exit(2);
}
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"strict": false,
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
+63
View File
@@ -0,0 +1,63 @@
# ROLL Mode
Roll mode is intended to be a fully automatic playback that takes precedence over event end actions.
It can be user either on its own, or as in conjunction with manual playback to allow for automated rundown sections.
## Overview
- As long as there are non-skipped events in the rundown, we will always accept roll mode
- If there are no events in the current time frame, we load the next event and count-down to its start
- Roll will always load the first matching event in the current time, this could cause issues if there are multiple days planned or if the rundown is not in order.
- If we go from manual playback, to Roll mode, the playback should continue as is. Roll mode will automate loading the next event when the current is finished
## Implementation details
### starting to roll
> RuntimeService.roll(rundown: OntimeRundown)
When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty.
#### Taking over playback
If we are currently in "Play" mode and an event is playing, roll simply takes over playback. No other data changes are made
#### Starting an event
If there is nothing playing and roll finds an element that in the current time frame playing, it will start the event
#### Waiting to start
If we do not find an event that should be playing now, but find an event for the future, we load the next event, set roll mode and wait
### tick update
> RuntimeState.onUpdate()
Updating in roll mode attempts to have the least amount of custom logic in relation to normal updates. The only difference in behaviour is the automation of loading the next event when the current one is finished.
#### normal update
On the update of timers, there is no logic specific to Roll mode, all side effects (ie: integrations) should have the same behaviour as Play mode
#### waiting to start
If we are currently waiting to start, we just need to update the `secondaryTimer`.
If waiting to start is finished, we load the next event and start it
#### an event is finished
If an event is finished, we check if the next event is ready to start, this is similarly as if we had a conditional `load-next` `play-next` automation
If there is a gap between the events, we add `secondaryTimer` to match and wait for the next event to start
Finish time should account for `timer.addedTime`
Finish actions are ignored in roll mode
#### time has skipped
If we find that the new time update has slid in comparison to the old update (either too long, or time went backwards), we re-calculate
### Finding an event
> loadRoll(timedEvents: OntimeEvent[], timeNow: number)
This is a helper function which iterates trough a rundown to find the first matching element in the current time. As a trade-off, the wrong event will be loaded if the rundown is not in order.
It is important to note that all times in the rundown are in milliseconds from midnight. In the case of multiple days being scheduled, Roll will return the first match.
### Assumptions
The function receives a pre-filtered list of `TimedEvents`. This is to avoid issues with inconsistent index references.
- `TimedEvents` cannot be an empty array
- `TimedEvents` is assumed to be in order
### Specification
- we should always receive an `eventNow` or `eventNext`
- if the current clock is past the last event end, we roll for the events tomorrow
- if the current clock is before the first event, we do not load next day events, even if there is a match, we always start on first day
-339
View File
@@ -1,339 +0,0 @@
{
"rundown": [
{
"title": "Albania",
"note": "SF1.01",
"endAction": "none",
"timerType": "count-down",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "32d31",
"cue": "SF1.01",
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
},
{
"title": "Latvia",
"note": "SF1.02",
"endAction": "none",
"timerType": "count-down",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "21cd2",
"cue": "SF1.02",
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"title": "Lithuania",
"note": "SF1.03",
"endAction": "none",
"timerType": "count-down",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "0b371",
"cue": "SF1.03",
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"title": "Switzerland",
"note": "SF1.04",
"endAction": "none",
"timerType": "count-down",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "3cd28",
"cue": "SF1.04",
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"title": "Slovenia",
"note": "SF1.05",
"endAction": "none",
"timerType": "count-down",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "e457f",
"cue": "SF1.05",
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"title": "Ukraine",
"note": "SF1.06",
"endAction": "none",
"timerType": "count-down",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "1c420",
"cue": "SF1.06",
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"title": "Bulgaria",
"note": "SF1.07",
"endAction": "none",
"timerType": "count-down",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "b7737",
"cue": "SF1.07",
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"title": "Netherlands",
"note": "SF1.08",
"endAction": "none",
"timerType": "count-down",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3a80",
"cue": "SF1.08",
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"title": "Moldova",
"note": "SF1.09",
"endAction": "none",
"timerType": "count-down",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "8276c",
"cue": "SF1.09",
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"title": "Portugal",
"note": "SF1.10",
"endAction": "none",
"timerType": "count-down",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "2340b",
"cue": "SF1.10",
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"title": "Croatia",
"note": "SF1.11",
"endAction": "none",
"timerType": "count-down",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "503c4",
"cue": "SF1.11",
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"title": "Denmark",
"note": "SF1.12",
"endAction": "none",
"timerType": "count-down",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "5e965",
"cue": "SF1.12",
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"title": "Austria",
"note": "SF1.13",
"endAction": "none",
"timerType": "count-down",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "bab4a",
"cue": "SF1.13",
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"title": "Greece",
"note": "SF1.14",
"endAction": "none",
"timerType": "count-down",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3eb1",
"cue": "SF1.14",
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
}
],
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
"publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
},
"settings": {
"app": "ontime",
"version": "2.0.0",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
"language": "en"
},
"viewSettings": {
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"warningThreshold": 120000,
"dangerColor": "#ED3333",
"dangerThreshold": 60000,
"endMessage": ""
},
"urlPresets": [
{
"enabled": true,
"alias": "test",
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
}
],
"osc": {
"portIn": 8888,
"portOut": 9999,
"targetIP": "127.0.0.1",
"enabledIn": true,
"enabledOut": true,
"subscriptions": []
},
"http": {
"enabledOut": true,
"subscriptions": []
}
}
+1 -2
View File
@@ -9,8 +9,7 @@ services:
- "8888:8888/udp"
- "9999:9999/udp"
volumes:
- "./ontime-db:/external/db/"
- "./ontime-styles:/external/styles/"
- "./ontime-data:/data/"
environment:
- TZ=Asia/Singapore
restart: unless-stopped
+8 -21
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.4.1-beta",
"version": "3.5.0-beta.2",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -25,7 +25,6 @@
"dev:electron": "turbo run dev --filter=ontime",
"dev:server": "turbo run dev --filter=ontime-server",
"lint": "turbo run lint",
"lint-staged": "turbo run lint-staged --concurrency=1",
"build": "turbo run build",
"build:local": "turbo run build:local",
"build:electron": "turbo run build:electron",
@@ -40,27 +39,15 @@
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@types/node": "^20.14.10",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-playwright": "^1.5.2",
"husky": "^8.0.3",
"lint-staged": "^15.1.0",
"prettier": "^3.3.1",
"prettier": "catalog:",
"turbo": "^1.11.2",
"typescript": "^5.4.3"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"pnpm lint"
]
"typescript": "catalog:"
}
}
+5 -6
View File
@@ -7,16 +7,15 @@
"description": "shared typings for ontime",
"scripts": {
"cleanup": "rm -rf .turbo && rm -rf node_modules",
"lint": "eslint . --quiet",
"lint-staged": "eslint"
"lint": "eslint . --quiet"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.56.0",
"typescript": "^5.4.3"
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}
@@ -43,3 +43,5 @@ export type OntimeEvent = OntimeBaseEvent & {
timeDanger: number;
custom: EventCustomFields;
};
export type PlayableEvent = OntimeEvent & { skip: false };
@@ -0,0 +1,7 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { OntimeBlock } from '../core/OntimeEvent.type.js';
export type CurrentBlockState = {
block: OntimeBlock | null;
startedAt: MaybeNumber;
};
@@ -1,5 +1,6 @@
import type { OntimeEvent } from '../core/OntimeEvent.type.js';
import type { SimpleTimerState } from './AuxTimer.type.js';
import type { CurrentBlockState } from './CurrentBlockState.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Runtime } from './Runtime.type.js';
import type { TimerState } from './TimerState.type.js';
@@ -15,6 +16,7 @@ export type RuntimeStore = {
// rundown data
runtime: Runtime;
currentBlock: CurrentBlockState;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
@@ -22,4 +24,7 @@ export type RuntimeStore = {
// extra timers
auxtimer1: SimpleTimerState;
// flags
frozen: boolean;
};
@@ -7,18 +7,27 @@ export enum TimerPhase {
Warning = 'warning',
Danger = 'danger',
Overtime = 'overtime',
Pending = 'pending', // used for waiting to roll
/** used for waiting to roll */
Pending = 'pending',
}
export type TimerState = {
addedTime: number; // time added by user, can be negative
current: MaybeNumber; // running countdown
duration: MaybeNumber; // normalised duration of current event
elapsed: MaybeNumber; // elapsed time in current timer
expectedFinish: MaybeNumber; // time we expect timer to finish
finishedAt: MaybeNumber; // only if timer has already finished
/** time added by user, can be negative */
addedTime: number;
/** running countdown */
current: MaybeNumber;
/** normalised duration of current event */
duration: MaybeNumber;
/** elapsed time in current timer */
elapsed: MaybeNumber;
/** time we expect timer to finish */
expectedFinish: MaybeNumber;
/** only if timer has already finished */
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber; // used for roll mode
startedAt: MaybeNumber; // only if timer has already started
/** used for roll mode */
secondaryTimer: MaybeNumber;
/** only if timer has already started */
startedAt: MaybeNumber;
};
+10 -1
View File
@@ -8,6 +8,7 @@ export {
type OntimeDelay,
type OntimeBlock,
type OntimeEvent,
type PlayableEvent,
SupportedEvent,
} from './definitions/core/OntimeEvent.type.js';
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
@@ -62,6 +63,7 @@ export type { Message, TimerMessage, MessageState } from './definitions/runtime/
export type { Runtime } from './definitions/runtime/Runtime.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export type { CurrentBlockState } from './definitions/runtime/CurrentBlockState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -70,5 +72,12 @@ export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './defini
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
// TYPE UTILITIES
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js';
export {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
isOntimeCycle,
isKeyOfType,
} from './utils/guards.js';
export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js';
+5 -1
View File
@@ -1,4 +1,4 @@
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js';
@@ -10,6 +10,10 @@ export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
return event?.type === SupportedEvent.Event;
}
export function isPlayableEvent(event: OntimeEvent): event is PlayableEvent {
return !event.skip;
}
export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay {
return event?.type === SupportedEvent.Delay;
}
+5
View File
@@ -8,6 +8,9 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js';
export {
getEventWithId,
filterPlayable,
filterTimedEvents,
getFirst,
getFirstEvent,
getFirstEventNormal,
@@ -23,6 +26,7 @@ export {
getPreviousEvent,
getPreviousEventNormal,
getPreviousNormal,
getRelevantBlock,
swapEventData,
} from './src/rundown-utils/rundownUtils.js';
@@ -67,6 +71,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
// feature business logic - spreadsheet import
+7 -8
View File
@@ -6,7 +6,6 @@
"description": "shared logic for ontime",
"scripts": {
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules"
@@ -16,15 +15,15 @@
"nanoid": "^5.0.7"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-simple-import-sort": "^8.0.0",
"ontime-types": "workspace:*",
"prettier": "^3.3.1",
"typescript": "^5.4.3",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "^1.6.0"
},
"sideEffects": false
@@ -0,0 +1,29 @@
import { checkIsNow } from './checkIsNow';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNow()', () => {
test('should return true if now is between timeStart and timeEnd', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 10;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
});
test('should return false if now is before start', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 8;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return false if now is after end', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 20;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return true accounting for events that roll over midnight', () => {
expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true);
});
});
@@ -0,0 +1,9 @@
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event should be playing now
*/
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
return timeStart <= clock && clock <= normalisedEnd;
}
@@ -2,12 +2,14 @@ import type { NormalisedRundown, OntimeEvent, OntimeRundown } from 'ontime-types
import { SupportedEvent } from 'ontime-types';
import {
filterPlayable,
getLastEvent,
getLastNormal,
getNext,
getNextEvent,
getPrevious,
getPreviousEvent,
getRelevantBlock,
swapEventData,
} from './rundownUtils';
@@ -262,4 +264,54 @@ describe('getLastEvent', () => {
expect(lastEntry).toBe(null);
});
});
describe('relevantBlock', () => {
const testRundown = [
{ id: 'a', type: SupportedEvent.Event },
{ id: 'b', type: SupportedEvent.Event },
{ id: 'c', type: SupportedEvent.Event },
{ id: 'd', type: SupportedEvent.Delay },
{ id: 'e', type: SupportedEvent.Block },
{ id: 'f', type: SupportedEvent.Event },
{ id: 'g', type: SupportedEvent.Block },
{ id: 'h', type: SupportedEvent.Event },
];
it('returns the relevant block', () => {
const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'h');
expect(block?.id).toBe('g');
});
it('returns the relevant block', () => {
const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'f');
expect(block?.id).toBe('e');
});
it('returns the relevant block', () => {
const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'a');
expect(block).toBeNull();
});
it('also works on index 0', () => {
testRundown.unshift({ id: '0', type: SupportedEvent.Block });
const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'a');
expect(block?.id).toBe('0');
});
});
describe('filterPlayable()', () => {
test('should return an array with only playable events', () => {
const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent;
const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent;
const testRundown = [
eventA,
eventB,
{ id: 'c', type: SupportedEvent.Delay },
{ id: 'd', type: SupportedEvent.Block },
];
const result = filterPlayable(testRundown as unknown as OntimeRundown);
expect(result).toMatchObject([eventA]);
});
});
});
@@ -1,14 +1,19 @@
import type { NormalisedRundown, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { isOntimeEvent } from 'ontime-types';
import type {
NormalisedRundown,
OntimeBlock,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
PlayableEvent,
} from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types';
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeRundownEntry | null}
*/
export function getFirst(rundown: OntimeRundownEntry[]) {
export function getFirst(rundown: OntimeRundown) {
return rundown.length ? rundown[0] : null;
}
@@ -25,16 +30,14 @@ export function getFirstNormal(rundown: NormalisedRundown, order: string[]) {
/**
* Gets first scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]): {
firstEvent: OntimeEvent | null;
export function getFirstEvent(rundown: OntimeRundown): {
firstEvent: PlayableEvent | null;
firstIndex: number | null;
} {
for (let i = 0; i < rundown.length; i++) {
const firstEvent = rundown[i];
if (isOntimeEvent(firstEvent) && !firstEvent.skip) {
if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
return { firstEvent, firstIndex: i };
}
}
@@ -43,21 +46,18 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]): {
/**
* Gets first scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/
export function getFirstEventNormal(
rundown: NormalisedRundown,
order: string[],
): {
firstEvent: OntimeEvent | null;
firstEvent: PlayableEvent | null;
firstIndex: number | null;
} {
for (let i = 0; i < order.length; i++) {
const firstId = order[i];
const firstEvent = rundown[firstId];
if (isOntimeEvent(firstEvent) && !firstEvent.skip) {
if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
return { firstEvent, firstIndex: i };
}
}
@@ -66,9 +66,6 @@ export function getFirstEventNormal(
/**
* Gets last event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/
export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null {
const lastId = order.at(-1);
@@ -80,11 +77,9 @@ export function getLastNormal(rundown: NormalisedRundown, order: string[]): Onti
/**
* Gets last scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getLastEvent(rundown: OntimeRundown): {
lastEvent: OntimeEvent | null;
lastEvent: PlayableEvent | null;
lastIndex: number | null;
} {
if (rundown.length < 1) {
@@ -93,7 +88,7 @@ export function getLastEvent(rundown: OntimeRundown): {
for (let i = rundown.length - 1; i >= 0; i--) {
const lastEvent = rundown.at(i);
if (isOntimeEvent(lastEvent) && !lastEvent.skip) {
if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) {
return { lastEvent, lastIndex: i };
}
}
@@ -102,9 +97,6 @@ export function getLastEvent(rundown: OntimeRundown): {
/**
* Gets last scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getLastEventNormal(
rundown: NormalisedRundown,
@@ -129,12 +121,9 @@ export function getLastEventNormal(
/**
* Gets next entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeRundownEntry | null; nextIndex: number | null } }
*/
export function getNext(
rundown: OntimeRundownEntry[],
rundown: OntimeRundown,
currentId: string,
): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -149,10 +138,6 @@ export function getNext(
/**
* Gets next entry in rundown, if it exists
* @param rundown
* @param order
* @param currentId
* @returns
*/
export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
@@ -168,12 +153,9 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre
/**
* Gets next scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/
export function getNextEvent(
rundown: OntimeRundownEntry[],
rundown: OntimeRundown,
currentId: string,
): { nextEvent: OntimeEvent | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -192,10 +174,6 @@ export function getNextEvent(
/**
* Gets next scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/
export function getNextEventNormal(
rundown: NormalisedRundown,
@@ -219,10 +197,8 @@ export function getNextEventNormal(
/**
* Gets previous entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
*/
export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): IndexAndEntry {
export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry {
const currentIndex = rundown.findIndex((event) => event.id === currentId);
if (currentIndex !== -1 && currentIndex - 1 >= 0) {
const index = currentIndex - 1;
@@ -235,9 +211,6 @@ export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): I
/**
* Gets previous entry in a nornalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
*/
export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
@@ -253,12 +226,9 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c
/**
* Gets previous scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } }
*/
export function getPreviousEvent(
rundown: OntimeRundownEntry[],
rundown: OntimeRundown,
currentId: string,
): { previousEvent: OntimeEvent | null; previousIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -302,8 +272,6 @@ export function getPreviousEventNormal(
/**
* @description swaps two OntimeEvents in the rundown
* @param {OntimeEvent} eventA
* @param {OntimeEvent} eventB
*/
export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => {
const newA = {
@@ -326,3 +294,42 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA:
return { newA, newB };
};
export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined {
return rundown.find((event) => event.id === id);
}
/**
* Gets relevant block element for a given ID
*/
export function getRelevantBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null {
let inBlock = false;
// Iterate backwards through the rundown to find the current event
for (let i = rundown.length - 1; i >= 0; i--) {
const entry = rundown[i];
if (entry.id === currentId) {
//set the flag when the current event is found
inBlock = true;
}
//the first block before the current event is the relevant one
if (inBlock && isOntimeBlock(entry)) {
return entry;
}
}
//no blocks exist before current event
return null;
}
/**
* filters a rundown to timed events
*/
export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] {
return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[];
}
/**
* filters a rundown to events that can be played
*/
export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] {
return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
+794 -995
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -2,3 +2,12 @@
packages:
- "apps/*"
- "packages/*"
catalog:
typescript: 5.5.3
"@typescript-eslint/eslint-plugin": 7.16.1
"@typescript-eslint/parser": 7.16.1
"@types/node": 20.14.10
prettier: 3.3.1
eslint: 8.56.0
eslint-config-prettier: 9.1.0
eslint-plugin-prettier: 5.1.3
-42
View File
@@ -1,42 +0,0 @@
{
"rundown": [],
"project": {
"title": "",
"description": "",
"publicUrl": "",
"publicInfo": "",
"backstageUrl": "",
"backstageInfo": ""
},
"settings": {
"app": "ontime",
"version": "3.0.0-beta.3",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
"language": "en"
},
"viewSettings": {
"dangerColor": "#ED3333",
"endMessage": "",
"freezeEnd": false,
"normalColor": "#ffffffcc",
"overrideStyles": false,
"warningColor": "#FFAB33"
},
"urlPresets": [],
"customFields": {},
"osc": {
"portIn": 8888,
"portOut": 9999,
"targetIP": "127.0.0.1",
"enabledIn": false,
"enabledOut": false,
"subscriptions": []
},
"http": {
"enabledOut": false,
"subscriptions": []
}
}
-4
View File
@@ -15,10 +15,6 @@
"lint": {
"cache": false
},
"lint-staged": {
"outputs": [],
"cache": false
},
"typecheck": {
"cache": false
},