mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 22:18:08 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71317f5e40 | |||
| cc14445252 | |||
| 31316a6661 | |||
| a8bc4eb098 | |||
| 807698aee1 |
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.5.0",
|
||||
"version": "3.4.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -20,8 +20,8 @@
|
||||
"server"
|
||||
],
|
||||
"devDependencies": {
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:"
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.3"
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.5.0",
|
||||
"version": "3.4.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": "^8.19.0",
|
||||
"@sentry/react": "^7.92.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.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-qr-code": "^2.0.12",
|
||||
@@ -42,6 +42,7 @@
|
||||
"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",
|
||||
@@ -69,13 +70,13 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
@@ -83,9 +84,9 @@
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "catalog:",
|
||||
"prettier": "^3.3.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
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 = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
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 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 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 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 STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
@@ -40,56 +28,41 @@ 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 = 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);
|
||||
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'));
|
||||
|
||||
export default function AppRouter() {
|
||||
// handle client path changes
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<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='/timeline' element={<STimeline />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/op' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
@@ -126,7 +99,7 @@ export default function AppRouter() {
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
</SentryRoutes>
|
||||
</React.Suspense>
|
||||
</Routes>
|
||||
</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,6 +54,7 @@ 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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { clamp } from '../../utils/math';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: MaybeNumber;
|
||||
complete: MaybeNumber;
|
||||
complete: number;
|
||||
normalColor: string;
|
||||
warning?: MaybeNumber;
|
||||
warningColor: string;
|
||||
@@ -31,9 +31,10 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { clamp } from '../../utils/math';
|
||||
|
||||
import './ProgressBar.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
now?: number;
|
||||
complete?: number;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, hidden, className = '' } = props;
|
||||
const progress = getProgress(current, duration);
|
||||
const { now = 0, complete = 100, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ 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,7 +12,9 @@ export const useClientPath = () => {
|
||||
|
||||
// notify of client path changes
|
||||
useEffect(() => {
|
||||
socketSendJson('set-client-path', pathname + search);
|
||||
//remove leading '/' from path
|
||||
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
|
||||
socketSendJson('set-client-path', fullPath);
|
||||
}, [pathname, search]);
|
||||
|
||||
// navigate to new path when received from server
|
||||
|
||||
@@ -50,7 +50,6 @@ export const usePlaybackControl = () => {
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
timerPhase: state.timer.phase,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
@@ -116,7 +115,6 @@ 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,
|
||||
@@ -151,6 +149,7 @@ export const useClock = () => {
|
||||
/** Used by the progress bar components */
|
||||
export const useProgressData = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
addedTime: state.timer.addedTime,
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||
@@ -180,26 +179,6 @@ export const useRuntimePlaybackOverview = () => {
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
|
||||
currentBlock: state.currentBlock,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimelineOverview = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimelineStatus = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
|
||||
@@ -38,10 +38,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
currentBlock: {
|
||||
block: null,
|
||||
startedAt: null,
|
||||
},
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
@@ -52,7 +48,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
},
|
||||
frozen: false,
|
||||
};
|
||||
|
||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { clamp } from './math';
|
||||
|
||||
/**
|
||||
* Returns completion percentage of a progress bar
|
||||
* This code assumes the current time and duration have addedTime already applied
|
||||
*/
|
||||
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
|
||||
if (current === null || duration === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (current <= 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (current >= duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp(((duration - current) / duration) * 100, 0, 100);
|
||||
}
|
||||
@@ -36,8 +36,7 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
socketSendJson('set-client-type', 'ontime');
|
||||
|
||||
socketSendJson('set-client-path', location.pathname + location.search);
|
||||
socketSendJson('set-client-path', location.pathname);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
@@ -151,11 +150,6 @@ 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,16 +34,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
|
||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
import { formatFromMillis } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
* Returns current time in milliseconds from midnight
|
||||
* Returns current time in milliseconds
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nowInMillis(): number {
|
||||
export const nowInMillis = () => {
|
||||
const now = new Date();
|
||||
|
||||
// extract milliseconds since midnight
|
||||
let elapsed = now.getHours() * MILLIS_PER_HOUR;
|
||||
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
|
||||
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
|
||||
let elapsed = now.getHours() * 3600000;
|
||||
elapsed += now.getMinutes() * 60000;
|
||||
elapsed += now.getSeconds() * 1000;
|
||||
elapsed += now.getMilliseconds();
|
||||
|
||||
return elapsed;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Resolves format from url and store
|
||||
@@ -95,33 +95,3 @@ 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, hideSeconds = true): 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`;
|
||||
}
|
||||
|
||||
if (!hideSeconds) {
|
||||
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
||||
if (seconds > 0) {
|
||||
result += `${seconds}s`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ export default function GeneralPanelForm() {
|
||||
<option value='en'>English</option>
|
||||
<option value='fr'>French</option>
|
||||
<option value='de'>German</option>
|
||||
<option value='hu'>Hungarian</option>
|
||||
<option value='it'>Italian</option>
|
||||
<option value='no'>Norwegian</option>
|
||||
<option value='pt'>Portuguese</option>
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
|
||||
const isLoading = Boolean(loading);
|
||||
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
|
||||
const canSubmitGSheet = !isLoading && !stepData.worksheet.error;
|
||||
const canSubmitGSheet = !isLoading;
|
||||
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function PlaybackControl() {
|
||||
playback={data.playback}
|
||||
numEvents={data.numEvents}
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<AuxTimer />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||
@@ -19,11 +19,10 @@ interface PlaybackButtonsProps {
|
||||
playback: Playback;
|
||||
numEvents: number;
|
||||
selectedEventIndex: number | null;
|
||||
timerPhase: TimerPhase;
|
||||
}
|
||||
|
||||
export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const { playback, numEvents, selectedEventIndex, timerPhase } = props;
|
||||
const { playback, numEvents, selectedEventIndex } = props;
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isPlaying = playback === Playback.Play;
|
||||
@@ -38,7 +37,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const disableNext = isRolling || noEvents || isLast;
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
|
||||
const playbackCan = validatePlayback(playback, timerPhase);
|
||||
const playbackCan = validatePlayback(playback);
|
||||
const disableStart = !playbackCan.start;
|
||||
const disablePause = !playbackCan.pause;
|
||||
const disableRoll = !playbackCan.roll || noEvents;
|
||||
|
||||
@@ -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, currentBlockId }: CuesheetProps) {
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
||||
|
||||
const {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
@@ -114,16 +114,11 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -133,6 +128,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
||||
if (isOntimeEvent(row.original)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedId;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isPast && !showPrevious) {
|
||||
return null;
|
||||
|
||||
@@ -107,7 +107,6 @@ export default function CuesheetWrapper() {
|
||||
columns={columns}
|
||||
handleUpdate={handleUpdate}
|
||||
selectedId={featureData.selectedEventId}
|
||||
currentBlockId={featureData.currentBlockId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,17 +6,18 @@ import styles from './CuesheetProgress.module.scss';
|
||||
|
||||
export default function CuesheetProgress() {
|
||||
const { data } = useViewSettings();
|
||||
const { current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const totalTime = (duration ?? 0) + (addedTime ?? 0);
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={current}
|
||||
complete={duration}
|
||||
normalColor={data.normalColor}
|
||||
complete={totalTime}
|
||||
normalColor={data!.normalColor}
|
||||
warning={timeWarning}
|
||||
warningColor={data.warningColor}
|
||||
warningColor={data!.warningColor}
|
||||
danger={timeDanger}
|
||||
dangerColor={data.dangerColor}
|
||||
dangerColor={data!.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
ignoreCssOverride
|
||||
/>
|
||||
|
||||
@@ -170,12 +170,9 @@ export default function Operator() {
|
||||
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
|
||||
const secondaryField = getPropertyValue(entry, secondary) ?? '';
|
||||
const subscribedData = subscriptions
|
||||
? subscriptions.flatMap((id) => {
|
||||
if (!customFields[id]) {
|
||||
return [];
|
||||
}
|
||||
? subscriptions.map((id) => {
|
||||
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, $active-red);
|
||||
background-color: var(--operator-running-bg-override, $red-700);
|
||||
}
|
||||
|
||||
&.past {
|
||||
@@ -99,6 +99,7 @@
|
||||
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 hide events that have passed',
|
||||
description: 'Whether to events that have passed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -11,12 +11,13 @@ interface StatusBarProgressProps {
|
||||
|
||||
export default function StatusBarProgress(props: StatusBarProgressProps) {
|
||||
const { viewSettings } = props;
|
||||
const { current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const totalTime = (duration ?? 0) + (addedTime ?? 0);
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={current}
|
||||
complete={duration}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
|
||||
@@ -33,7 +33,6 @@ 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} />
|
||||
@@ -95,14 +94,6 @@ 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();
|
||||
|
||||
|
||||
@@ -2,24 +2,8 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
PlayableEvent,
|
||||
Playback,
|
||||
RundownCached,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextBlockNormal,
|
||||
getNextNormal,
|
||||
getPreviousBlockNormal,
|
||||
getPreviousNormal,
|
||||
isNewLatest,
|
||||
} from 'ontime-utils';
|
||||
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
@@ -114,61 +98,28 @@ export default function Rundown({ data }: RundownProps) {
|
||||
[rundown, order, addEvent],
|
||||
);
|
||||
|
||||
const selectBlock = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
let newCursor = cursor;
|
||||
if (cursor === null) {
|
||||
// there is no cursor, we select the first or last depending on direction
|
||||
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
|
||||
|
||||
if (isOntimeBlock(selected)) {
|
||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
||||
return;
|
||||
}
|
||||
newCursor = selected?.id ?? null;
|
||||
}
|
||||
|
||||
if (newCursor === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise we select the next or previous
|
||||
const selected =
|
||||
direction === 'up'
|
||||
? getPreviousBlockNormal(rundown, order, newCursor)
|
||||
: getNextBlockNormal(rundown, order, newCursor);
|
||||
|
||||
if (selected.entry !== null && selected.index !== null) {
|
||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||
}
|
||||
},
|
||||
[order, rundown, setSelectedEvents],
|
||||
);
|
||||
|
||||
const selectEntry = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newCursor: string | null;
|
||||
let newIndex: number | null;
|
||||
if (cursor === null) {
|
||||
// there is no cursor, we select the first or last depending on direction if it exists
|
||||
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
|
||||
if (selected !== null) {
|
||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
||||
}
|
||||
return;
|
||||
newCursor =
|
||||
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
|
||||
newIndex = direction === 'up' ? order.length : 0;
|
||||
} else {
|
||||
// otherwise we select the next or previous
|
||||
const selected =
|
||||
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
||||
newCursor = selected.entry?.id ?? null;
|
||||
newIndex = selected.index;
|
||||
}
|
||||
|
||||
// otherwise we select the next or previous
|
||||
const selected =
|
||||
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
||||
|
||||
if (selected.entry !== null && selected.index !== null) {
|
||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||
if (newCursor && newIndex !== null) {
|
||||
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
|
||||
}
|
||||
},
|
||||
[order, rundown, setSelectedEvents],
|
||||
@@ -194,10 +145,6 @@ export default function Rundown({ data }: RundownProps) {
|
||||
useHotkeys([
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
|
||||
|
||||
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
|
||||
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
|
||||
|
||||
@@ -256,9 +203,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||
}
|
||||
|
||||
let lastEntry: PlayableEvent | undefined; // used by indicators
|
||||
let thisEntry: PlayableEvent | undefined;
|
||||
let previousStart: MaybeNumber = null;
|
||||
let previousEnd: MaybeNumber = null;
|
||||
let previousEventId: string | undefined;
|
||||
let thisStart: MaybeNumber = null;
|
||||
let thisEnd: MaybeNumber = null;
|
||||
let thisId = previousEventId;
|
||||
|
||||
let eventIndex = 0;
|
||||
@@ -286,14 +235,13 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (isOntimeEvent(event)) {
|
||||
// event indexes are 1 based in frontend
|
||||
eventIndex++;
|
||||
previousStart = thisStart;
|
||||
previousEnd = thisEnd;
|
||||
previousEventId = thisId;
|
||||
lastEntry = thisEntry;
|
||||
|
||||
if (isPlayableEvent(event)) {
|
||||
// populate previous entry
|
||||
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
||||
thisEntry = event;
|
||||
}
|
||||
if (!event.skip) {
|
||||
thisStart = event.timeStart;
|
||||
thisEnd = event.timeEnd;
|
||||
thisId = eventId;
|
||||
}
|
||||
}
|
||||
@@ -320,8 +268,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
loaded={isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousStart={lastEntry?.timeStart}
|
||||
previousEnd={lastEntry?.timeEnd}
|
||||
previousStart={previousStart}
|
||||
previousEnd={previousEnd}
|
||||
previousEventId={previousEventId}
|
||||
playback={isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
@@ -32,8 +32,8 @@ interface RundownEntryProps {
|
||||
eventIndex: number;
|
||||
hasCursor: boolean;
|
||||
isNext: boolean;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
previousStart: MaybeNumber;
|
||||
previousEnd: MaybeNumber;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
isRolling: boolean; // we need to know even if not related to this event
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -36,8 +36,8 @@ interface EventBlockProps {
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
previousStart: MaybeNumber;
|
||||
previousEnd: MaybeNumber;
|
||||
colour: string;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
calculateDuration,
|
||||
checkIsNextDay,
|
||||
dayInMs,
|
||||
getTimeFromPrevious,
|
||||
millisToString,
|
||||
removeTrailingZero,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
|
||||
|
||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
if (!delay) return;
|
||||
@@ -18,24 +10,31 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
|
||||
return `New start ${timeTag}`;
|
||||
}
|
||||
|
||||
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
|
||||
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
|
||||
export function formatOverlap(
|
||||
previousStart: MaybeNumber,
|
||||
previousEnd: MaybeNumber,
|
||||
timeStart: number,
|
||||
): string | undefined {
|
||||
const noPreviousElement = previousEnd === null || previousStart === null;
|
||||
if (noPreviousElement) return;
|
||||
|
||||
const normalisedDuration = calculateDuration(previousStart, previousEnd);
|
||||
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
|
||||
if (timeFromPrevious === 0) return;
|
||||
const overlap = previousEnd - timeStart;
|
||||
if (overlap === 0) return;
|
||||
|
||||
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
|
||||
const previousCrossMidnight = previousStart > previousEnd;
|
||||
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
|
||||
const previousCrossMidnight = previousStart > previousEnd;
|
||||
const isNextDay = previousCrossMidnight
|
||||
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight
|
||||
: checkIsNextDay(previousStart, timeStart);
|
||||
|
||||
const gap = dayInMs - normalisedPreviousEnd + timeStart;
|
||||
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
|
||||
|
||||
if (isNextDay) {
|
||||
const gap = dayInMs - correctedPreviousEnd + timeStart;
|
||||
if (gap === 0) return;
|
||||
const gapString = formatDuration(Math.abs(gap), false);
|
||||
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
|
||||
return `Gap ${gapString} (next day)`;
|
||||
}
|
||||
|
||||
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
|
||||
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
|
||||
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
|
||||
return `${overlap > 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { formatDelay, formatOverlap } from './EventBlock.utils';
|
||||
|
||||
import style from './RundownIndicators.module.scss';
|
||||
|
||||
interface RundownIndicatorProps {
|
||||
timeStart: number;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
previousStart: MaybeNumber;
|
||||
previousEnd: MaybeNumber;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default function RundownIndicators(props: RundownIndicatorProps) {
|
||||
const { timeStart, previousStart, previousEnd, delay } = props;
|
||||
|
||||
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
const hasDelay = formatDelay(timeStart, delay);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,63 +16,47 @@ describe('formatOverlap()', () => {
|
||||
const previousStart = 0;
|
||||
const previousEnd = 60000; // 1 min
|
||||
const timeStart = 30000; // 30 sec
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toEqual('Overlap 30s');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toEqual('Overlap 0:30');
|
||||
});
|
||||
|
||||
it('bug #949 recognises an overlap between two times', () => {
|
||||
const previousStart = 46800000; // 13:00:00
|
||||
const previousEnd = 48600000; // 13:30:00
|
||||
const timeStart = 48300000; // 13:25:00
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toEqual('Overlap 5m');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toEqual('Overlap 5:00');
|
||||
});
|
||||
|
||||
it('handles events the day after, without overlap', () => {
|
||||
const previousStart = 11 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 12 * MILLIS_PER_HOUR;
|
||||
const timeStart = 6 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 18h (next day)');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 18:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with gap', () => {
|
||||
const previousStart = 17 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 23 * MILLIS_PER_HOUR;
|
||||
const timeStart = 9 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 10h (next day)');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 10:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending at midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
|
||||
const previousEnd = 0; // 00:00:00
|
||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 1h (next day)');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 01:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles sequential events the day after, with previous ending over midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 1 * MILLIS_PER_HOUR;
|
||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending over midnight with overlap', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 2 * MILLIS_PER_HOUR;
|
||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Overlap 1h');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending over midnight with gap', () => {
|
||||
it('handles events the day after, with previous ending over midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 1 * MILLIS_PER_HOUR;
|
||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 1h');
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 01:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../../../common/utils/getProgress';
|
||||
import { clamp } from '../../../../common/utils/math';
|
||||
|
||||
import style from './EventBlockProgressBar.module.scss';
|
||||
|
||||
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
|
||||
if (remaining === null || total === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (remaining <= 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (remaining === total) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp(100 - (remaining * 100) / total, 0, 100);
|
||||
}
|
||||
|
||||
export default function EventBlockProgressBar() {
|
||||
const timer = useTimer();
|
||||
|
||||
const progress = getProgress(timer.current, timer.duration);
|
||||
|
||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||
const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
|
||||
return <div className={style.progressBar} style={{ width: progress }} />;
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { getPercentComplete } from '../EventBlockProgressBar';
|
||||
|
||||
describe('getPercentComplete()', () => {
|
||||
describe('calculates progress in normal cases', () => {
|
||||
const testScenarios = [
|
||||
{ current: 0, duration: 0, expect: 100 },
|
||||
{ current: 0, duration: 100, expect: 100 },
|
||||
{ current: 0, duration: dayInMs, expect: 100 },
|
||||
{ current: 10, duration: 100, expect: 90 },
|
||||
{ current: 50, duration: 100, expect: 50 },
|
||||
{ current: 100, duration: 100, expect: 0 },
|
||||
];
|
||||
|
||||
testScenarios.forEach((testCase) => {
|
||||
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
|
||||
const progress = getPercentComplete(testCase.current, testCase.duration);
|
||||
expect(progress).toBe(testCase.expect);
|
||||
});
|
||||
});
|
||||
});
|
||||
it('is 0 if we dont have a current or duration', () => {
|
||||
const progress = getPercentComplete(null, null);
|
||||
expect(progress).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@
|
||||
}
|
||||
td:nth-child(even) {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,6 @@ function EventEditorEmpty() {
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Select block</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deselect entry</td>
|
||||
<td>
|
||||
|
||||
@@ -39,7 +39,7 @@ type WithDataProps = {
|
||||
publicSelectedId: string | null;
|
||||
runtime: Runtime;
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined; // TODO: what is the case for this being undefined?
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||
stageTimer = removeLeadingZero(stageTimer);
|
||||
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
|
||||
|
||||
@@ -110,8 +111,8 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
<ProgressBar
|
||||
className='progress-container'
|
||||
current={time.current}
|
||||
duration={time.duration}
|
||||
now={time.current ?? undefined}
|
||||
complete={totalTime}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,18 @@ 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',
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$timeline-entry-height: 20px;
|
||||
$lane-height: 120px;
|
||||
$timeline-height: 1rem;
|
||||
|
||||
.timeline {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
background-color: $ui-black;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { useTimelineOverview } from '../../../common/hooks/useSocket';
|
||||
|
||||
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';
|
||||
|
||||
interface TimelineProps {
|
||||
selectedEventId: string | null;
|
||||
rundown: OntimeEvent[];
|
||||
}
|
||||
|
||||
export default memo(Timeline);
|
||||
|
||||
function Timeline(props: TimelineProps) {
|
||||
const { selectedEventId, rundown } = props;
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
const { plannedStart, plannedEnd } = useTimelineOverview();
|
||||
|
||||
if (plannedStart === null || plannedEnd === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastEvent } = getLastEvent(rundown);
|
||||
const startHour = getStartHour(plannedStart);
|
||||
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0));
|
||||
|
||||
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;
|
||||
}
|
||||
// TODO: timeline must accumulate normalised time over days
|
||||
const isNextDay =
|
||||
previousEventStartTime !== null
|
||||
? checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||
: false;
|
||||
const normalisedStart = hasTimelinePassedMidnight || isNextDay ? 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} // dataset solves issues related to crossing midnight
|
||||
title={event.title}
|
||||
width={elementWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.timeline {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
||||
.project-header {
|
||||
padding-inline: 2rem;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
}
|
||||
|
||||
.title-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 3fr;
|
||||
row-gap: 1rem;
|
||||
column-gap: 2rem;
|
||||
grid-template-areas:
|
||||
'now next'
|
||||
'now following';
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-radius: $element-border-radius;
|
||||
}
|
||||
|
||||
.section--now {
|
||||
grid-area: now;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
line-height: 1em;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title__label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-title__status {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
min-height: 2em;
|
||||
line-height: 1em;
|
||||
font-size: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-content--now {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.section-content--next {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
.section-content--subdue {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import Section from './timeline-section/TimelineSection';
|
||||
import Timeline from './Timeline';
|
||||
import { getTimelineOptions } from './timeline.options';
|
||||
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
|
||||
|
||||
import './TimelinePage.scss';
|
||||
|
||||
interface TimelinePageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
selectedId: MaybeString;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = formatTime(time.clock);
|
||||
|
||||
// holds copy of the rundown with only relevant events
|
||||
const scopedRundown = useMemo(() => {
|
||||
return getScopedRundown(backstageEvents, selectedId);
|
||||
}, [backstageEvents, selectedId]);
|
||||
|
||||
const { now, next, followedBy } = useMemo(() => {
|
||||
return getUpcomingEvents(scopedRundown, selectedId);
|
||||
}, [scopedRundown, selectedId]);
|
||||
|
||||
useWindowTitle('Timeline');
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// populate options
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const progressOptions = getTimelineOptions(defaultFormat);
|
||||
|
||||
const titleNow = now?.title ?? '-';
|
||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||
const nextText = next !== null ? next.title : '-';
|
||||
const followedByText = followedBy !== null ? followedBy.title : '-';
|
||||
|
||||
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
|
||||
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
|
||||
|
||||
return (
|
||||
<div className='timeline'>
|
||||
<ViewParamsEditor viewOptions={progressOptions} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='title-grid'>
|
||||
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
|
||||
<Section title={getLocalizedString('common.next')} status={nextStatus} content={nextText} category='next' />
|
||||
<Section
|
||||
title={getLocalizedString('timeline.followedby')}
|
||||
status={followedByStatus}
|
||||
content={followedByText}
|
||||
category='next'
|
||||
/>
|
||||
</div>
|
||||
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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, index) => {
|
||||
return <span key={`${index}-${tag}`}>{tag}</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
position: relative;
|
||||
|
||||
background-color: $gray-1000;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
background-color: $active-red;
|
||||
transition-duration: 0.3s;
|
||||
transition-property: width;
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
// TODO: how to account for days?
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
interface SectionProps {
|
||||
category: 'now' | 'next';
|
||||
content: MaybeString;
|
||||
title: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export default memo(Section);
|
||||
|
||||
export function Section(props: SectionProps) {
|
||||
const { category, content, title, status } = props;
|
||||
|
||||
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
|
||||
const contentClasses = cx(['section-content', content ? `section-content--${category}` : 'section-content--subdue']);
|
||||
return (
|
||||
<div className={sectionClasses}>
|
||||
<div className='section-title'>
|
||||
<span className='section-title__label'>{title}</span>
|
||||
{status && <span className='section-title__status'>{status}</span>}
|
||||
</div>
|
||||
<div className={contentClasses}>{content ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -1,13 +1,51 @@
|
||||
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 />
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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,7 +7,6 @@ $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);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ $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;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { langDe } from './languages/de';
|
||||
import { langEn } from './languages/en';
|
||||
import { langEs } from './languages/es';
|
||||
import { langFr } from './languages/fr';
|
||||
import { langHu } from './languages/hu';
|
||||
import { langIt } from './languages/it';
|
||||
import { langNo } from './languages/no';
|
||||
import { langPl } from './languages/pl';
|
||||
@@ -17,7 +16,6 @@ const translationsList = {
|
||||
en: langEn,
|
||||
es: langEs,
|
||||
fr: langFr,
|
||||
hu: langHu,
|
||||
it: langIt,
|
||||
de: langDe,
|
||||
no: langNo,
|
||||
|
||||
@@ -19,8 +19,4 @@ 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,10 +17,6 @@ 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,8 +19,4 @@ 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,8 +19,4 @@ 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',
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langHu: TranslationObject = {
|
||||
'common.expected_finish': 'Várható befejezés',
|
||||
'common.minutes': 'perc',
|
||||
'common.now': 'Most',
|
||||
'common.next': 'Következő',
|
||||
'common.public_message': 'Nyilvános közlemény',
|
||||
'common.scheduled_start': 'Ütemezett kezdés',
|
||||
'common.scheduled_end': 'Ütemezett befejezés',
|
||||
'common.projected_start': 'Várható kezdés',
|
||||
'common.projected_end': 'Várható befejezés',
|
||||
'common.stage_timer': 'Színpadi időzítő',
|
||||
'common.started_at': 'Kezdődött',
|
||||
'common.time_now': 'Jelenlegi idő',
|
||||
'countdown.ended': 'Esemény véget ért',
|
||||
'countdown.running': 'Esemény folyamatban',
|
||||
'countdown.select_event': 'Válassza ki a követendő eseményt',
|
||||
'countdown.to_start': 'Idő kezdésig',
|
||||
'countdown.waiting': 'Várakozás az esemény kezdetére',
|
||||
'countdown.overtime': 'csúszik',
|
||||
'timeline.live': 'élő',
|
||||
'timeline.done': 'kész',
|
||||
'timeline.due': 'esedékes',
|
||||
'timeline.followedby': 'Követi',
|
||||
};
|
||||
@@ -19,8 +19,4 @@ 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,8 +19,4 @@ 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,8 +19,4 @@ 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,8 +19,4 @@ 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,8 +19,4 @@ 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',
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ export const navigatorConstants = [
|
||||
{ url: '/clock', label: 'Clock' },
|
||||
{ url: '/minimal', label: 'Minimal Timer' },
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/timeline', label: 'Timeline (beta)' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -188,9 +187,7 @@ app.whenReady().then(() => {
|
||||
? electronConfig.reactAppUrl.production(port)
|
||||
: electronConfig.reactAppUrl.development(port);
|
||||
|
||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
|
||||
win.loadURL(`${clientUrl}/${path}`);
|
||||
});
|
||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl);
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.5.0",
|
||||
"version": "3.4.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -14,14 +14,15 @@
|
||||
"devDependencies": {
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.0.3",
|
||||
"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",
|
||||
|
||||
@@ -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, version, redirectWindow) {
|
||||
function getApplicationMenu(isMac, askToQuit, urlBase) {
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
@@ -59,19 +59,6 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
|
||||
{
|
||||
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',
|
||||
@@ -98,9 +85,15 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Timeline (beta)',
|
||||
label: 'Public',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/timeline`);
|
||||
await shell.openExternal(`${urlBase}/public`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Lower Thirds',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/lower`);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,14 +150,6 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About',
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: version,
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: 'See on github',
|
||||
click: async () => {
|
||||
|
||||
+19
-15
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.5.0",
|
||||
"version": "3.4.2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -27,37 +27,41 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"esbuild": "^0.19.10",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "catalog:",
|
||||
"prettier": "^3.3.1",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "catalog:",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
"postinstall": "pnpm addversion",
|
||||
"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 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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
deleteEvent,
|
||||
editEvent,
|
||||
reorderEvent,
|
||||
setFrozenState,
|
||||
swapEvents,
|
||||
} from '../../services/rundown-service/RundownService.js';
|
||||
import {
|
||||
@@ -127,17 +126,6 @@ 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;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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,7 +5,6 @@ import {
|
||||
rundownApplyDelay,
|
||||
rundownBatchPut,
|
||||
rundownDelete,
|
||||
rundownFrozenPost,
|
||||
rundownGetAll,
|
||||
rundownGetById,
|
||||
rundownGetNormalised,
|
||||
@@ -19,14 +18,12 @@ 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();
|
||||
|
||||
@@ -36,14 +33,13 @@ 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, preventIfFrozen, rundownReorder);
|
||||
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
|
||||
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||
router.patch('/swap', rundownSwapValidator, rundownSwap);
|
||||
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
||||
|
||||
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
|
||||
router.delete('/all', preventIfFrozen, rundownDelete);
|
||||
router.delete('/', rundownArrayOfIds, deletesEventById);
|
||||
router.delete('/all', rundownDelete);
|
||||
|
||||
@@ -21,15 +21,6 @@ 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(),
|
||||
|
||||
+1
-13
@@ -19,7 +19,7 @@ import {
|
||||
resolvePublicDirectoy,
|
||||
} from './setup/index.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
|
||||
import { consoleSuccess, consoleHighlight } from './utils/console.js';
|
||||
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
@@ -179,10 +179,6 @@ 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,
|
||||
@@ -192,7 +188,6 @@ export const startServer = async (
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
},
|
||||
frozen: false,
|
||||
});
|
||||
|
||||
// initialise logging service, escalateErrorFn is only exists in electron
|
||||
@@ -268,7 +263,6 @@ 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();
|
||||
@@ -285,18 +279,12 @@ 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,19 +13,24 @@ import {
|
||||
import type { Low } from 'lowdb';
|
||||
import { JSONFilePreset } from 'lowdb/node';
|
||||
|
||||
import { isTest } from '../../setup/index.js';
|
||||
import { isProduction, 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) {
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
|
||||
if (!isProduction) {
|
||||
if (!isPath(filePath)) {
|
||||
consoleError(filePath);
|
||||
consoleError(new Error('initPersistence should be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
|
||||
|
||||
// Read the database to initialize it
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
This directory holds the demo file shipped with Ontime
|
||||
@@ -11,7 +11,6 @@ export type RestorePoint = {
|
||||
addedTime: number;
|
||||
pausedAt: MaybeNumber;
|
||||
firstStart: MaybeNumber;
|
||||
blockStartAt: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,11 +45,7 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.firstStart !== 'number' && restorePoint.firstStart !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.blockStartAt !== 'number' && restorePoint.blockStartAt !== null) {
|
||||
if (typeof restorePoint.firstStart !== 'number' && restorePoint.pausedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { OntimeEvent } 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 EventTimer {
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timeout;
|
||||
/** how often we recalculate */
|
||||
static _refreshInterval: number;
|
||||
|
||||
/** when timer will be finished */
|
||||
private endCallback: NodeJS.Timeout | undefined = undefined;
|
||||
private endCallback: NodeJS.Timeout;
|
||||
|
||||
private onUpdateCallback: UpdateCallbackFn | undefined = undefined;
|
||||
private onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
@@ -23,18 +23,17 @@ export class EventTimer {
|
||||
* @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 }) {
|
||||
EventTimer._refreshInterval = timerConfig.refresh;
|
||||
constructor(timerConfig: {
|
||||
refresh: number;
|
||||
updateInterval: number;
|
||||
onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
}) {
|
||||
TimerService._refreshInterval = timerConfig.refresh;
|
||||
|
||||
this.onUpdateCallback = timerConfig.onUpdateCallback;
|
||||
this._interval = setInterval(() => {
|
||||
this.update();
|
||||
}, EventTimer._refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting a callback for when the timer updates
|
||||
*/
|
||||
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
|
||||
this.onUpdateCallback = callback;
|
||||
}, TimerService._refreshInterval);
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -70,6 +69,7 @@ export class EventTimer {
|
||||
|
||||
/**
|
||||
* Adds time to running timer by given amount
|
||||
* @param {number} amount
|
||||
*/
|
||||
addTime(amount: number): boolean {
|
||||
if (!runtimeState.addTime(amount)) {
|
||||
@@ -79,12 +79,6 @@ export class EventTimer {
|
||||
// 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;
|
||||
}
|
||||
@@ -95,7 +89,15 @@ export class EventTimer {
|
||||
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);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
@@ -14,7 +14,6 @@ describe('isRestorePoint()', () => {
|
||||
addedTime: 2,
|
||||
pausedAt: 3,
|
||||
firstStart: 1,
|
||||
blockStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
|
||||
@@ -25,7 +24,6 @@ describe('isRestorePoint()', () => {
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1,
|
||||
blockStartAt: null,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
});
|
||||
@@ -38,7 +36,6 @@ describe('isRestorePoint()', () => {
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
blockStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
@@ -48,7 +45,6 @@ describe('isRestorePoint()', () => {
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
blockStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
@@ -59,7 +55,6 @@ describe('isRestorePoint()', () => {
|
||||
startedAt: 'testing',
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
blockStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
@@ -76,7 +71,6 @@ describe('RestoreService()', () => {
|
||||
addedTime: 5678,
|
||||
pausedAt: 9087,
|
||||
firstStart: 1234,
|
||||
blockStartAt: 1652,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
@@ -94,7 +88,6 @@ describe('RestoreService()', () => {
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1234,
|
||||
blockStartAt: null,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
@@ -112,7 +105,6 @@ describe('RestoreService()', () => {
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
firstStart: 1234,
|
||||
blockStartAt: 10,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
@@ -132,7 +124,6 @@ describe('RestoreService()', () => {
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
firstStart: 1234,
|
||||
blockStartAt: null,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
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,14 +1,16 @@
|
||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
||||
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, 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';
|
||||
|
||||
@@ -695,6 +697,520 @@ 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,
|
||||
@@ -721,6 +1237,196 @@ 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 = {
|
||||
@@ -848,7 +1554,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: { pausedAt: null },
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -889,7 +1595,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: { pausedAt: null },
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -924,7 +1630,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: 0,
|
||||
offset: null,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
@@ -941,7 +1647,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_timer: { pausedAt: null },
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -976,7 +1682,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: 0,
|
||||
offset: null,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
@@ -993,7 +1699,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_timer: { pausedAt: null },
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -1016,7 +1722,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: 0,
|
||||
offset: null,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 82000000, // 22:46:40 <--- started now
|
||||
@@ -1033,7 +1739,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 82000000, // <--- started now
|
||||
},
|
||||
_timer: { pausedAt: null },
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const updateCurrent = getCurrent(state);
|
||||
@@ -1181,7 +1887,7 @@ describe('getTimerPhase()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: 0,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
@@ -1203,6 +1909,7 @@ describe('getTimerPhase()', () => {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
@@ -1220,7 +1927,7 @@ describe('getTimerPhase()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: 0,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
@@ -1242,6 +1949,7 @@ 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, isTest } from '../../setup/index.js';
|
||||
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { shouldCrashDev } from '../../utils/development.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
|
||||
interface AppState {
|
||||
lastLoadedProject?: string;
|
||||
@@ -27,8 +27,13 @@ export async function getLastLoadedProject(): Promise<string | undefined> {
|
||||
|
||||
export async function setLastLoadedProject(filename: string): Promise<void> {
|
||||
if (isTest) return;
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: shouldCrashDev(isPath(filename), 'setLastLoadedProject should not be called with a path');
|
||||
if (!isProduction) {
|
||||
if (isPath(filename)) {
|
||||
consoleError(filename);
|
||||
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
config.data.lastLoadedProject = filename;
|
||||
await config.write();
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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,19 +20,17 @@ import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
|
||||
import * as cache from './rundownCache.js';
|
||||
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import { getPlayableEvents } from './rundownUtils.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,
|
||||
@@ -215,8 +213,8 @@ export async function swapEvents(from: string, to: string) {
|
||||
* Called when we make changes to the rundown object
|
||||
*/
|
||||
function updateRuntimeOnChange() {
|
||||
const timedEvents = getTimedEvents();
|
||||
const numEvents = timedEvents.length;
|
||||
const playableEvents = getPlayableEvents();
|
||||
const numEvents = playableEvents.length;
|
||||
const metadata = cache.getMetadata();
|
||||
|
||||
// schedule an update for the end of the event loop
|
||||
@@ -241,7 +239,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.notifyOfChangedEvents(affected);
|
||||
runtimeService.maybeUpdate(playableEvents, affected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +262,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
|
||||
import {
|
||||
@@ -70,13 +70,13 @@ describe('generate()', () => {
|
||||
|
||||
it('accounts for gaps in rundown when calculating delays', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
|
||||
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
@@ -89,74 +89,15 @@ describe('generate()', () => {
|
||||
expect(initResult.totalDuration).toBe(700 - 100);
|
||||
});
|
||||
|
||||
it('accounts for overlaps in rundown', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start
|
||||
});
|
||||
|
||||
it('accounts for overlaps in rundown (with added gap)', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start
|
||||
});
|
||||
|
||||
it('accounts for overlaps in rundown (with multiple days)', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1',
|
||||
timeStart: 9 * MILLIS_PER_HOUR,
|
||||
timeEnd: 10 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE,
|
||||
timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE,
|
||||
duration: 30 * MILLIS_PER_MINUTE,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '3',
|
||||
timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
|
||||
timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '4',
|
||||
timeStart: 9 * MILLIS_PER_HOUR,
|
||||
timeEnd: 10 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start
|
||||
});
|
||||
|
||||
it('handles negative delays', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
|
||||
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
@@ -231,17 +172,10 @@ describe('generate()', () => {
|
||||
|
||||
it('calculates total duration', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'skipped',
|
||||
skip: true,
|
||||
timeStart: 300,
|
||||
timeEnd: 400,
|
||||
duration: 100,
|
||||
} as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
@@ -252,16 +186,9 @@ describe('generate()', () => {
|
||||
it('calculates total duration with 0 duration events without causing a next day', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'skipped',
|
||||
skip: true,
|
||||
timeStart: 300,
|
||||
timeEnd: 400,
|
||||
duration: 0,
|
||||
} as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
@@ -274,28 +201,27 @@ describe('generate()', () => {
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1',
|
||||
timeStart: 9 * MILLIS_PER_HOUR,
|
||||
timeEnd: 23 * MILLIS_PER_HOUR,
|
||||
duration: (23 - 9) * MILLIS_PER_HOUR,
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: 9 * MILLIS_PER_HOUR,
|
||||
timeEnd: 23 * MILLIS_PER_HOUR,
|
||||
duration: (23 - 9) * MILLIS_PER_HOUR,
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '3',
|
||||
timeStart: 9 * MILLIS_PER_HOUR,
|
||||
timeEnd: 23 * MILLIS_PER_HOUR,
|
||||
duration: (23 - 9) * MILLIS_PER_HOUR,
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR);
|
||||
const expectedDuration = (23 - 9 + 48) * MILLIS_PER_HOUR;
|
||||
expect(millisToString(initResult.totalDuration)).toBe('62:00:00');
|
||||
expect(initResult.totalDuration).toBe(expectedDuration);
|
||||
});
|
||||
|
||||
it('calculates total duration across days', () => {
|
||||
@@ -303,21 +229,20 @@ describe('generate()', () => {
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1',
|
||||
timeStart: 12 * MILLIS_PER_HOUR,
|
||||
timeEnd: 22 * MILLIS_PER_HOUR,
|
||||
duration: 10 * MILLIS_PER_HOUR,
|
||||
timeStart: new Date(0).setHours(12),
|
||||
timeEnd: new Date(0).setHours(22),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: 22 * MILLIS_PER_HOUR,
|
||||
timeEnd: 8 * MILLIS_PER_HOUR,
|
||||
duration: (24 - 22 + 8) * MILLIS_PER_HOUR,
|
||||
timeStart: new Date(0).setHours(22),
|
||||
timeEnd: new Date(0).setHours(8),
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
|
||||
expect(millisToString(initResult.totalDuration)).toBe('20:00:00');
|
||||
expect(initResult.totalDuration).toBe(expectedDuration);
|
||||
});
|
||||
|
||||
@@ -421,7 +346,7 @@ describe('generate()', () => {
|
||||
];
|
||||
const initResult = generate(testRundown, customProperties);
|
||||
expect(initResult.order.length).toBe(2);
|
||||
expect(initResult.assignedCustomFields).toMatchObject({
|
||||
expect(initResult.assignedCustomProperties).toMatchObject({
|
||||
lighting: ['1', '2'],
|
||||
sound: ['2'],
|
||||
});
|
||||
@@ -557,6 +482,20 @@ describe('swap() mutation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
describe('calculateRuntimeDelays', () => {
|
||||
it('calculates all delays in a given rundown', () => {
|
||||
const rundown: OntimeRundown = [
|
||||
|
||||
@@ -4,16 +4,16 @@ import {
|
||||
CustomFields,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
MaybeNumber,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
PlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { generateId, insertAtIndex, reorderArray, swapEventData, getTimeFromPrevious, isNewLatest } from 'ontime-utils';
|
||||
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { createPatch } from '../../utils/parser.js';
|
||||
import { getTotalDuration } from '../timerUtils.js';
|
||||
import { apply } from './delayUtils.js';
|
||||
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
|
||||
|
||||
@@ -83,78 +83,74 @@ export function generate(
|
||||
totalDuration = 0;
|
||||
totalDelay = 0;
|
||||
|
||||
let lastEntry: PlayableEvent | null = null;
|
||||
let accumulatedDelay = 0;
|
||||
let daySpan = 0;
|
||||
let previousStart: MaybeNumber = null;
|
||||
let previousEnd: MaybeNumber = null;
|
||||
let previousDuration: MaybeNumber = null;
|
||||
|
||||
for (let i = 0; i < initialRundown.length; i++) {
|
||||
// TODO: filter properties that should not be persisted (eg: delay)
|
||||
// we assign a reference to the current entry, this will be mutated in place
|
||||
const currentEntry = initialRundown[i];
|
||||
const currentEvent = initialRundown[i];
|
||||
const updatedEvent = { ...currentEvent };
|
||||
|
||||
if (isOntimeEvent(currentEntry)) {
|
||||
// 1. handle links - mutates updatedEvent
|
||||
handleLink(i, initialRundown, currentEntry, links);
|
||||
if (isOntimeEvent(updatedEvent)) {
|
||||
// 1. handle links
|
||||
handleLink(i, initialRundown, updatedEvent, links);
|
||||
|
||||
// 2. handle custom fields - mutates updatedEvent
|
||||
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
||||
// 2. handle custom fields
|
||||
handleCustomField(customFields, customFieldChangelog, updatedEvent, assignedCustomFields);
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(currentEntry)) {
|
||||
// fist start is always the first event
|
||||
// update the persisted event
|
||||
initialRundown[i] = updatedEvent;
|
||||
|
||||
// we need to generate the skip event, but dont want to use its times
|
||||
if (!updatedEvent.skip) {
|
||||
// update rundown duration
|
||||
if (firstStart === null) {
|
||||
firstStart = currentEntry.timeStart;
|
||||
firstStart = updatedEvent.timeStart;
|
||||
}
|
||||
lastEnd = updatedEvent.timeEnd;
|
||||
|
||||
const timeFromPrevious: number = getTimeFromPrevious(
|
||||
currentEntry.timeStart,
|
||||
lastEntry?.timeStart,
|
||||
lastEntry?.timeEnd,
|
||||
lastEntry?.duration,
|
||||
);
|
||||
|
||||
if (timeFromPrevious === 0) {
|
||||
// event starts on previous finish, we add its duration
|
||||
totalDuration += currentEntry.duration;
|
||||
} else if (timeFromPrevious > 0) {
|
||||
// event has a gap, we add the gap and the duration
|
||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
||||
} else if (timeFromPrevious < 0) {
|
||||
// there is an overlap, we remove the overlap from the duration
|
||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||
// NOTE: we add the gap since it is a negative number
|
||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||
}
|
||||
|
||||
// remove eventual gaps from the accumulated delay
|
||||
// we only affect positive delays (time forwards)
|
||||
if (totalDelay > 0 && timeFromPrevious > 0) {
|
||||
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
|
||||
}
|
||||
// current event delay is the current accumulated delay
|
||||
currentEntry.delay = totalDelay;
|
||||
|
||||
// lastEntry is the event with the latest end time
|
||||
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
||||
lastEntry = currentEntry;
|
||||
// check if we go over midnight, account for eventual gaps
|
||||
const gapOverMidnight =
|
||||
previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart, previousDuration);
|
||||
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
|
||||
if (gapOverMidnight || durationOverMidnight) {
|
||||
daySpan++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculate delays
|
||||
// !!! this must happen after handling the links
|
||||
if (isOntimeDelay(currentEntry)) {
|
||||
totalDelay += currentEntry.duration;
|
||||
if (isOntimeDelay(updatedEvent)) {
|
||||
accumulatedDelay += updatedEvent.duration;
|
||||
} else if (isOntimeEvent(updatedEvent) && !updatedEvent.skip) {
|
||||
const eventStart = updatedEvent.timeStart;
|
||||
|
||||
// we only affect positive delays (time forwards)
|
||||
if (accumulatedDelay > 0 && previousEnd) {
|
||||
const gap = Math.max(eventStart - previousEnd, 0);
|
||||
accumulatedDelay = Math.max(accumulatedDelay - gap, 0);
|
||||
}
|
||||
updatedEvent.delay = accumulatedDelay;
|
||||
previousStart = updatedEvent.timeStart;
|
||||
previousEnd = updatedEvent.timeEnd;
|
||||
previousDuration = updatedEvent.duration;
|
||||
}
|
||||
|
||||
// add id to order
|
||||
order.push(currentEntry.id);
|
||||
// add entry to rundown
|
||||
rundown[currentEntry.id] = currentEntry;
|
||||
order.push(updatedEvent.id);
|
||||
rundown[updatedEvent.id] = { ...updatedEvent };
|
||||
}
|
||||
|
||||
lastEnd = lastEntry?.timeEnd ?? null;
|
||||
isStale = false;
|
||||
customFieldChangelog.clear();
|
||||
return { rundown, order, links, totalDelay, totalDuration, assignedCustomFields };
|
||||
totalDelay = accumulatedDelay;
|
||||
if (lastEnd !== null && firstStart !== null) {
|
||||
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
|
||||
}
|
||||
|
||||
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
|
||||
}
|
||||
|
||||
/** Returns an ID guaranteed to be unique */
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types';
|
||||
import { filterPlayable, filterTimedEvents } from 'ontime-utils';
|
||||
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
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();
|
||||
@@ -19,20 +16,32 @@ export function getRundown(): OntimeRundown {
|
||||
|
||||
/**
|
||||
* returns all events of type OntimeEvent
|
||||
* @return {array}
|
||||
*/
|
||||
export function getTimedEvents(): OntimeEvent[] {
|
||||
return filterTimedEvents(getRundown());
|
||||
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all events that can be loaded
|
||||
* @return {array}
|
||||
*/
|
||||
export function getPlayableEvents(): PlayableEvent[] {
|
||||
return filterPlayable(getRundown());
|
||||
export function getPlayableEvents(): OntimeEvent[] {
|
||||
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* returns number of events that can be loaded
|
||||
* @return {number}
|
||||
*/
|
||||
export function getNumEvents(): number {
|
||||
return getPlayableEvents().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns an event given its index after filtering for OntimeEvents
|
||||
* @param {number} eventIndex
|
||||
* @return {OntimeEvent | undefined}
|
||||
*/
|
||||
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
|
||||
const timedEvents = getTimedEvents();
|
||||
@@ -41,6 +50,8 @@ 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();
|
||||
@@ -49,14 +60,17 @@ 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 playableEvents = getPlayableEvents();
|
||||
const timedEvents = getPlayableEvents();
|
||||
const lowerCaseCue = targetCue.toLowerCase();
|
||||
|
||||
for (let i = currentEventIndex; i < playableEvents.length; i++) {
|
||||
const event = playableEvents.at(i);
|
||||
if (event?.cue.toLowerCase() === lowerCaseCue) {
|
||||
for (let i = currentEventIndex; i < timedEvents.length; i++) {
|
||||
const event = timedEvents.at(i);
|
||||
if (event && event.cue.toLowerCase() === lowerCaseCue) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -64,41 +78,43 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
|
||||
|
||||
/**
|
||||
* finds the previous event
|
||||
* @return {object | undefined}
|
||||
*/
|
||||
export function findPrevious(currentEventId?: string): OntimeEvent | null {
|
||||
const playableEvents = getPlayableEvents();
|
||||
if (!playableEvents || !playableEvents.length) {
|
||||
const timedEvents = getPlayableEvents();
|
||||
if (!timedEvents || !timedEvents.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if there is no event running, go to first
|
||||
if (!currentEventId) {
|
||||
return playableEvents.at(0) ?? null;
|
||||
return timedEvents.at(0) ?? null;
|
||||
}
|
||||
|
||||
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
|
||||
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
|
||||
const newIndex = Math.max(currentIndex - 1, 0);
|
||||
const previousEvent = playableEvents.at(newIndex) ?? null;
|
||||
const previousEvent = timedEvents.at(newIndex) ?? null;
|
||||
return previousEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* finds the next event
|
||||
* @return {object | undefined}
|
||||
*/
|
||||
export function findNext(currentEventId?: string): PlayableEvent | null {
|
||||
const playableEvents = getPlayableEvents();
|
||||
if (!playableEvents.length) {
|
||||
export function findNext(currentEventId?: string): OntimeEvent | null {
|
||||
const timedEvents = getPlayableEvents();
|
||||
if (!timedEvents || !timedEvents.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if there is no event running, go to first
|
||||
if (!currentEventId) {
|
||||
return playableEvents.at(0) ?? null;
|
||||
return timedEvents.at(0) ?? null;
|
||||
}
|
||||
|
||||
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
|
||||
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
|
||||
const newIndex = currentIndex + 1;
|
||||
const nextEvent = playableEvents.at(newIndex);
|
||||
const nextEvent = timedEvents.at(newIndex);
|
||||
return nextEvent ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
EndAction,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
LogOrigin,
|
||||
MaybeNumber,
|
||||
OntimeEvent,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
} from 'ontime-types';
|
||||
import { millisToString, validatePlayback } from 'ontime-utils';
|
||||
|
||||
@@ -21,7 +19,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { timerConfig } from '../../config/config.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
import { EventTimer } from '../EventTimer.js';
|
||||
import { TimerService } from '../TimerService.js';
|
||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
||||
import {
|
||||
findNext,
|
||||
@@ -29,93 +27,46 @@ import {
|
||||
getEventAtIndex,
|
||||
getNextEventWithCue,
|
||||
getEventWithId,
|
||||
getRundown,
|
||||
getTimedEvents,
|
||||
getPlayableEvents,
|
||||
} 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: EventTimer;
|
||||
private eventTimer: TimerService | null = null;
|
||||
private lastIntegrationClockUpdate = -1;
|
||||
private lastIntegrationTimerValue = -1;
|
||||
|
||||
/** last time we updated the socket */
|
||||
static previousTimerUpdate: number;
|
||||
static previousTimerValue: MaybeNumber; // previous timer value, could be null
|
||||
static previousTimerValue: MaybeNumber;
|
||||
static previousClockUpdate: number;
|
||||
|
||||
/** last known state */
|
||||
static previousState: RuntimeState;
|
||||
|
||||
constructor(eventTimer: EventTimer) {
|
||||
this.eventTimer = eventTimer;
|
||||
|
||||
constructor() {
|
||||
RuntimeService.previousTimerUpdate = -1;
|
||||
RuntimeService.previousTimerValue = -1;
|
||||
RuntimeService.previousClockUpdate = -1;
|
||||
RuntimeService.previousState = {} as RuntimeState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks result of an update and notifies integrations as needed
|
||||
* This is the only exception of a private method that has broadcast result
|
||||
* */
|
||||
/** Checks result of an update and notifies integrations as needed */
|
||||
@broadcastResult
|
||||
private checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
|
||||
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
|
||||
const newState = runtimeState.getState();
|
||||
// 1. find if we need to dispatch integrations related to the phase
|
||||
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
|
||||
if (timerPhaseChanged) {
|
||||
if (newState.timer.phase === TimerPhase.Warning) {
|
||||
process.nextTick(() => {
|
||||
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
|
||||
const keepOffset = newState.runtime.offset;
|
||||
if (hasSecondaryTimerFinished) {
|
||||
// if the secondary timer has finished, we need to call roll
|
||||
// since event is already loaded
|
||||
this.rollLoaded(keepOffset);
|
||||
} else if (hasTimerFinished) {
|
||||
// if the timer has finished, we need to load next and keep rolling
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onFinish);
|
||||
});
|
||||
this.handleLoadNext();
|
||||
this.rollLoaded(keepOffset);
|
||||
} 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) {
|
||||
if (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.eventNow) {
|
||||
if (newState.timer.playback === Playback.Play && newState.eventNow) {
|
||||
if (newState.eventNow.endAction === EndAction.Stop) {
|
||||
setTimeout(this.stop.bind(this), 0);
|
||||
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
|
||||
@@ -126,8 +77,10 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. find if we need to update the timer
|
||||
const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
|
||||
const shouldUpdateTimer =
|
||||
hasRunningTimer && getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
|
||||
if (shouldUpdateTimer) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
@@ -136,7 +89,6 @@ 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(() => {
|
||||
@@ -145,19 +97,49 @@ 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 */
|
||||
public init(resumable: RestorePoint | null) {
|
||||
init(resumable: RestorePoint | null) {
|
||||
logger.info(LogOrigin.Server, 'Runtime service started');
|
||||
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
this.eventTimer = new TimerService({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult),
|
||||
});
|
||||
|
||||
if (resumable) {
|
||||
this.resume(resumable);
|
||||
}
|
||||
}
|
||||
|
||||
public shutdown() {
|
||||
shutdown() {
|
||||
if (this.eventTimer) {
|
||||
logger.info(LogOrigin.Server, 'Runtime service shutting down');
|
||||
this.eventTimer.shutdown();
|
||||
@@ -182,7 +164,7 @@ class RuntimeService {
|
||||
}
|
||||
|
||||
private isNewNext() {
|
||||
const timedEvents = getTimedEvents();
|
||||
const timedEvents = getPlayableEvents();
|
||||
const state = runtimeState.getState();
|
||||
const now = state.eventNow?.id;
|
||||
const next = state.eventNext?.id;
|
||||
@@ -223,7 +205,7 @@ class RuntimeService {
|
||||
* Called when the underlying data has changed,
|
||||
* we check if the change affects the runtime
|
||||
*/
|
||||
public notifyOfChangedEvents(affectedIds?: string[]) {
|
||||
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
|
||||
const state = runtimeState.getState();
|
||||
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
|
||||
if (!hasLoadedElements) {
|
||||
@@ -234,55 +216,49 @@ class RuntimeService {
|
||||
// 1. we are not confident that changes do not affect running event (eg. all events where changed)
|
||||
const safeOption = typeof affectedIds === 'undefined';
|
||||
// 2. the edited event is in memory (now or next) running
|
||||
// behind conditional to avoid doing unnecessary work
|
||||
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
|
||||
// 3. the edited event replaces next event
|
||||
let isNext = false;
|
||||
|
||||
// if we are not sure, or the event is in memory, we reload
|
||||
if (safeOption || eventInMemory) {
|
||||
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)) {
|
||||
// maybe the event was deleted or the skip state was changed
|
||||
runtimeState.stop();
|
||||
return;
|
||||
}
|
||||
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
|
||||
|
||||
if (onlyChangedNow) {
|
||||
runtimeState.updateLoaded(eventNow);
|
||||
} else {
|
||||
const rundown = getRundown();
|
||||
runtimeState.updateAll(rundown);
|
||||
}
|
||||
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)) {
|
||||
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) {
|
||||
const timedEvents = getTimedEvents();
|
||||
runtimeState.loadNext(timedEvents);
|
||||
runtimeState.loadNext(playableEvents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* makes calls for loading and starting given event
|
||||
* @param {PlayableEvent} event
|
||||
* @param {Partial<TimerState & RestorePoint>} initialData
|
||||
* @param {OntimeEvent} event
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
private loadEvent(event: OntimeEvent, initialData?: Partial<TimerState & RestorePoint>): boolean {
|
||||
if (!isPlayableEvent(event)) {
|
||||
@broadcastResult
|
||||
loadEvent(event: OntimeEvent): boolean {
|
||||
if (event.skip) {
|
||||
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const rundown = getRundown();
|
||||
const success = runtimeState.load(event, rundown, initialData);
|
||||
const timedEvents = getPlayableEvents();
|
||||
const success = runtimeState.load(event, timedEvents);
|
||||
|
||||
if (success) {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
@@ -298,8 +274,7 @@ class RuntimeService {
|
||||
* @param {string} eventId
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startById(eventId: string): boolean {
|
||||
startById(eventId: string): boolean {
|
||||
const event = getEventWithId(eventId);
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return false;
|
||||
@@ -308,7 +283,7 @@ class RuntimeService {
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,8 +291,7 @@ class RuntimeService {
|
||||
* @param {number} eventIndex
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startByIndex(eventIndex: number): boolean {
|
||||
startByIndex(eventIndex: number): boolean {
|
||||
const event = getEventAtIndex(eventIndex);
|
||||
if (!event) {
|
||||
return false;
|
||||
@@ -326,7 +300,7 @@ class RuntimeService {
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,8 +308,7 @@ class RuntimeService {
|
||||
* @param {string} cue
|
||||
* @return {boolean} success - whether an event was started
|
||||
*/
|
||||
@broadcastResult
|
||||
public startByCue(cue: string): boolean {
|
||||
startByCue(cue: string): boolean {
|
||||
const event = getNextEventWithCue(cue); //TODO: add index
|
||||
if (!event) {
|
||||
return false;
|
||||
@@ -344,7 +317,7 @@ class RuntimeService {
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,8 +325,7 @@ class RuntimeService {
|
||||
* @param {string} eventId
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadById(eventId: string): boolean {
|
||||
loadById(eventId: string): boolean {
|
||||
const event = getEventWithId(eventId);
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return false;
|
||||
@@ -366,8 +338,7 @@ class RuntimeService {
|
||||
* @param {number} eventIndex
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadByIndex(eventIndex: number): boolean {
|
||||
loadByIndex(eventIndex: number): boolean {
|
||||
const event = getEventAtIndex(eventIndex);
|
||||
if (!event) {
|
||||
return false;
|
||||
@@ -380,8 +351,7 @@ class RuntimeService {
|
||||
* @param {string} cue
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadByCue(cue: string): boolean {
|
||||
loadByCue(cue: string): boolean {
|
||||
const event = getNextEventWithCue(cue); //TODO: add index
|
||||
if (!event) {
|
||||
return false;
|
||||
@@ -390,12 +360,10 @@ class RuntimeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for loading the previous event
|
||||
*
|
||||
* we need to isolate handleLoadPrevious so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
* Loads event before currently selected
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
*/
|
||||
private handleLoadPrevious(): boolean {
|
||||
loadPrevious(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const previousEvent = findPrevious(state.eventNow?.id);
|
||||
if (previousEvent) {
|
||||
@@ -405,28 +373,13 @@ class RuntimeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads event before currently selected
|
||||
* @return {boolean} success - whether an event was loaded
|
||||
* Loads event after currently selected
|
||||
* @return {boolean} success
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadPrevious(): boolean {
|
||||
return this.handleLoadPrevious();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for loading the next event
|
||||
*
|
||||
* we need to isolate handleLoadNext so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
* and pass on runtime offset in case of roll mode
|
||||
*/
|
||||
private handleLoadNext(): boolean {
|
||||
loadNext(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const nextEvent = findNext(state.eventNow?.id);
|
||||
if (nextEvent) {
|
||||
if (state.timer.playback === Playback.Roll) {
|
||||
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
|
||||
}
|
||||
return this.loadEvent(nextEvent);
|
||||
}
|
||||
|
||||
@@ -435,31 +388,18 @@ class RuntimeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads event after currently selected
|
||||
* @return {boolean} success
|
||||
* Starts playback on selected event
|
||||
*/
|
||||
@broadcastResult
|
||||
public loadNext(): boolean {
|
||||
return this.handleLoadNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains logic for starting selected event
|
||||
*
|
||||
* we need to isolate handleStart so we have control over the side effects
|
||||
* startSelected being a private function does not trigger emits
|
||||
*/
|
||||
private handleStart(): boolean {
|
||||
const previousState = runtimeState.getState();
|
||||
const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start;
|
||||
start(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const canStart = validatePlayback(state.timer.playback).start;
|
||||
if (!canStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const didStart = this.eventTimer?.start() ?? false;
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
|
||||
if (didStart) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onStart);
|
||||
@@ -468,52 +408,43 @@ class RuntimeService {
|
||||
return didStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on selected event
|
||||
*/
|
||||
@broadcastResult
|
||||
public start(): boolean {
|
||||
return this.handleStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on previous event
|
||||
*/
|
||||
@broadcastResult
|
||||
public startPrevious(): boolean {
|
||||
const hasPrevious = this.handleLoadPrevious();
|
||||
startPrevious(): boolean {
|
||||
const hasPrevious = this.loadPrevious();
|
||||
if (!hasPrevious) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.handleStart();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on next event
|
||||
*/
|
||||
@broadcastResult
|
||||
public startNext(): boolean {
|
||||
const hasNext = this.handleLoadNext();
|
||||
startNext(): boolean {
|
||||
const hasNext = this.loadNext();
|
||||
if (!hasNext) {
|
||||
return false;
|
||||
}
|
||||
return this.handleStart();
|
||||
|
||||
return this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback on selected event
|
||||
*/
|
||||
@broadcastResult
|
||||
public pause() {
|
||||
pause() {
|
||||
const state = runtimeState.getState();
|
||||
const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause;
|
||||
const canPause = validatePlayback(state.timer.playback).pause;
|
||||
if (!canPause) {
|
||||
return;
|
||||
}
|
||||
this.eventTimer?.pause();
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
const newState = state.timer.playback;
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onPause);
|
||||
});
|
||||
@@ -523,16 +454,16 @@ class RuntimeService {
|
||||
* Stops timer and unloads any events
|
||||
*/
|
||||
@broadcastResult
|
||||
public stop(): boolean {
|
||||
stop(): boolean {
|
||||
const state = runtimeState.getState();
|
||||
const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop;
|
||||
const canStop = validatePlayback(state.timer.playback).stop;
|
||||
if (!canStop) {
|
||||
return false;
|
||||
}
|
||||
const didStop = this.eventTimer?.stop();
|
||||
if (didStop) {
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
const newState = state.timer.playback;
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onStop);
|
||||
});
|
||||
@@ -546,23 +477,16 @@ class RuntimeService {
|
||||
* Reloads current event
|
||||
*/
|
||||
@broadcastResult
|
||||
public reload() {
|
||||
reload() {
|
||||
const state = runtimeState.getState();
|
||||
if (state.eventNow) {
|
||||
return this.loadEvent(state.eventNow);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles special case to call roll on a loaded event which we do not want to discard
|
||||
*/
|
||||
private rollLoaded(offset?: number) {
|
||||
const rundown = getRundown();
|
||||
try {
|
||||
runtimeState.roll(rundown, offset);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||
const eventId = runtimeState.reload();
|
||||
if (eventId) {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${eventId}`);
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onLoad);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,40 +494,24 @@ class RuntimeService {
|
||||
* Sets playback to roll
|
||||
*/
|
||||
@broadcastResult
|
||||
public roll(skipCheck: boolean = false) {
|
||||
const previousState = runtimeState.getState();
|
||||
if (!skipCheck) {
|
||||
const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll;
|
||||
if (!canRoll) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rundown = getRundown();
|
||||
const result = runtimeState.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}`);
|
||||
roll() {
|
||||
const beforeState = runtimeState.getState();
|
||||
const canRoll = validatePlayback(beforeState.timer.playback).roll;
|
||||
if (!canRoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = runtimeState.getState();
|
||||
|
||||
if (previousState.timer.playback !== newState.timer.playback) {
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
const playableEvents = getPlayableEvents();
|
||||
if (playableEvents.length === 0) {
|
||||
logger.warning(LogOrigin.Server, 'Roll: no events found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.eventTimer.roll(playableEvents);
|
||||
|
||||
const state = runtimeState.getState();
|
||||
const newState = state.timer.playback;
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,7 +519,7 @@ class RuntimeService {
|
||||
* @param restorePoint
|
||||
*/
|
||||
@broadcastResult
|
||||
public resume(restorePoint: RestorePoint) {
|
||||
resume(restorePoint: RestorePoint) {
|
||||
const { selectedEventId, playback } = restorePoint;
|
||||
if (playback === Playback.Roll) {
|
||||
this.roll();
|
||||
@@ -623,14 +531,14 @@ class RuntimeService {
|
||||
}
|
||||
|
||||
// the db would have to change for the event not to exist
|
||||
// we do not know the reason for the crash, so we check anyway
|
||||
// we do not kow the reason for the crash, so we check anyway
|
||||
const event = getEventWithId(selectedEventId);
|
||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rundown = getRundown();
|
||||
runtimeState.resume(restorePoint, event, rundown);
|
||||
const timedEvents = getPlayableEvents();
|
||||
runtimeState.resume(restorePoint, event, timedEvents);
|
||||
logger.info(LogOrigin.Playback, 'Resuming playback');
|
||||
}
|
||||
|
||||
@@ -638,26 +546,15 @@ class RuntimeService {
|
||||
* Adds time to current event
|
||||
* @param {number} time - time to add in milliseconds
|
||||
*/
|
||||
@broadcastResult
|
||||
public addTime(time: number) {
|
||||
addTime(time: number) {
|
||||
if (this.eventTimer.addTime(time)) {
|
||||
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
const eventTimer = new EventTimer({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
export const runtimeService = new RuntimeService();
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
* This should only be applied to functions that are exposed for consumption
|
||||
* ie: whenever an external service makes a request, we update the state with the mutation result
|
||||
*/
|
||||
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
@@ -669,6 +566,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
const shouldUpdateClock = getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock);
|
||||
const shouldForceTimerUpdate = getForceUpdate(RuntimeService.previousTimerUpdate, state.clock);
|
||||
const shouldUpdateTimer =
|
||||
shouldForceTimerUpdate || getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current);
|
||||
@@ -702,16 +600,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
updateEventIfChanged('eventNext', state);
|
||||
updateEventIfChanged('publicEventNext', state);
|
||||
|
||||
let syncBlockStartAt = false;
|
||||
|
||||
if (!deepEqual(RuntimeService?.previousState.currentBlock, state.currentBlock)) {
|
||||
eventStore.set('currentBlock', state.currentBlock);
|
||||
RuntimeService.previousState.currentBlock = { ...state.currentBlock };
|
||||
syncBlockStartAt = true;
|
||||
}
|
||||
|
||||
const shouldUpdateClock = syncBlockStartAt || getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock);
|
||||
|
||||
if (shouldUpdateClock) {
|
||||
RuntimeService.previousClockUpdate = state.clock;
|
||||
eventStore.set('clock', state.clock);
|
||||
@@ -755,7 +643,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
firstStart: state.runtime.actualStart,
|
||||
blockStartAt: state.currentBlock.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
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(); // 2 seconds ago
|
||||
const now = Date.now() + 2000;
|
||||
const previousUpdate = Date.now() - 2000; // 2 seconds ago
|
||||
const now = Date.now();
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when we slid backwards', () => {
|
||||
const previousUpdate = Date.now() + 2000;
|
||||
const now = Date.now(); // 2 seconds ago
|
||||
const previousUpdate = Date.now();
|
||||
const now = Date.now() - 2000; // 2 seconds ago
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -33,8 +24,8 @@ describe('getShouldClockUpdate()', () => {
|
||||
});
|
||||
|
||||
it('should return false when clock is not a second ahead and force update is not required', () => {
|
||||
const previousUpdate = Date.now();
|
||||
const now = Date.now() + 32;
|
||||
const previousUpdate = Date.now() - 32;
|
||||
const now = Date.now();
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, 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
|
||||
@@ -55,12 +56,6 @@ 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;
|
||||
@@ -93,11 +88,6 @@ 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;
|
||||
|
||||
@@ -111,6 +101,199 @@ 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
|
||||
@@ -122,14 +305,6 @@ 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;
|
||||
@@ -181,7 +356,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 || state.runtime.plannedEnd === null) {
|
||||
if (state.runtime.actualStart === null) {
|
||||
return null;
|
||||
}
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||
@@ -192,7 +367,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
* @param state
|
||||
* @returns
|
||||
*/
|
||||
export function isPlaybackActive(state: RuntimeState): boolean {
|
||||
function isPlaybackActive(state: RuntimeState): boolean {
|
||||
return (
|
||||
state.timer.playback === Playback.Play ||
|
||||
state.timer.playback === Playback.Pause ||
|
||||
@@ -211,7 +386,7 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
|
||||
const current = state.timer.current;
|
||||
|
||||
if (current === null || state.eventNow === null || state.timer.secondaryTimer != null) {
|
||||
if (current === null || state.eventNow === null) {
|
||||
return TimerPhase.Pending;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const config = {
|
||||
demoProject: 'demo project.json',
|
||||
newProject: 'new project.json',
|
||||
database: {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
},
|
||||
|
||||
@@ -73,6 +73,7 @@ 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');
|
||||
@@ -81,6 +82,13 @@ 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,7 +1,7 @@
|
||||
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import { RuntimeState, addTime, clear, getState, load, pause, roll, start, stop } from '../runtimeState.js';
|
||||
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
|
||||
import { initRundown } from '../../services/rundown-service/RundownService.js';
|
||||
|
||||
const mockEvent = {
|
||||
@@ -11,8 +11,7 @@ const mockEvent = {
|
||||
timeStart: 0,
|
||||
timeEnd: 1000,
|
||||
duration: 1000,
|
||||
skip: false,
|
||||
} as PlayableEvent;
|
||||
} as OntimeEvent;
|
||||
|
||||
const mockState = {
|
||||
clock: 666,
|
||||
@@ -37,6 +36,7 @@ const mockState = {
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('mutation on runtimeState', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('playback operations', async () => {
|
||||
describe('playback operations', () => {
|
||||
it('refuses if nothing is loaded', () => {
|
||||
let success = start(mockState);
|
||||
expect(success).toBe(false);
|
||||
@@ -99,7 +99,6 @@ 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();
|
||||
@@ -145,7 +144,6 @@ describe('mutation on runtimeState', () => {
|
||||
|
||||
// 5. Stop event
|
||||
success = stop();
|
||||
newState = getState();
|
||||
expect(success).toBe(true);
|
||||
expect(newState.eventNow).toBe(null);
|
||||
expect(newState.timer).toMatchObject({
|
||||
@@ -162,19 +160,14 @@ 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
|
||||
vi.useFakeTimers();
|
||||
await initRundown([event1, event2], {});
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
test('runtime offset', async () => {
|
||||
initRundown([event1, event2], {});
|
||||
test('runtime offset', () => {
|
||||
// 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();
|
||||
@@ -206,7 +199,6 @@ 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);
|
||||
@@ -227,103 +219,7 @@ describe('mutation on runtimeState', () => {
|
||||
});
|
||||
|
||||
test.todo('runtime offset on timers in overtime', () => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roll mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime('jan 1 00:00');
|
||||
clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('normal roll', () => {
|
||||
const rundown = [
|
||||
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
||||
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
||||
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
||||
] as PlayableEvent[];
|
||||
|
||||
test('pending event', () => {
|
||||
const { eventId, didStart } = roll(rundown);
|
||||
const state = getState();
|
||||
|
||||
expect(eventId).toBe('1');
|
||||
expect(didStart).toBe(false);
|
||||
expect(state.timer.phase).toBe(TimerPhase.Pending);
|
||||
expect(state.timer.secondaryTimer).toBe(1000);
|
||||
});
|
||||
|
||||
test('roll events', () => {
|
||||
vi.setSystemTime('jan 1 00:00:01');
|
||||
let result = roll(rundown);
|
||||
expect(result).toStrictEqual({ eventId: '1', didStart: true });
|
||||
|
||||
vi.setSystemTime('jan 1 00:00:02');
|
||||
result = roll(rundown);
|
||||
expect(result).toStrictEqual({ eventId: '2', didStart: true });
|
||||
|
||||
vi.setSystemTime('jan 1 00:00:03:500');
|
||||
result = roll(rundown);
|
||||
expect(result).toStrictEqual({ eventId: '3', didStart: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('roll takover', () => {
|
||||
const rundown = [
|
||||
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
||||
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
||||
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
||||
] as PlayableEvent[];
|
||||
|
||||
test('from load', () => {
|
||||
load(rundown[2], rundown);
|
||||
const result = roll(rundown);
|
||||
expect(result).toStrictEqual({ eventId: '3', didStart: false });
|
||||
const state = getState();
|
||||
expect(state.timer.phase).toBe(TimerPhase.Pending);
|
||||
expect(state.timer.secondaryTimer).toBe(3000);
|
||||
});
|
||||
|
||||
test('from play', () => {
|
||||
load(rundown[0], rundown);
|
||||
start();
|
||||
const result = roll(rundown);
|
||||
expect(result).toStrictEqual({ eventId: '1', didStart: false });
|
||||
expect(getState().runtime.offset).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roll continue with offset', () => {
|
||||
test('no gaps', () => {
|
||||
const rundown = [
|
||||
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
||||
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
||||
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
||||
] as PlayableEvent[];
|
||||
|
||||
load(rundown[0], rundown);
|
||||
start();
|
||||
let result = roll(rundown, getState().runtime.offset);
|
||||
expect(result).toStrictEqual({ eventId: '1', didStart: false });
|
||||
expect(getState().runtime.offset).toBe(1000);
|
||||
|
||||
vi.setSystemTime('jan 1 00:00:01');
|
||||
result = roll(rundown, getState().runtime.offset);
|
||||
expect(result).toStrictEqual({ eventId: '2', didStart: true });
|
||||
expect(getState().runtime.offset).toBe(1000);
|
||||
|
||||
vi.setSystemTime('jan 1 00:00:02');
|
||||
result = roll(rundown, getState().runtime.offset);
|
||||
expect(result).toStrictEqual({ eventId: '3', didStart: true });
|
||||
expect(getState().runtime.offset).toBe(1000);
|
||||
});
|
||||
|
||||
test.todo('with gaps', () => {
|
||||
//this is a bit involved as it also depends somewhat on the RintimeService
|
||||
});
|
||||
|
||||
test.todo('roll mode', () => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,55 @@
|
||||
import {
|
||||
CurrentBlockState,
|
||||
isPlayableEvent,
|
||||
MaybeNumber,
|
||||
MaybeString,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
PlayableEvent,
|
||||
Playback,
|
||||
Runtime,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
} from 'ontime-types';
|
||||
import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getPreviousBlock } from 'ontime-utils';
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { RestorePoint } from '../services/RestoreService.js';
|
||||
|
||||
import {
|
||||
getCurrent,
|
||||
getExpectedEnd,
|
||||
getExpectedFinish,
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
isPlaybackActive,
|
||||
skippedOutOfEvent,
|
||||
updateRoll,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../config/config.js';
|
||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||
|
||||
const initialRuntime: Runtime = {
|
||||
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, overflows over dayInMs
|
||||
actualStart: null, // set once we start the timer
|
||||
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
offset: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
} as const;
|
||||
|
||||
const initialTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
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
|
||||
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,
|
||||
} as const;
|
||||
|
||||
export type RuntimeState = {
|
||||
clock: number; // realtime clock
|
||||
eventNow: PlayableEvent | null;
|
||||
currentBlock: CurrentBlockState;
|
||||
publicEventNow: PlayableEvent | null;
|
||||
eventNext: PlayableEvent | null;
|
||||
publicEventNext: PlayableEvent | null;
|
||||
eventNow: OntimeEvent | null;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
publicEventNext: OntimeEvent | null;
|
||||
runtime: Runtime;
|
||||
timer: TimerState;
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
||||
forceFinish: MaybeNumber;
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
@@ -69,10 +58,6 @@ export type RuntimeState = {
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: clock.timeNow(),
|
||||
currentBlock: {
|
||||
block: null,
|
||||
startedAt: null,
|
||||
},
|
||||
eventNow: null,
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
@@ -88,27 +73,13 @@ const runtimeState: RuntimeState = {
|
||||
};
|
||||
|
||||
export function getState(): Readonly<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 },
|
||||
};
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
runtimeState.eventNow = null;
|
||||
runtimeState.publicEventNow = null;
|
||||
runtimeState.eventNext = null;
|
||||
|
||||
runtimeState.currentBlock.block = null;
|
||||
runtimeState.currentBlock.startedAt = null;
|
||||
|
||||
runtimeState.publicEventNext = null;
|
||||
|
||||
runtimeState.runtime.offset = 0;
|
||||
@@ -122,6 +93,7 @@ export function clear() {
|
||||
|
||||
// we maintain the total delay
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +109,7 @@ function patchTimer(newState: Partial<TimerState>) {
|
||||
}
|
||||
|
||||
type RundownData = {
|
||||
numEvents: number; // length of rundown filtered for timed events
|
||||
numEvents: number;
|
||||
firstStart: MaybeNumber;
|
||||
lastEnd: MaybeNumber;
|
||||
totalDelay: number;
|
||||
@@ -149,77 +121,54 @@ type RundownData = {
|
||||
* @param playableRundown
|
||||
*/
|
||||
export function updateRundownData(rundownData: RundownData) {
|
||||
// we keep this in private state since there is no UI use case for it
|
||||
runtimeState._timer.totalDelay = rundownData.totalDelay;
|
||||
|
||||
runtimeState.runtime.numEvents = rundownData.numEvents;
|
||||
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
||||
runtimeState.runtime.plannedEnd =
|
||||
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
|
||||
runtimeState.runtime.plannedEnd = 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: PlayableEvent,
|
||||
rundown: OntimeRundown,
|
||||
event: OntimeEvent,
|
||||
rundown: OntimeEvent[],
|
||||
initialData?: Partial<TimerState & RestorePoint>,
|
||||
): boolean {
|
||||
// we need to persist the current block state across loads
|
||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
||||
clear();
|
||||
runtimeState.currentBlock = prevCurrentBlock;
|
||||
|
||||
// filter rundown
|
||||
const timedEvents = filterTimedEvents(rundown);
|
||||
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
|
||||
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
|
||||
|
||||
if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
runtimeState.runtime.selectedEventIndex = eventIndex;
|
||||
|
||||
// load events in memory along with their data
|
||||
loadNow(timedEvents, eventIndex);
|
||||
loadNext(timedEvents, eventIndex);
|
||||
loadBlock(rundown);
|
||||
loadNow(event, rundown);
|
||||
loadNext(rundown);
|
||||
|
||||
// update state
|
||||
runtimeState.clock = clock.timeNow();
|
||||
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);
|
||||
|
||||
const firstStart = initialData?.firstStart;
|
||||
if (firstStart === null || typeof firstStart === 'number') {
|
||||
runtimeState.runtime.actualStart = firstStart;
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
}
|
||||
if (typeof initialData.blockStartAt === 'number') {
|
||||
runtimeState.currentBlock.startedAt = initialData.blockStartAt;
|
||||
}
|
||||
}
|
||||
|
||||
return event.id === runtimeState.eventNow?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return;
|
||||
}
|
||||
|
||||
const event = timedEvents[eventIndex] as PlayableEvent;
|
||||
runtimeState.runtime.selectedEventIndex = eventIndex;
|
||||
export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) {
|
||||
runtimeState.eventNow = event;
|
||||
|
||||
// check if current is also public
|
||||
@@ -230,81 +179,63 @@ export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = ru
|
||||
runtimeState.publicEventNow = null;
|
||||
|
||||
// if there is nothing before, return
|
||||
if (!eventIndex) {
|
||||
if (!runtimeState.runtime.selectedEventIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iterate backwards to find it
|
||||
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;
|
||||
for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) {
|
||||
if (playableEvents[i].isPublic) {
|
||||
runtimeState.publicEventNow = playableEvents[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export function loadNext(playableEvents: OntimeEvent[]) {
|
||||
// assume there are no next events
|
||||
runtimeState.eventNext = null;
|
||||
runtimeState.publicEventNext = null;
|
||||
|
||||
if (runtimeState.runtime.selectedEventIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const numEvents = playableEvents.length;
|
||||
|
||||
// the private event is the one immediately after the current event
|
||||
if (runtimeState.eventNext === null) {
|
||||
runtimeState.eventNext = event;
|
||||
}
|
||||
if (runtimeState.runtime.selectedEventIndex < numEvents - 1) {
|
||||
let nextPublic = false;
|
||||
let nextProduction = false;
|
||||
|
||||
// if event is public
|
||||
if (event.isPublic) {
|
||||
runtimeState.publicEventNext = 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;
|
||||
}
|
||||
|
||||
// Stop if both are set
|
||||
if (runtimeState.eventNext !== null && runtimeState.publicEventNext !== null) {
|
||||
return;
|
||||
// if event is public
|
||||
if (playableEvents[i].isPublic) {
|
||||
runtimeState.publicEventNext = playableEvents[i];
|
||||
nextPublic = true;
|
||||
}
|
||||
|
||||
// Stop if both are set
|
||||
if (nextPublic && nextProduction) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume from restore point
|
||||
*/
|
||||
export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) {
|
||||
export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) {
|
||||
load(event, rundown, restorePoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* We only pass an event if we are hot reloading
|
||||
* @param {PlayableEvent} event only passed if we are changing the data if a playing timer
|
||||
* @param {OntimeEvent} event only passed if we are changing the data if a playing timer
|
||||
*/
|
||||
export function updateLoaded(event?: PlayableEvent): string | undefined {
|
||||
// if there is no event loaded, nothing to do
|
||||
if (runtimeState.eventNow === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
export function reload(event?: OntimeEvent) {
|
||||
// we only pass an event for hot reloading, ie: the event has changed
|
||||
if (event) {
|
||||
runtimeState.eventNow = event;
|
||||
@@ -313,51 +244,33 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
|
||||
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) {
|
||||
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
||||
// if waiting to roll, we update the targets and potentially start the timer
|
||||
if (runtimeState._timer.secondaryTarget !== null) {
|
||||
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
|
||||
// if the event is now, we queue a start
|
||||
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
} else {
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.addedTime = 0;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState.timer.addedTime = 0;
|
||||
|
||||
// this could be looked after by the timer
|
||||
runtimeState.timer.elapsed = null;
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
|
||||
return runtimeState.eventNow.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in situations when we want to hot-reload all events without interrupting timer
|
||||
* Used in situations when we want to reload all events
|
||||
* without interrupting timer
|
||||
* @param eventNow
|
||||
* @param playableEvents
|
||||
*/
|
||||
export function updateAll(rundown: OntimeRundown) {
|
||||
const timedEvents = filterTimedEvents(rundown);
|
||||
loadNow(timedEvents);
|
||||
loadNext(timedEvents);
|
||||
updateLoaded(runtimeState.eventNow ?? undefined);
|
||||
loadBlock(rundown);
|
||||
export function reloadAll(eventNow: OntimeEvent, playableEvents: OntimeEvent[]) {
|
||||
loadNow(eventNow, playableEvents);
|
||||
loadNext(playableEvents);
|
||||
reload(eventNow);
|
||||
}
|
||||
|
||||
export function start(state: RuntimeState = runtimeState): boolean {
|
||||
@@ -369,6 +282,7 @@ 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) {
|
||||
@@ -381,11 +295,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
state.timer.startedAt = state.clock;
|
||||
}
|
||||
|
||||
// update block start time
|
||||
if (state.currentBlock.startedAt === null) {
|
||||
state.currentBlock.startedAt = state.clock;
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Play;
|
||||
state.timer.expectedFinish = getExpectedFinish(state);
|
||||
state.timer.elapsed = 0;
|
||||
@@ -427,22 +336,11 @@ 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;
|
||||
@@ -472,225 +370,114 @@ export function addTime(amount: number) {
|
||||
|
||||
export type UpdateResult = {
|
||||
hasTimerFinished: boolean;
|
||||
hasSecondaryTimerFinished: boolean;
|
||||
shouldCallRoll: boolean;
|
||||
};
|
||||
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
||||
let hasTimerFinished = false;
|
||||
let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished
|
||||
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState)) {
|
||||
return updateIfIdle();
|
||||
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;
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
return updateIfWaitingToRoll();
|
||||
}
|
||||
|
||||
// 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);
|
||||
// update timer phase
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
// update runtime, needs up-to-date timer state
|
||||
// update offset
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
const finishedNow =
|
||||
Boolean(runtimeState._timer.forceFinish) ||
|
||||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
|
||||
return {
|
||||
hasTimerFinished,
|
||||
shouldCallRoll,
|
||||
};
|
||||
|
||||
if (finishedNow) {
|
||||
// reset state
|
||||
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
|
||||
} else {
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
}
|
||||
|
||||
return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
|
||||
|
||||
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');
|
||||
}
|
||||
function onRollUpdate() {
|
||||
const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit);
|
||||
if (hasSkippedOutOfEvent) {
|
||||
return { doRoll: true };
|
||||
}
|
||||
//account for offset
|
||||
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (runtimeState.timer.playback === Playback.Play && finishedNow) {
|
||||
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
|
||||
isFinished = true;
|
||||
} else {
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
}
|
||||
|
||||
if (shouldForceFinish) {
|
||||
runtimeState._timer.forceFinish = null;
|
||||
}
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
return { isFinished };
|
||||
}
|
||||
}
|
||||
|
||||
export function roll(rundown: OntimeRundown, offset = 0): { 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 };
|
||||
}
|
||||
export function roll(rundown: OntimeEvent[]) {
|
||||
const selectedEventIndex = runtimeState.runtime.selectedEventIndex;
|
||||
clear();
|
||||
runtimeState.runtime.numEvents = rundown.length;
|
||||
|
||||
// 2. if there is an event armed, we use it
|
||||
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (runtimeState.eventNow === null) {
|
||||
throw new Error('runtimeState.roll: invalid state received');
|
||||
}
|
||||
}
|
||||
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock, selectedEventIndex);
|
||||
|
||||
runtimeState.runtime.offset = offset;
|
||||
runtimeState.timer.playback = Playback.Roll;
|
||||
if (currentEvent) {
|
||||
// there is something running, load
|
||||
runtimeState.timer.secondaryTimer = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
|
||||
// account for event that finishes the day after
|
||||
const normalisedEndTime =
|
||||
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
|
||||
? runtimeState.eventNow.timeEnd + dayInMs
|
||||
: runtimeState.eventNow.timeEnd;
|
||||
runtimeState.timer.expectedFinish = normalisedEndTime;
|
||||
const endTime =
|
||||
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
|
||||
|
||||
//account for offset
|
||||
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
||||
|
||||
// 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 pending
|
||||
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
|
||||
if (isNow) {
|
||||
runtimeState.timer.startedAt = runtimeState.clock;
|
||||
|
||||
// update runtime
|
||||
if (runtimeState.currentBlock.startedAt === null) {
|
||||
runtimeState.currentBlock.startedAt = runtimeState.clock;
|
||||
}
|
||||
if (!runtimeState.runtime.actualStart) {
|
||||
runtimeState.runtime.actualStart = runtimeState.clock;
|
||||
}
|
||||
runtimeState.timer.secondaryTimer = null;
|
||||
} else {
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
// 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;
|
||||
}
|
||||
|
||||
return { eventId: runtimeState.eventNow.id, didStart: isNow };
|
||||
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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// we need to persist the current block state across loads
|
||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
||||
clear();
|
||||
runtimeState.currentBlock = prevCurrentBlock;
|
||||
|
||||
//account for offset but we only keep it if passed to us
|
||||
runtimeState.runtime.offset = offset;
|
||||
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
||||
|
||||
const { index, isPending } = loadRoll(timedEvents, offsetClock);
|
||||
|
||||
// load events in memory along with their data
|
||||
loadNow(timedEvents, index);
|
||||
loadNext(timedEvents, index);
|
||||
loadBlock(rundown);
|
||||
|
||||
// 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, offsetClock);
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
function loadBlock(rundown: OntimeRundown) {
|
||||
if (runtimeState.eventNow === null) {
|
||||
// we need a loaded event to have a block
|
||||
runtimeState.currentBlock.block = null;
|
||||
runtimeState.currentBlock.startedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id);
|
||||
|
||||
// test all block change posibiletys
|
||||
const formNoBlockToBlock = runtimeState.currentBlock.block === null && newCurrentBlock !== null;
|
||||
const formBlockToNoBlock = runtimeState.currentBlock.block !== null && newCurrentBlock === null;
|
||||
const formBlockToNewBlock = runtimeState.currentBlock.block?.id !== newCurrentBlock?.id;
|
||||
|
||||
// update time only if the block has changed
|
||||
if (formNoBlockToBlock || formBlockToNoBlock || formBlockToNewBlock) {
|
||||
runtimeState.currentBlock.startedAt = null;
|
||||
}
|
||||
|
||||
// update the block anyway
|
||||
runtimeState.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user