mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
16 Commits
v3.4.1
...
v3.5.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b5075e8d18 | |||
| 2105a2af2a | |||
| 1ddcec993a | |||
| 91d6adf8e0 | |||
| a7fe5eceef | |||
| e6e00c0d4a | |||
| edc7f20d7a | |||
| cb773ded9f | |||
| 2f65711078 | |||
| 3056b75960 | |||
| dd9423abf4 | |||
| dee6a67018 | |||
| a79fb4ecbb | |||
| a53a1f87be | |||
| ef9afcbf3e | |||
| 33b04e01c9 |
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -22,7 +22,6 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY ./demo-db/ ./preloaded-db/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
|
||||
# Export default ports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -20,8 +20,8 @@
|
||||
"server"
|
||||
],
|
||||
"devDependencies": {
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.3"
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.92.0",
|
||||
"@sentry/react": "^8.19.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
@@ -42,7 +42,6 @@
|
||||
"build:docker": "vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
|
||||
@@ -70,13 +69,13 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
@@ -84,9 +83,9 @@
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"prettier": "catalog:",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
@@ -28,41 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
integrations: [
|
||||
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||
useEffect: React.useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 0.3,
|
||||
release: ONTIME_VERSION,
|
||||
enabled: import.meta.env.PROD,
|
||||
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
});
|
||||
|
||||
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
|
||||
export default function AppRouter() {
|
||||
// handle client path changes
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
<Route path='/timeline' element={<STimeline />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/op' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
@@ -99,7 +126,7 @@ export default function AppRouter() {
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</SentryRoutes>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ function Overlay() {
|
||||
};
|
||||
}, [showOverlay, id, setIdentify, handleClose]);
|
||||
|
||||
console.log('here2');
|
||||
return (
|
||||
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||
<div className={style.name}>{name}</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ interface EditFormDrawerProps {
|
||||
viewOptions: ViewOption[];
|
||||
}
|
||||
|
||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
@@ -183,3 +183,12 @@ export const useRuntimePlaybackOverview = () => {
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimelineStatus = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
},
|
||||
frozen: false,
|
||||
};
|
||||
|
||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||
|
||||
@@ -34,3 +34,16 @@ export const enDash = '–';
|
||||
|
||||
export const timerPlaceholder = '––:––:––';
|
||||
export const timerPlaceholderMin = '––:––';
|
||||
|
||||
/**
|
||||
* Adds opacity to a given colour if possible
|
||||
*/
|
||||
export function alpha(colour: string, amount: number): string {
|
||||
try {
|
||||
const withAlpha = Color(colour).alpha(amount).hexa();
|
||||
return withAlpha;
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
|
||||
import { formatFromMillis } from 'ontime-utils';
|
||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
@@ -9,17 +9,17 @@ import { ontimeQueryClient } from '../queryClient';
|
||||
* Returns current time in milliseconds
|
||||
* @returns {number}
|
||||
*/
|
||||
export const nowInMillis = () => {
|
||||
export function nowInMillis(): number {
|
||||
const now = new Date();
|
||||
|
||||
// extract milliseconds since midnight
|
||||
let elapsed = now.getHours() * 3600000;
|
||||
elapsed += now.getMinutes() * 60000;
|
||||
elapsed += now.getSeconds() * 1000;
|
||||
let elapsed = now.getHours() * MILLIS_PER_HOUR;
|
||||
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
|
||||
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
|
||||
elapsed += now.getMilliseconds();
|
||||
|
||||
return elapsed;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Resolves format from url and store
|
||||
@@ -95,3 +95,26 @@ export const formatTime = (
|
||||
const isNegative = milliseconds < 0;
|
||||
return `${isNegative ? '-' : ''}${display}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles case for formatting a duration time
|
||||
* @param duration
|
||||
* @returns
|
||||
*/
|
||||
export function formatDuration(duration: number): string {
|
||||
// durations should never be negative, we handle it here to flag if there is an issue in future
|
||||
if (duration <= 0) {
|
||||
return '0h 0m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
||||
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
||||
let result = '';
|
||||
if (hours > 0) {
|
||||
result += `${hours}h `;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
result += `${minutes}m`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $red-700);
|
||||
background-color: var(--operator-running-bg-override, $active-red);
|
||||
}
|
||||
|
||||
&.past {
|
||||
@@ -99,7 +99,6 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding-inline: 0.25rem;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$timeline-entry-height: 20px;
|
||||
$lane-height: 120px;
|
||||
|
||||
.timeline {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.timelineEvents {
|
||||
position: relative;
|
||||
top: 0.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
border-inline: 1px solid $ui-black;
|
||||
// avoiding content being larger than the view
|
||||
height: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
.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, $ui-white);
|
||||
}
|
||||
|
||||
.delay {
|
||||
margin-top: -2rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
|
||||
.timeOverview {
|
||||
padding-top: 0.25rem;
|
||||
padding-inline-start: 0.25em;
|
||||
text-transform: capitalize;
|
||||
white-space: normal;
|
||||
height: 6rem;
|
||||
|
||||
&[data-status='done'] {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
|
||||
&[data-status='live'] {
|
||||
.status {
|
||||
color: $active-red;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-status='future'] {
|
||||
.status {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cross {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, MaybeNumber } from 'ontime-types';
|
||||
import { dayInMs, getFirstEventNormal, getLastEventNormal, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
|
||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||
import ProgressBar from './timeline-progress-bar/TimelineProgressBar';
|
||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
function useTimeline() {
|
||||
const { data } = useRundown();
|
||||
if (data.revision === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
|
||||
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
|
||||
const firstStart = firstEvent?.timeStart ?? 0;
|
||||
const lastEnd = lastEvent?.timeEnd ?? 0;
|
||||
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
|
||||
|
||||
// timeline is padded to nearest hours (floor and ceil)
|
||||
const startHour = getStartHour(firstStart) * MILLIS_PER_HOUR;
|
||||
const endHour = getEndHour(normalisedLastEnd) * MILLIS_PER_HOUR;
|
||||
const accumulatedDelay = lastEvent?.delay ?? 0;
|
||||
|
||||
return {
|
||||
rundown: data.rundown,
|
||||
order: data.order,
|
||||
startHour,
|
||||
endHour,
|
||||
accumulatedDelay,
|
||||
};
|
||||
}
|
||||
|
||||
interface TimelineProps {
|
||||
selectedEventId: string | null;
|
||||
}
|
||||
|
||||
export default memo(Timeline);
|
||||
|
||||
function Timeline(props: TimelineProps) {
|
||||
const { selectedEventId } = props;
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
const timelineData = useTimeline();
|
||||
|
||||
if (timelineData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { rundown, order, startHour, endHour, accumulatedDelay } = timelineData;
|
||||
|
||||
let hasTimelinePassedMidnight = false;
|
||||
let previousEventStartTime: MaybeNumber = null;
|
||||
let eventStatus: ProgressStatus = 'done';
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<TimelineMarkers />
|
||||
<ProgressBar startHour={startHour} endHour={endHour + accumulatedDelay} />
|
||||
<div className={style.timelineEvents}>
|
||||
{order.map((eventId) => {
|
||||
// for now we dont render delays and blocks
|
||||
const event = rundown[eventId];
|
||||
if (!isOntimeEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// keep track of progress of rundown
|
||||
if (eventStatus === 'live') {
|
||||
eventStatus = 'future';
|
||||
}
|
||||
if (eventId === selectedEventId) {
|
||||
eventStatus = 'live';
|
||||
}
|
||||
|
||||
// we need to offset the start to account for midnight
|
||||
if (!hasTimelinePassedMidnight) {
|
||||
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
|
||||
}
|
||||
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
|
||||
previousEventStartTime = normalisedStart;
|
||||
|
||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||
startHour,
|
||||
endHour + accumulatedDelay,
|
||||
normalisedStart + (event.delay ?? 0),
|
||||
event.duration,
|
||||
screenWidth,
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineEntry
|
||||
key={eventId}
|
||||
colour={event.colour}
|
||||
delay={event.delay ?? 0}
|
||||
duration={event.duration}
|
||||
left={elementLeftPosition}
|
||||
status={eventStatus}
|
||||
start={event.timeStart}
|
||||
title={event.title}
|
||||
width={elementWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useTimelineStatus } from '../../../common/hooks/useSocket';
|
||||
import { alpha } 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);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.column}
|
||||
style={{
|
||||
'--color': colour,
|
||||
'--lighter': lighterColour ?? '',
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={style.content}
|
||||
data-status={status}
|
||||
style={{
|
||||
'--color': colour,
|
||||
}}
|
||||
>
|
||||
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
|
||||
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
|
||||
<div>{title}</div>
|
||||
</div>
|
||||
<div className={style.timeOverview} data-status={status}>
|
||||
<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();
|
||||
|
||||
let statusText = getStatusLabel(start - clock + offset, status);
|
||||
if (statusText === 'live') {
|
||||
statusText = getLocalizedString('timeline.live');
|
||||
} else if (statusText === 'pending') {
|
||||
statusText = getLocalizedString('timeline.due');
|
||||
} else if (statusText === 'done') {
|
||||
statusText = getLocalizedString('timeline.done');
|
||||
}
|
||||
|
||||
return <div className={style.status}>{statusText}</div>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.timeline {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-inline: 2rem;
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.sections {
|
||||
padding-inline: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
row-gap: 1rem;
|
||||
column-gap: 3rem;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import Section from './timeline-section/TimelineSection';
|
||||
import Timeline from './Timeline';
|
||||
import { getTimelineOptions } from './timeline.options';
|
||||
import { getFormattedTimeToStart, getUpcomingEvents } from './timeline.utils';
|
||||
|
||||
import style from './TimelinePage.module.scss';
|
||||
|
||||
interface TimelinePageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
selectedId: MaybeString;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
* since we inherit from viewPage
|
||||
* which refreshes at least once a second
|
||||
* There is little point splitting or memoising top level elements
|
||||
*/
|
||||
export default function TimelinePage(props: TimelinePageProps) {
|
||||
const { backstageEvents, general, selectedId, settings, time } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = formatTime(time.clock);
|
||||
|
||||
const { now, next, followedBy } = useMemo(() => {
|
||||
return getUpcomingEvents(backstageEvents, selectedId);
|
||||
}, [backstageEvents, selectedId]);
|
||||
|
||||
// populate options
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const progressOptions = getTimelineOptions(defaultFormat);
|
||||
|
||||
const titleNow = now?.title ?? '-';
|
||||
const dueText = getLocalizedString('timeline.due');
|
||||
const nextText = next !== null ? `${next.title} · ${getFormattedTimeToStart(next, time.clock, dueText)}` : '-';
|
||||
const followedByText =
|
||||
followedBy !== null ? `${followedBy.title} · ${getFormattedTimeToStart(followedBy, time.clock, dueText)}` : '-';
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<ViewParamsEditor viewOptions={progressOptions} />
|
||||
<div className={style.title}>{general.title}</div>
|
||||
<div className={style.sections}>
|
||||
<Section title={getLocalizedString('common.time_now')} content={clock} category='now' />
|
||||
<Section title={getLocalizedString('common.next')} content={nextText} category='next' />
|
||||
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
|
||||
<Section title={getLocalizedString('timeline.followedby')} content={followedByText} category='next' />
|
||||
</div>
|
||||
<Timeline selectedEventId={selectedId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
|
||||
|
||||
describe('getCSSPosition()', () => {
|
||||
it('accounts for rundown with one event', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = dayInMs;
|
||||
const eventStart = 0;
|
||||
const eventDuration = dayInMs;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(0);
|
||||
expect(result.width).toBe(containerWidth);
|
||||
});
|
||||
|
||||
it('accounts for an event that starts halfway and ends at end', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = 100;
|
||||
const eventStart = 50;
|
||||
const eventDuration = 50;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(50);
|
||||
expect(result.width).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for an event that starts first and ends halfway', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = 100;
|
||||
const eventStart = 0;
|
||||
const eventDuration = 50;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(0);
|
||||
expect(result.width).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for an event that is in the middle of the rundown', () => {
|
||||
const scheduleStart = 7;
|
||||
const scheduleEnd = 23;
|
||||
const eventStart = 10;
|
||||
const eventDuration = 1;
|
||||
const containerWidth = 1000;
|
||||
|
||||
// 16 hour event, this gives 62.5px per hour
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(187.5); // 3 * 62.5
|
||||
expect(result.width).toBe(62.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTmelineSections', () => {
|
||||
it('creates an array between the hours given, end excluded', () => {
|
||||
const result = makeTimelineSections(11, 17);
|
||||
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
|
||||
});
|
||||
|
||||
it('wraps around midnight', () => {
|
||||
const result = makeTimelineSections(22, 26);
|
||||
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
.markers {
|
||||
width: 100%;
|
||||
color: $ui-white;
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
justify-content: space-evenly;
|
||||
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid $white-7;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||
import { getTimelineSections } from '../timeline.utils';
|
||||
|
||||
import style from './TimelineMarkers.module.scss';
|
||||
|
||||
export default function TimelineMarkers() {
|
||||
const { data } = useRundown();
|
||||
|
||||
if (!data || data.revision === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = getTimelineSections(data.rundown, data.order);
|
||||
|
||||
return (
|
||||
<div className={style.markers}>
|
||||
{elements.map((tag) => {
|
||||
return <span key={tag}>{tag}</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
|
||||
transition-duration: 0.3s;
|
||||
transition-property: left;
|
||||
background-color: $gray-1000;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background-color: $active-red;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useClock } from '../../../../common/hooks/useSocket';
|
||||
import { getRelativePositionX } from '../timeline.utils';
|
||||
|
||||
import style from './TimelineProgressBar.module.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { startHour, endHour } = props;
|
||||
const { clock } = useClock();
|
||||
|
||||
const width = getRelativePositionX(startHour, endHour, clock);
|
||||
|
||||
return (
|
||||
<div className={style.progressBar}>
|
||||
<div className={style.progress} style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
.sectionTitle {
|
||||
line-height: 1.2em;
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
min-height: 2em;
|
||||
line-height: 1em;
|
||||
font-size: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
|
||||
&.now {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&.next {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
&.subdue {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TimelineSection.module.scss';
|
||||
|
||||
interface SectionProps {
|
||||
category: 'now' | 'next';
|
||||
content: MaybeString;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function Section(props: SectionProps) {
|
||||
const { category, content, title } = props;
|
||||
|
||||
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
|
||||
return (
|
||||
<div>
|
||||
<div className={style.sectionTitle}>{title}</div>
|
||||
<div className={contentClasses}>{content ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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)];
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getEventWithId,
|
||||
getFirstEvent,
|
||||
getFirstEventNormal,
|
||||
getLastEventNormal,
|
||||
getNextEvent,
|
||||
MILLIS_PER_HOUR,
|
||||
millisToString,
|
||||
removeSeconds,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { clamp } from '../../../common/utils/math';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the timeline sections from a rundown
|
||||
*/
|
||||
export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] {
|
||||
if (order.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { firstEvent } = getFirstEventNormal(rundown, order);
|
||||
const { lastEvent } = getLastEventNormal(rundown, order);
|
||||
const firstStart = firstEvent?.timeStart ?? 0;
|
||||
const lastEnd = lastEvent?.timeEnd ?? 0;
|
||||
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
|
||||
|
||||
const startHour = getStartHour(firstStart);
|
||||
const endHour = getEndHour(normalisedLastEnd);
|
||||
|
||||
const elements = makeTimelineSections(startHour, endHour);
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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,51 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import App from './App';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container as Element);
|
||||
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
|
||||
const sentryRecommendedIgnore = [
|
||||
// Random plugins/extensions
|
||||
'top.GLOBALS',
|
||||
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
|
||||
'originalCreateNotification',
|
||||
'canvas.contentDocument',
|
||||
'MyApp_RemoveAllHighlights',
|
||||
'http://tt.epicplay.com',
|
||||
"Can't find variable: ZiteReader",
|
||||
'jigsaw is not defined',
|
||||
'ComboSearch is not defined',
|
||||
'http://loading.retry.widdit.com/',
|
||||
'atomicFindClose',
|
||||
// Facebook borked
|
||||
'fb_xd_fragment',
|
||||
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
|
||||
// reduce this. (thanks @acdha)
|
||||
// See http://stackoverflow.com/questions/4113268
|
||||
'bmi_SafeAddOnload',
|
||||
'EBCallBackMessageReceived',
|
||||
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
|
||||
'conduitPage',
|
||||
];
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
|
||||
integrations: [new Sentry.BrowserTracing()],
|
||||
tracesSampleRate: 0.3,
|
||||
release: ONTIME_VERSION,
|
||||
enabled: import.meta.env.PROD,
|
||||
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
});
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
|
||||
export const sentryRecommendedIgnore = [
|
||||
// Random plugins/extensions
|
||||
'top.GLOBALS',
|
||||
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
|
||||
'originalCreateNotification',
|
||||
'canvas.contentDocument',
|
||||
'MyApp_RemoveAllHighlights',
|
||||
'http://tt.epicplay.com',
|
||||
"Can't find variable: ZiteReader",
|
||||
'jigsaw is not defined',
|
||||
'ComboSearch is not defined',
|
||||
'http://loading.retry.widdit.com/',
|
||||
'atomicFindClose',
|
||||
// Facebook borked
|
||||
'fb_xd_fragment',
|
||||
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
|
||||
// reduce this. (thanks @acdha)
|
||||
// See http://stackoverflow.com/questions/4113268
|
||||
'bmi_SafeAddOnload',
|
||||
'EBCallBackMessageReceived',
|
||||
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
|
||||
'conduitPage',
|
||||
];
|
||||
export const sentryDsn = 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360';
|
||||
@@ -15,6 +15,7 @@ $ontime-color: #ff7597;
|
||||
$error-red: $red-500;
|
||||
$warning-orange: $orange-500;
|
||||
$opacity-disabled: 0.4;
|
||||
$active-red: $red-700;
|
||||
|
||||
// playback colours
|
||||
$playback-start: $green-600;
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langDe: TranslationObject = {
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
'countdown.overtime': 'überfällig',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Beendet',
|
||||
'timeline.due': 'fällig',
|
||||
'timeline.followedby': 'Gefolgt von',
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@ export const langEn = {
|
||||
'countdown.to_start': 'Time to start',
|
||||
'countdown.waiting': 'Waiting for event start',
|
||||
'countdown.overtime': 'in overtime',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'done',
|
||||
'timeline.due': 'due',
|
||||
'timeline.followedby': 'Followed by',
|
||||
};
|
||||
|
||||
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langEs: TranslationObject = {
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
'countdown.overtime': 'en tiempo extra',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminado',
|
||||
'timeline.due': 'pendiente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langFr: TranslationObject = {
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
'countdown.waiting': 'En attente du début de l’évènement',
|
||||
'countdown.overtime': 'en dépassement',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminé',
|
||||
'timeline.due': 'dû',
|
||||
'timeline.followedby': 'Suivi de',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langIt: TranslationObject = {
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||
'countdown.overtime': 'in ritardo',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminato',
|
||||
'timeline.due': 'previsto',
|
||||
'timeline.followedby': 'Seguito da',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langNo: TranslationObject = {
|
||||
'countdown.to_start': 'Tid til start',
|
||||
'countdown.waiting': 'Venter på start',
|
||||
'countdown.overtime': 'i overtiden',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Ferdig',
|
||||
'timeline.due': 'Venter',
|
||||
'timeline.followedby': 'Etterfulgt av',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langPl: TranslationObject = {
|
||||
'countdown.to_start': 'Do rozpoczęcia',
|
||||
'countdown.waiting': 'Oczekiwanie na start',
|
||||
'countdown.overtime': 'ponad czasem',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Zakończony',
|
||||
'timeline.due': 'termin',
|
||||
'timeline.followedby': 'Następnie',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langPt: TranslationObject = {
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
'countdown.overtime': 'em tempo extra',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Concluído',
|
||||
'timeline.due': 'Pendente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langSv: TranslationObject = {
|
||||
'countdown.to_start': 'Tid till start',
|
||||
'countdown.waiting': 'Väntar på att evenemanget ska starta',
|
||||
'countdown.overtime': 'i övertid',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Avslutad',
|
||||
'timeline.due': 'Väntande',
|
||||
'timeline.followedby': 'Följt av',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export const navigatorConstants = [
|
||||
{ url: '/clock', label: 'Clock' },
|
||||
{ url: '/minimal', label: 'Minimal Timer' },
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/timeline', label: 'Timeline' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -14,15 +14,14 @@
|
||||
"devDependencies": {
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.0.3",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
|
||||
+15
-19
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -27,41 +27,37 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"esbuild": "^0.19.10",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"prettier": "catalog:",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
|
||||
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json",
|
||||
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject",
|
||||
"postinstall": "pnpm addversion",
|
||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||
"prebuild": "pnpm set:demoproject",
|
||||
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:local": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
|
||||
"build:localdocker": "cross-env NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
|
||||
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
|
||||
"build:electron": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
|
||||
"build:local": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
|
||||
"build:docker": "esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
|
||||
"build:localdocker": "cross-env NODE_ENV=local esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
|
||||
"build:debug": "esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "cross-env IS_TEST=true vitest",
|
||||
"test:pipeline": "cross-env IS_TEST=true vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
deleteEvent,
|
||||
editEvent,
|
||||
reorderEvent,
|
||||
setFrozenState,
|
||||
swapEvents,
|
||||
} from '../../services/rundown-service/RundownService.js';
|
||||
import {
|
||||
@@ -114,6 +115,17 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
|
||||
}
|
||||
}
|
||||
|
||||
export async function rundownFrozenPost(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const { frozen } = req.body;
|
||||
setFrozenState(frozen);
|
||||
res.status(200).send({ message: 'Rundown frozen state updated.' });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
export const preventIfFrozen = function (req, res, next) {
|
||||
if (eventStore.get('frozen')) {
|
||||
res.status(403).send({ message: 'Rundown is frozen' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
rundownApplyDelay,
|
||||
rundownBatchPut,
|
||||
rundownDelete,
|
||||
rundownFrozenPost,
|
||||
rundownGetById,
|
||||
rundownGetNormalised,
|
||||
rundownGetPaginated,
|
||||
@@ -17,12 +18,14 @@ 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();
|
||||
|
||||
@@ -31,13 +34,14 @@ router.get('/normalised', rundownGetNormalised);
|
||||
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
|
||||
|
||||
router.post('/', rundownPostValidator, rundownPost);
|
||||
router.post('/frozen', rundownFrozenPostValidator, rundownFrozenPost);
|
||||
|
||||
router.put('/', rundownPutValidator, rundownPut);
|
||||
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
|
||||
|
||||
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||
router.patch('/swap', rundownSwapValidator, rundownSwap);
|
||||
router.patch('/reorder/', rundownReorderValidator, preventIfFrozen, rundownReorder);
|
||||
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
|
||||
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
||||
|
||||
router.delete('/', rundownArrayOfIds, deletesEventById);
|
||||
router.delete('/all', rundownDelete);
|
||||
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
|
||||
router.delete('/all', preventIfFrozen, rundownDelete);
|
||||
|
||||
@@ -21,6 +21,15 @@ export const rundownPutValidator = [
|
||||
},
|
||||
];
|
||||
|
||||
export const rundownFrozenPostValidator = [
|
||||
body('frozen').isBoolean().exists(),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
]
|
||||
|
||||
export const rundownBatchPutValidator = [
|
||||
body('data').isObject().exists(),
|
||||
body('ids').isArray().exists(),
|
||||
|
||||
@@ -188,6 +188,7 @@ export const startServer = async (
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
},
|
||||
frozen: false,
|
||||
});
|
||||
|
||||
// initialise logging service, escalateErrorFn is only exists in electron
|
||||
@@ -263,6 +264,7 @@ export const shutdown = async (exitCode = 0) => {
|
||||
// clear the restore file if it was a normal exit
|
||||
// 0 means it was a SIGNAL
|
||||
// 1 means crash -> keep the file
|
||||
// 2 means dev crash -> do nothing
|
||||
// 99 means there was a shutdown request from the UI
|
||||
if (exitCode === 0 || exitCode === 99) {
|
||||
await restoreService.clear();
|
||||
|
||||
@@ -13,24 +13,19 @@ import {
|
||||
import type { Low } from 'lowdb';
|
||||
import { JSONFilePreset } from 'lowdb/node';
|
||||
|
||||
import { isProduction, isTest } from '../../setup/index.js';
|
||||
import { isTest } from '../../setup/index.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
import { shouldCrashDev } from '../../utils/development.js';
|
||||
|
||||
type ReadonlyPromise<T> = Promise<Readonly<T>>;
|
||||
|
||||
let db = {} as Low<DatabaseModel>;
|
||||
|
||||
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
|
||||
if (!isProduction) {
|
||||
if (!isPath(filePath)) {
|
||||
consoleError(filePath);
|
||||
consoleError(new Error('initPersistence should be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
|
||||
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
|
||||
|
||||
// Read the database to initialize it
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This directory holds the demo file shipped with Ontime
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
*/
|
||||
@@ -13,9 +15,9 @@ export class TimerService {
|
||||
static _refreshInterval: number;
|
||||
|
||||
/** when timer will be finished */
|
||||
private endCallback: NodeJS.Timeout;
|
||||
private endCallback: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
private onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
private onUpdateCallback: UpdateCallbackFn | undefined = undefined;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
@@ -23,19 +25,21 @@ export class TimerService {
|
||||
* @param {number} [timerConfig.updateInterval] how often we update the socket
|
||||
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket
|
||||
*/
|
||||
constructor(timerConfig: {
|
||||
refresh: number;
|
||||
updateInterval: number;
|
||||
onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
}) {
|
||||
constructor(timerConfig: { refresh: number; updateInterval: number }) {
|
||||
TimerService._refreshInterval = timerConfig.refresh;
|
||||
|
||||
this.onUpdateCallback = timerConfig.onUpdateCallback;
|
||||
this._interval = setInterval(() => {
|
||||
this.update();
|
||||
}, TimerService._refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting a callback for when the timer updates
|
||||
* @param callback
|
||||
*/
|
||||
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
|
||||
this.onUpdateCallback = callback;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!runtimeState.start()) {
|
||||
return false;
|
||||
@@ -79,6 +83,12 @@ export class TimerService {
|
||||
// renew end callback
|
||||
clearTimeout(this.endCallback);
|
||||
const state = runtimeState.getState();
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (state.timer.expectedFinish === null) {
|
||||
throw new Error('TimerService.addTime: expectedFinish is negative');
|
||||
}
|
||||
}
|
||||
this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
|
||||
return true;
|
||||
}
|
||||
@@ -89,7 +99,7 @@ export class TimerService {
|
||||
update() {
|
||||
const updateResult = runtimeState.update();
|
||||
// pass the result to the parent
|
||||
this.onUpdateCallback(updateResult);
|
||||
this.onUpdateCallback?.(updateResult);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getTotalDuration,
|
||||
normaliseEndTime,
|
||||
skippedOutOfEvent,
|
||||
updateRoll,
|
||||
} from '../timerUtils.js';
|
||||
import { RuntimeState } from '../../stores/runtimeState.js';
|
||||
|
||||
@@ -1237,196 +1236,6 @@ test('normaliseEndTime()', () => {
|
||||
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
|
||||
});
|
||||
|
||||
describe('updateRoll()', () => {
|
||||
it('it updates running events correctly', () => {
|
||||
const timers = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
},
|
||||
clock: 11,
|
||||
timer: {
|
||||
current: 10,
|
||||
expectedFinish: 100,
|
||||
secondaryTimer: null,
|
||||
startedAt: 1,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: 100 - 11,
|
||||
updatedSecondaryTimer: null, // usually clock - expectedFinish
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
|
||||
// test that it can jump time
|
||||
timers.timer.expectedFinish = 1000;
|
||||
timers.clock = 600;
|
||||
expected.updatedTimer = 1000 - 600;
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('it updates secondary timer', () => {
|
||||
const timers = {
|
||||
eventNow: null,
|
||||
clock: 11,
|
||||
timer: {
|
||||
current: null,
|
||||
expectedFinish: null,
|
||||
secondaryTimer: 1,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: 15,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: 15 - 11, // countdown to secondary
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('flags an event end', () => {
|
||||
const timers = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
},
|
||||
clock: 12,
|
||||
timer: {
|
||||
startedAt: 0,
|
||||
current: 10,
|
||||
expectedFinish: 11,
|
||||
secondaryTimer: null,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: -1,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: true,
|
||||
isFinished: true,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('secondary events do not trigger event ends', () => {
|
||||
const timers = {
|
||||
eventNow: null,
|
||||
clock: 16,
|
||||
timer: {
|
||||
startedAt: null,
|
||||
current: null,
|
||||
expectedFinish: null,
|
||||
secondaryTimer: 1,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: 15,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: -1,
|
||||
doRollLoad: true,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('when a secondary timer is finished, it prompts for new event load', () => {
|
||||
const timers = {
|
||||
eventNow: null,
|
||||
clock: 15,
|
||||
timer: {
|
||||
current: null,
|
||||
expectedFinish: null,
|
||||
secondaryTimer: 0,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: 15,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: 0,
|
||||
doRollLoad: true,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('counts over midnight', () => {
|
||||
const timers = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
},
|
||||
clock: dayInMs - 10,
|
||||
timer: {
|
||||
current: 25,
|
||||
expectedFinish: 10,
|
||||
startedAt: 1000,
|
||||
secondaryTimer: null,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: 20,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('rolls over midnight', () => {
|
||||
const timers = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
},
|
||||
clock: 10,
|
||||
timer: {
|
||||
current: dayInMs,
|
||||
expectedFinish: 10,
|
||||
startedAt: 1000,
|
||||
secondaryTimer: null,
|
||||
},
|
||||
_timer: {
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: dayInMs,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimeOffset()', () => {
|
||||
it('is the difference between scheduled and when we actually started', () => {
|
||||
const state = {
|
||||
@@ -1554,7 +1363,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -1595,7 +1404,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -1630,7 +1439,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
offset: 0,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
@@ -1647,7 +1456,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -1682,7 +1491,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
offset: 0,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
@@ -1699,7 +1508,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
@@ -1722,7 +1531,7 @@ describe('getRuntimeOffset()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
offset: 0,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 82000000, // 22:46:40 <--- started now
|
||||
@@ -1739,7 +1548,7 @@ describe('getRuntimeOffset()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: 82000000, // <--- started now
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const updateCurrent = getCurrent(state);
|
||||
@@ -1887,7 +1696,7 @@ describe('getTimerPhase()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
offset: 0,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
@@ -1909,7 +1718,6 @@ describe('getTimerPhase()', () => {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
@@ -1927,7 +1735,7 @@ describe('getTimerPhase()', () => {
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
offset: 0,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
@@ -1949,7 +1757,6 @@ describe('getTimerPhase()', () => {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
|
||||
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
|
||||
import { appStatePath, isTest } from '../../setup/index.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
import { shouldCrashDev } from '../../utils/development.js';
|
||||
|
||||
interface AppState {
|
||||
lastLoadedProject?: string;
|
||||
@@ -27,13 +27,8 @@ export async function getLastLoadedProject(): Promise<string | undefined> {
|
||||
|
||||
export async function setLastLoadedProject(filename: string): Promise<void> {
|
||||
if (isTest) return;
|
||||
if (!isProduction) {
|
||||
if (isPath(filename)) {
|
||||
consoleError(filename);
|
||||
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: shouldCrashDev(isPath(filename), 'setLastLoadedProject should not be called with a path');
|
||||
|
||||
config.data.lastLoadedProject = filename;
|
||||
await config.write();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
|
||||
import * as cache from './rundownCache.js';
|
||||
import { getPlayableEvents } from './rundownUtils.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
|
||||
|
||||
@@ -262,3 +263,7 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
|
||||
// notify timer of change
|
||||
notifyChanges({ timer: true });
|
||||
}
|
||||
|
||||
export async function setFrozenState(state: boolean) {
|
||||
eventStore.set('frozen', state);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
getEventWithId,
|
||||
getPlayableEvents,
|
||||
} from '../rundown-service/rundownUtils.js';
|
||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||
import { integrationService } from '../integration-service/IntegrationService.js';
|
||||
|
||||
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
|
||||
|
||||
/**
|
||||
@@ -37,18 +39,21 @@ import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './ru
|
||||
* Coordinating with necessary services
|
||||
*/
|
||||
class RuntimeService {
|
||||
private eventTimer: TimerService | null = null;
|
||||
private eventTimer: TimerService;
|
||||
private lastIntegrationClockUpdate = -1;
|
||||
private lastIntegrationTimerValue = -1;
|
||||
|
||||
/** last time we updated the socket */
|
||||
static previousTimerUpdate: number;
|
||||
static previousTimerValue: MaybeNumber;
|
||||
static previousTimerValue: MaybeNumber; // previous timer value, could be null
|
||||
static previousClockUpdate: number;
|
||||
|
||||
/** last known state */
|
||||
static previousState: RuntimeState;
|
||||
|
||||
constructor() {
|
||||
constructor(timerService: TimerService) {
|
||||
this.eventTimer = timerService;
|
||||
|
||||
RuntimeService.previousTimerUpdate = -1;
|
||||
RuntimeService.previousTimerValue = -1;
|
||||
RuntimeService.previousClockUpdate = -1;
|
||||
@@ -59,7 +64,45 @@ class RuntimeService {
|
||||
@broadcastResult
|
||||
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
|
||||
const newState = runtimeState.getState();
|
||||
if (hasTimerFinished) {
|
||||
|
||||
// 1. find if we need to dispatch integrations related to the phase
|
||||
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
|
||||
if (timerPhaseChanged) {
|
||||
if (newState.timer.phase === TimerPhase.Warning) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onWarning);
|
||||
});
|
||||
} else if (newState.timer.phase === TimerPhase.Danger) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onDanger);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. handle edge cases related to roll
|
||||
if (newState.timer.playback === Playback.Roll) {
|
||||
// check if we need to call roll again
|
||||
const needsEvent =
|
||||
newState.eventNow === null
|
||||
? true
|
||||
: skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit);
|
||||
const hasFinishedRoll = hasTimerFinished && shouldCallRoll;
|
||||
if (shouldCallRoll || needsEvent) {
|
||||
if (hasFinishedRoll) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onFinish);
|
||||
});
|
||||
}
|
||||
|
||||
// we dont call this.roll because we need to bypass the checks
|
||||
const rundown = getPlayableEvents();
|
||||
// TODO: by not calling roll, we dont get the events
|
||||
this.eventTimer.roll(rundown);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. find if we need to process actions related to the timer finishing
|
||||
if (newState.timer.playback !== Playback.Roll && hasTimerFinished) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onFinish);
|
||||
});
|
||||
@@ -77,10 +120,8 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
|
||||
const shouldUpdateTimer =
|
||||
hasRunningTimer && getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
|
||||
// 4. find if we need to update the timer
|
||||
const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
if (shouldUpdateTimer) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
@@ -89,6 +130,7 @@ class RuntimeService {
|
||||
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
|
||||
}
|
||||
|
||||
// 5. find if we need to update the clock
|
||||
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
||||
if (shouldUpdateClock) {
|
||||
process.nextTick(() => {
|
||||
@@ -97,42 +139,12 @@ class RuntimeService {
|
||||
|
||||
this.lastIntegrationClockUpdate = newState.clock;
|
||||
}
|
||||
|
||||
if (shouldCallRoll) {
|
||||
// we dont call this.roll because we need to bypass the checks
|
||||
const rundown = getPlayableEvents();
|
||||
this.eventTimer.roll(rundown);
|
||||
}
|
||||
|
||||
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
|
||||
|
||||
if (timerPhaseChanged) {
|
||||
switch (newState.timer.phase) {
|
||||
case TimerPhase.Warning:
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onWarning);
|
||||
});
|
||||
break;
|
||||
case TimerPhase.Danger:
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onDanger);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** delay initialisation until we have a restore point */
|
||||
init(resumable: RestorePoint | null) {
|
||||
logger.info(LogOrigin.Server, 'Runtime service started');
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
this.eventTimer = new TimerService({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult),
|
||||
});
|
||||
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
|
||||
|
||||
if (resumable) {
|
||||
this.resume(resumable);
|
||||
@@ -553,8 +565,16 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
export const runtimeService = new RuntimeService();
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
const eventTimer = new TimerService({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
*/
|
||||
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -56,6 +55,12 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
*/
|
||||
|
||||
export function getCurrent(state: RuntimeState): number {
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (state.eventNow === null || state.timer.duration === null) {
|
||||
throw new Error('timerUtils.getCurrent: invalid state received');
|
||||
}
|
||||
}
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
const { timerType, timeStart, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
@@ -88,6 +93,12 @@ export function getCurrent(state: RuntimeState): number {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
|
||||
throw new Error('timerUtils.skippedOutOfEvent: invalid state received');
|
||||
}
|
||||
}
|
||||
const { startedAt, expectedFinish } = state.timer;
|
||||
const { clock } = state;
|
||||
|
||||
@@ -246,54 +257,6 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIn
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Implements update functions for roll mode
|
||||
* @param {RuntimeState}
|
||||
* @returns object with selection variables
|
||||
*/
|
||||
export const updateRoll = (state: RuntimeState) => {
|
||||
const { current, expectedFinish, startedAt, secondaryTimer } = state.timer;
|
||||
const { secondaryTarget } = state._timer;
|
||||
const { clock } = state;
|
||||
const selectedEventId = state.eventNow?.id ?? null;
|
||||
|
||||
// timers
|
||||
let updatedTimer = current;
|
||||
let updatedSecondaryTimer = secondaryTimer;
|
||||
// whether rollLoad should be called: force reload of events
|
||||
let doRollLoad = false;
|
||||
// whether finished event should trigger
|
||||
let isPrimaryFinished = false;
|
||||
|
||||
if (selectedEventId && current !== null) {
|
||||
// if we have something selected and a timer, we are running
|
||||
|
||||
const finishAt = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
|
||||
updatedTimer = finishAt - clock;
|
||||
|
||||
if (updatedTimer > dayInMs) {
|
||||
updatedTimer -= dayInMs;
|
||||
}
|
||||
|
||||
if (updatedTimer <= timerConfig.triggerAhead) {
|
||||
isPrimaryFinished = true;
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
} else if (secondaryTimer >= 0) {
|
||||
// if secondaryTimer is running we are in waiting to roll
|
||||
|
||||
updatedSecondaryTimer = secondaryTarget - clock;
|
||||
|
||||
if (updatedSecondaryTimer <= 0) {
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates difference between the runtime and the schedule of an event
|
||||
* Positive offset is time ahead
|
||||
@@ -305,6 +268,14 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
// we know current exists as long as eventNow exists
|
||||
if (state.timer.current === null) {
|
||||
throw new Error('timerUtils.calculate: current must be set');
|
||||
}
|
||||
}
|
||||
|
||||
const { clock } = state;
|
||||
const { timeStart, timerType } = state.eventNow;
|
||||
const { addedTime, current, startedAt } = state.timer;
|
||||
@@ -356,7 +327,7 @@ export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: n
|
||||
*/
|
||||
export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
// there is no expected end if we havent started
|
||||
if (state.runtime.actualStart === null) {
|
||||
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
|
||||
return null;
|
||||
}
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||
@@ -367,7 +338,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
* @param state
|
||||
* @returns
|
||||
*/
|
||||
function isPlaybackActive(state: RuntimeState): boolean {
|
||||
export function isPlaybackActive(state: RuntimeState): boolean {
|
||||
return (
|
||||
state.timer.playback === Playback.Play ||
|
||||
state.timer.playback === Playback.Pause ||
|
||||
@@ -386,7 +357,7 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
|
||||
const current = state.timer.current;
|
||||
|
||||
if (current === null || state.eventNow === null) {
|
||||
if (current === null || state.eventNow === null || state.timer.secondaryTimer != null) {
|
||||
return TimerPhase.Pending;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ export const config = {
|
||||
demoProject: 'demo project.json',
|
||||
newProject: 'new project.json',
|
||||
database: {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
},
|
||||
|
||||
@@ -73,7 +73,6 @@ export const resolvedPath = (): string => {
|
||||
export const resolvePublicDirectoy = getAppDataPath();
|
||||
ensureDirectory(resolvePublicDirectoy);
|
||||
|
||||
const testDbStartDirectory = isTest ? '../' : resolvePublicDirectoy;
|
||||
export const externalsStartDirectory = isProduction ? resolvePublicDirectoy : join(srcDirectory, 'external');
|
||||
// TODO: we only need one when they are all in the same folder
|
||||
export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirectoy : srcDirectory, 'external');
|
||||
@@ -82,13 +81,6 @@ export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirect
|
||||
export const appStatePath = join(resolvePublicDirectoy, config.appState);
|
||||
export const uploadsFolderPath = join(resolvePublicDirectoy, config.uploads);
|
||||
|
||||
// path to public db
|
||||
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
|
||||
|
||||
export const pathToStartDb = isTest
|
||||
? join(srcDirectory, '..', config.database.testdb, config.database.filename)
|
||||
: join(srcDirectory, '/preloaded-db/', config.database.filename);
|
||||
|
||||
// path to public styles
|
||||
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||
|
||||
@@ -36,7 +36,6 @@ const mockState = {
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
@@ -84,7 +83,7 @@ describe('mutation on runtimeState', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('playback operations', () => {
|
||||
describe('playback operations', async () => {
|
||||
it('refuses if nothing is loaded', () => {
|
||||
let success = start(mockState);
|
||||
expect(success).toBe(false);
|
||||
@@ -160,8 +159,12 @@ describe('mutation on runtimeState', () => {
|
||||
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
|
||||
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
|
||||
// force update
|
||||
initRundown([event1, event2], {});
|
||||
test('runtime offset', () => {
|
||||
vi.useFakeTimers();
|
||||
await initRundown([event1, event2], {});
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
test('runtime offset', async () => {
|
||||
// 1. Load event
|
||||
load(event1, [event1, event2]);
|
||||
let newState = getState();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { RestorePoint } from '../services/RestoreService.js';
|
||||
|
||||
import {
|
||||
getCurrent,
|
||||
getExpectedEnd,
|
||||
@@ -11,32 +10,32 @@ import {
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
skippedOutOfEvent,
|
||||
updateRoll,
|
||||
isPlaybackActive,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../config/config.js';
|
||||
|
||||
const initialRuntime: Runtime = {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
offset: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
||||
numEvents: 0, // change initiated by user
|
||||
offset: 0, // changes at runtime
|
||||
plannedStart: 0, // only changes if event changes
|
||||
plannedEnd: 0, // only changes if event changes
|
||||
actualStart: null, // set once we start the timer
|
||||
expectedEnd: null, // changes with runtime, based on offset
|
||||
} as const;
|
||||
|
||||
const initialTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
current: null, // changes on every update
|
||||
duration: null, // only changes if event changes
|
||||
elapsed: null, // changes on every update
|
||||
// TODO: expected finish could account for midnight, we cleanup in the clients
|
||||
expectedFinish: null, // change can only be initiated by user
|
||||
finishedAt: null, // can change on update or user action
|
||||
phase: TimerPhase.None, // can change on update or user action
|
||||
playback: Playback.Stop, // change initiated by user
|
||||
secondaryTimer: null, // change on every update
|
||||
startedAt: null, // change can only be initiated by user
|
||||
} as const;
|
||||
|
||||
export type RuntimeState = {
|
||||
@@ -49,10 +48,9 @@ export type RuntimeState = {
|
||||
timer: TimerState;
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: MaybeNumber;
|
||||
forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,7 +66,6 @@ const runtimeState: RuntimeState = {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,7 +90,6 @@ export function clear() {
|
||||
|
||||
// we maintain the total delay
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +121,8 @@ export function updateRundownData(rundownData: RundownData) {
|
||||
|
||||
runtimeState.runtime.numEvents = rundownData.numEvents;
|
||||
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
||||
runtimeState.runtime.plannedEnd = rundownData.firstStart + rundownData.totalDuration;
|
||||
runtimeState.runtime.plannedEnd =
|
||||
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
}
|
||||
|
||||
@@ -282,7 +279,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
state.clock = clock.timeNow();
|
||||
state.timer.secondaryTimer = null;
|
||||
state._timer.secondaryTarget = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state._timer.pausedAt) {
|
||||
@@ -374,73 +370,60 @@ export type UpdateResult = {
|
||||
};
|
||||
|
||||
export function update(): UpdateResult {
|
||||
let hasTimerFinished = false;
|
||||
let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished
|
||||
// 0. there are some things we always do
|
||||
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
||||
|
||||
const previousTime = runtimeState.clock;
|
||||
runtimeState.clock = clock.timeNow();
|
||||
|
||||
// we call integrations if we update timers
|
||||
if (runtimeState.timer.playback === Playback.Roll) {
|
||||
const result = onRollUpdate();
|
||||
shouldCallRoll = result.doRoll;
|
||||
hasTimerFinished = result.isFinished;
|
||||
} else if (runtimeState.timer.startedAt !== null) {
|
||||
// we only update timer if a timer has been started
|
||||
const result = onPlayUpdate();
|
||||
hasTimerFinished = result.isFinished;
|
||||
} else if (runtimeState.eventNow?.timerType === TimerType.TimeToEnd) {
|
||||
// or if we are in a time-to-end timer
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.timer.duration = runtimeState.timer.current;
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState)) {
|
||||
return updateIfIdle();
|
||||
}
|
||||
|
||||
// update timer phase
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
return updateIfWaitingToRoll(runtimeState.timer.secondaryTimer);
|
||||
}
|
||||
|
||||
// update offset
|
||||
// 3. at this point we know that we are playing an event
|
||||
// reset data
|
||||
runtimeState.timer.secondaryTimer = null;
|
||||
|
||||
// update timer state
|
||||
if (!runtimeState.timer.duration) {
|
||||
throw new Error('Timer duration is not set');
|
||||
}
|
||||
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
// update runtime, needs up-to-date timer state
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
return {
|
||||
hasTimerFinished,
|
||||
shouldCallRoll,
|
||||
};
|
||||
const finishedNow =
|
||||
Boolean(runtimeState._timer.forceFinish) ||
|
||||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
|
||||
|
||||
function onRollUpdate() {
|
||||
const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit);
|
||||
if (hasSkippedOutOfEvent) {
|
||||
return { doRoll: true };
|
||||
}
|
||||
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(runtimeState);
|
||||
runtimeState.timer.current = updatedTimer;
|
||||
runtimeState.timer.secondaryTimer = updatedSecondaryTimer;
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
return { doRoll: doRollLoad, isFinished };
|
||||
if (finishedNow) {
|
||||
// reset state
|
||||
runtimeState._timer.forceFinish;
|
||||
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
|
||||
} else {
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
}
|
||||
|
||||
function onPlayUpdate() {
|
||||
let isFinished = false;
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
const shouldForceFinish = runtimeState._timer.forceFinish !== null;
|
||||
const finishedNow =
|
||||
shouldForceFinish ||
|
||||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
|
||||
return { hasTimerFinished: finishedNow, shouldCallRoll: finishedNow };
|
||||
|
||||
if (runtimeState.timer.playback === Playback.Play && finishedNow) {
|
||||
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
|
||||
isFinished = true;
|
||||
} else {
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
}
|
||||
function updateIfIdle() {
|
||||
// if nothing is running, nothing to do
|
||||
return { hasTimerFinished: false, shouldCallRoll: false };
|
||||
}
|
||||
|
||||
if (shouldForceFinish) {
|
||||
runtimeState._timer.forceFinish = null;
|
||||
}
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
return { isFinished };
|
||||
function updateIfWaitingToRoll(targetTime: number) {
|
||||
runtimeState.timer.secondaryTimer = targetTime - runtimeState.clock;
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
return { hasTimerFinished: false, shouldCallRoll: runtimeState.timer.secondaryTimer < 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +437,6 @@ export function roll(rundown: OntimeEvent[]) {
|
||||
if (currentEvent) {
|
||||
// there is something running, load
|
||||
runtimeState.timer.secondaryTimer = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
|
||||
// account for event that finishes the day after
|
||||
const endTime =
|
||||
@@ -475,8 +457,8 @@ export function roll(rundown: OntimeEvent[]) {
|
||||
// account for day after
|
||||
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
|
||||
// nothing now, but something coming up
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock;
|
||||
runtimeState._timer.secondaryTarget = nextStart;
|
||||
}
|
||||
|
||||
runtimeState.timer.playback = Playback.Roll;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isProduction } from '../setup/index.js';
|
||||
import { consoleError } from '../utils/console.js';
|
||||
|
||||
/**
|
||||
* Milestone checker for dev environment
|
||||
* will terminate process if check returns true
|
||||
* Ideally we would like to remove the call to this function on build
|
||||
*/
|
||||
export function shouldCrashDev(check: boolean, reason: string) {
|
||||
if (isProduction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!check) {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleError(new Error(reason).stack ?? '');
|
||||
process.exit(2);
|
||||
}
|
||||
-339
@@ -1,339 +0,0 @@
|
||||
{
|
||||
"rundown": [
|
||||
{
|
||||
"title": "Albania",
|
||||
"note": "SF1.01",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 36000000,
|
||||
"timeEnd": 37200000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "32d31",
|
||||
"cue": "SF1.01",
|
||||
"custom": {
|
||||
"song": "Sekret",
|
||||
"artist": "Ronela Hajati"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Latvia",
|
||||
"note": "SF1.02",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 37500000,
|
||||
"timeEnd": 38700000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "21cd2",
|
||||
"cue": "SF1.02",
|
||||
"custom": {
|
||||
"song": "Eat Your Salad",
|
||||
"artist": "Citi Zeni"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Lithuania",
|
||||
"note": "SF1.03",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 39000000,
|
||||
"timeEnd": 40200000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "0b371",
|
||||
"cue": "SF1.03",
|
||||
"custom": {
|
||||
"song": "Sentimentai",
|
||||
"artist": "Monika Liu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Switzerland",
|
||||
"note": "SF1.04",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 40500000,
|
||||
"timeEnd": 41700000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "3cd28",
|
||||
"cue": "SF1.04",
|
||||
"custom": {
|
||||
"song": "Boys Do Cry",
|
||||
"artist": "Marius Bear"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Slovenia",
|
||||
"note": "SF1.05",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 42000000,
|
||||
"timeEnd": 43200000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "e457f",
|
||||
"cue": "SF1.05",
|
||||
"custom": {
|
||||
"song": "Disko",
|
||||
"artist": "LPS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Lunch break",
|
||||
"type": "block",
|
||||
"id": "01e85"
|
||||
},
|
||||
{
|
||||
"title": "Ukraine",
|
||||
"note": "SF1.06",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 47100000,
|
||||
"timeEnd": 48300000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "1c420",
|
||||
"cue": "SF1.06",
|
||||
"custom": {
|
||||
"song": "Stefania",
|
||||
"artist": "Kalush Orchestra"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Bulgaria",
|
||||
"note": "SF1.07",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 48600000,
|
||||
"timeEnd": 49800000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "b7737",
|
||||
"cue": "SF1.07",
|
||||
"custom": {
|
||||
"song": "Intention",
|
||||
"artist": "Intelligent Music Project"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Netherlands",
|
||||
"note": "SF1.08",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 50100000,
|
||||
"timeEnd": 51300000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "d3a80",
|
||||
"cue": "SF1.08",
|
||||
"custom": {
|
||||
"song": "De Diepte",
|
||||
"artist": "S10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Moldova",
|
||||
"note": "SF1.09",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 51600000,
|
||||
"timeEnd": 52800000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "8276c",
|
||||
"cue": "SF1.09",
|
||||
"custom": {
|
||||
"song": "Trenuletul",
|
||||
"artist": "Zdob si Zdub"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Portugal",
|
||||
"note": "SF1.10",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 53100000,
|
||||
"timeEnd": 54300000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "2340b",
|
||||
"cue": "SF1.10",
|
||||
"custom": {
|
||||
"song": "Saudade Saudade",
|
||||
"artist": "Maro"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Afternoon break",
|
||||
"type": "block",
|
||||
"id": "cb90b"
|
||||
},
|
||||
{
|
||||
"title": "Croatia",
|
||||
"note": "SF1.11",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 56100000,
|
||||
"timeEnd": 57300000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "503c4",
|
||||
"cue": "SF1.11",
|
||||
"custom": {
|
||||
"song": "Guilty Pleasure",
|
||||
"artist": "Mia Dimsic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Denmark",
|
||||
"note": "SF1.12",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 57600000,
|
||||
"timeEnd": 58800000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "5e965",
|
||||
"cue": "SF1.12",
|
||||
"custom": {
|
||||
"song": "The Show",
|
||||
"artist": "Reddi"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Austria",
|
||||
"note": "SF1.13",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 59100000,
|
||||
"timeEnd": 60300000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "bab4a",
|
||||
"cue": "SF1.13",
|
||||
"custom": {
|
||||
"song": "Halo",
|
||||
"artist": "LUM!X & Pia Maria"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Greece",
|
||||
"note": "SF1.14",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 60600000,
|
||||
"timeEnd": 61800000,
|
||||
"duration": 1200000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "d3eb1",
|
||||
"cue": "SF1.14",
|
||||
"custom": {
|
||||
"song": "Die Together",
|
||||
"artist": "Amanda Tenfjord"
|
||||
}
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
"title": "Eurovision Song Contest",
|
||||
"description": "Turin 2022",
|
||||
"publicUrl": "www.getontime.no",
|
||||
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
||||
"backstageUrl": "www.github.com/cpvalente/ontime",
|
||||
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
|
||||
},
|
||||
"settings": {
|
||||
"app": "ontime",
|
||||
"version": "2.0.0",
|
||||
"serverPort": 4001,
|
||||
"editorKey": null,
|
||||
"operatorKey": null,
|
||||
"timeFormat": "24",
|
||||
"language": "en"
|
||||
},
|
||||
"viewSettings": {
|
||||
"overrideStyles": false,
|
||||
"normalColor": "#ffffffcc",
|
||||
"warningColor": "#FFAB33",
|
||||
"warningThreshold": 120000,
|
||||
"dangerColor": "#ED3333",
|
||||
"dangerThreshold": 60000,
|
||||
"endMessage": ""
|
||||
},
|
||||
"urlPresets": [
|
||||
{
|
||||
"enabled": true,
|
||||
"alias": "test",
|
||||
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
|
||||
}
|
||||
],
|
||||
"osc": {
|
||||
"portIn": 8888,
|
||||
"portOut": 9999,
|
||||
"targetIP": "127.0.0.1",
|
||||
"enabledIn": true,
|
||||
"enabledOut": true,
|
||||
"subscriptions": []
|
||||
},
|
||||
"http": {
|
||||
"enabledOut": true,
|
||||
"subscriptions": []
|
||||
}
|
||||
}
|
||||
+8
-21
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -25,7 +25,6 @@
|
||||
"dev:electron": "turbo run dev --filter=ontime",
|
||||
"dev:server": "turbo run dev --filter=ontime-server",
|
||||
"lint": "turbo run lint",
|
||||
"lint-staged": "turbo run lint-staged --concurrency=1",
|
||||
"build": "turbo run build",
|
||||
"build:local": "turbo run build:local",
|
||||
"build:electron": "turbo run build:electron",
|
||||
@@ -40,27 +39,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/node": "^20.14.10",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-playwright": "^1.5.2",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
"prettier": "^3.3.1",
|
||||
"prettier": "catalog:",
|
||||
"turbo": "^1.11.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"pnpm lint"
|
||||
]
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
"description": "shared typings for ontime",
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint"
|
||||
"lint": "eslint . --quiet"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"eslint": "^8.56.0",
|
||||
"typescript": "^5.5.3"
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,7 @@ export type RuntimeStore = {
|
||||
|
||||
// extra timers
|
||||
auxtimer1: SimpleTimerState;
|
||||
|
||||
// flags
|
||||
frozen: boolean;
|
||||
};
|
||||
|
||||
@@ -7,18 +7,27 @@ export enum TimerPhase {
|
||||
Warning = 'warning',
|
||||
Danger = 'danger',
|
||||
Overtime = 'overtime',
|
||||
Pending = 'pending', // used for waiting to roll
|
||||
/** used for waiting to roll */
|
||||
Pending = 'pending',
|
||||
}
|
||||
|
||||
export type TimerState = {
|
||||
addedTime: number; // time added by user, can be negative
|
||||
current: MaybeNumber; // running countdown
|
||||
duration: MaybeNumber; // normalised duration of current event
|
||||
elapsed: MaybeNumber; // elapsed time in current timer
|
||||
expectedFinish: MaybeNumber; // time we expect timer to finish
|
||||
finishedAt: MaybeNumber; // only if timer has already finished
|
||||
/** time added by user, can be negative */
|
||||
addedTime: number;
|
||||
/** running countdown */
|
||||
current: MaybeNumber;
|
||||
/** normalised duration of current event */
|
||||
duration: MaybeNumber;
|
||||
/** elapsed time in current timer */
|
||||
elapsed: MaybeNumber;
|
||||
/** time we expect timer to finish */
|
||||
expectedFinish: MaybeNumber;
|
||||
/** only if timer has already finished */
|
||||
finishedAt: MaybeNumber;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber; // used for roll mode
|
||||
startedAt: MaybeNumber; // only if timer has already started
|
||||
/** used for roll mode */
|
||||
secondaryTimer: MaybeNumber;
|
||||
/** only if timer has already started */
|
||||
startedAt: MaybeNumber;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||
export { generateId } from './src/generate-id/generateId.js';
|
||||
export {
|
||||
getEventWithId,
|
||||
getFirst,
|
||||
getFirstEvent,
|
||||
getFirstEventNormal,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"description": "shared logic for ontime",
|
||||
"scripts": {
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules"
|
||||
@@ -16,15 +15,15 @@
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"sideEffects": false
|
||||
|
||||
@@ -326,3 +326,7 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA:
|
||||
|
||||
return { newA, newB };
|
||||
};
|
||||
|
||||
export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined {
|
||||
return rundown.find((event) => event.id === id);
|
||||
}
|
||||
|
||||
Generated
+139
-340
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,12 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
catalog:
|
||||
typescript: 5.5.3
|
||||
"@typescript-eslint/eslint-plugin": 7.16.1
|
||||
"@typescript-eslint/parser": 7.16.1
|
||||
"@types/node": 20.14.10
|
||||
prettier: 3.3.1
|
||||
eslint: 8.56.0
|
||||
eslint-config-prettier: 9.1.0
|
||||
eslint-plugin-prettier: 5.1.3
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"rundown": [],
|
||||
"project": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"publicUrl": "",
|
||||
"publicInfo": "",
|
||||
"backstageUrl": "",
|
||||
"backstageInfo": ""
|
||||
},
|
||||
"settings": {
|
||||
"app": "ontime",
|
||||
"version": "3.0.0-beta.3",
|
||||
"serverPort": 4001,
|
||||
"editorKey": null,
|
||||
"operatorKey": null,
|
||||
"timeFormat": "24",
|
||||
"language": "en"
|
||||
},
|
||||
"viewSettings": {
|
||||
"dangerColor": "#ED3333",
|
||||
"endMessage": "",
|
||||
"freezeEnd": false,
|
||||
"normalColor": "#ffffffcc",
|
||||
"overrideStyles": false,
|
||||
"warningColor": "#FFAB33"
|
||||
},
|
||||
"urlPresets": [],
|
||||
"customFields": {},
|
||||
"osc": {
|
||||
"portIn": 8888,
|
||||
"portOut": 9999,
|
||||
"targetIP": "127.0.0.1",
|
||||
"enabledIn": false,
|
||||
"enabledOut": false,
|
||||
"subscriptions": []
|
||||
},
|
||||
"http": {
|
||||
"enabledOut": false,
|
||||
"subscriptions": []
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,6 @@
|
||||
"lint": {
|
||||
"cache": false
|
||||
},
|
||||
"lint-staged": {
|
||||
"outputs": [],
|
||||
"cache": false
|
||||
},
|
||||
"typecheck": {
|
||||
"cache": false
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user