mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 01:13:55 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac61f257c0 | |||
| 8d8625aa77 | |||
| 1e1aa4bca5 | |||
| 1f0cde6d0d | |||
| c4b66a9ed3 | |||
| 133b3aa819 | |||
| 9566fadbbb | |||
| 62570899e8 | |||
| c1f25f8dba | |||
| 37a91026d8 | |||
| c62ce213f5 | |||
| 529751578d | |||
| dce87c3a81 | |||
| a94b10cb0f | |||
| 6817263123 | |||
| ddd629d7db | |||
| 9d2a3d5a12 | |||
| 4bc271208e | |||
| dbd535dd6c | |||
| 57b7fe214a | |||
| b136e2856a | |||
| 03e68c03a9 | |||
| 44048a889d | |||
| f72db62e08 | |||
| 333f2623a5 | |||
| 3ebb3451e4 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.14.3",
|
"version": "3.15.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.14.3",
|
"version": "3.15.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -17,12 +17,13 @@
|
|||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"@tanstack/react-query-devtools": "^5.62.7",
|
"@tanstack/react-query-devtools": "^5.62.7",
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"framer-motion": "^10.10.0",
|
"framer-motion": "^10.10.0",
|
||||||
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"react-icons": "5.4.0",
|
"react-icons": "5.4.0",
|
||||||
"react-qr-code": "^2.0.12",
|
"react-qr-code": "^2.0.12",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"web-vitals": "^3.1.1",
|
"web-vitals": "^3.1.1",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
|
const assetsPath = `${apiEntryUrl}/assets`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to get css contents
|
||||||
|
*/
|
||||||
|
export async function getCSSContents(): Promise<string> {
|
||||||
|
const res = await axios.get(`${assetsPath}/css`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to post css contents
|
||||||
|
*/
|
||||||
|
export async function postCSSContents(css: string): Promise<void> {
|
||||||
|
await axios.post(`${assetsPath}/css`, {
|
||||||
|
css,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to restore default css
|
||||||
|
*/
|
||||||
|
export async function restoreCSSContents(): Promise<string> {
|
||||||
|
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
color: $blue-400;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $blue-400;
|
color: $blue-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { MultiselectOptions, ParamField } from './types';
|
|||||||
|
|
||||||
export const makeOptionsFromCustomFields = (
|
export const makeOptionsFromCustomFields = (
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
additionalOptions: Record<string, string> = {},
|
additionalOptions: Readonly<Record<string, string>> = {},
|
||||||
filterImageType = true,
|
filterImageType = true,
|
||||||
) => {
|
) => {
|
||||||
const options = structuredClone(additionalOptions);
|
const options = { ...additionalOptions };
|
||||||
for (const [key, value] of Object.entries(customFields)) {
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
if (filterImageType && value.type === 'image') {
|
if (filterImageType && value.type === 'image') {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
|||||||
|
|
||||||
numEvents: state.runtime.numEvents,
|
numEvents: state.runtime.numEvents,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
|
|
||||||
currentBlock: state.currentBlock,
|
currentBlock: state.currentBlock,
|
||||||
}));
|
}));
|
||||||
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
|||||||
|
|
||||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
|
offsetMode: state.runtime.offsetMode,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||||
|
actualStart: state.runtime.actualStart,
|
||||||
|
plannedStart: state.runtime.plannedStart,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
|||||||
isOnline: state.ping > 0,
|
isOnline: state.ping > 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||||
|
offsetMode: state.runtime.offsetMode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
|
||||||
|
|
||||||
export const usePlayback = () => {
|
export const usePlayback = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { OffsetMode } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateTimeUntilStart()', () => {
|
describe('calculateTimeUntilStart()', () => {
|
||||||
test('ontime', () => {
|
describe('Absolute offset mode', () => {
|
||||||
|
test('ontime', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 90,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 90,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running ahead', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 80,
|
||||||
|
offset: 10,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind with enough gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 20,
|
||||||
|
clock: 50,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind with too little gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 10,
|
||||||
|
clock: 50,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: 0,
|
||||||
|
plannedStart: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Relative offset mode', () => {
|
||||||
|
test('basic function', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeStartEvent2 = 10;
|
||||||
|
const timeStartEvent3 = 20;
|
||||||
|
|
||||||
|
//event 1 is the currently running event
|
||||||
|
|
||||||
|
//event 2
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
||||||
|
|
||||||
|
//event 3
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||||
|
test.clock = 105;
|
||||||
|
|
||||||
|
//event 2
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
||||||
|
|
||||||
|
//event 3
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 20,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 10,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||||
|
test.clock = 105;
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('added/remove time', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 20,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
||||||
|
|
||||||
|
test.offset = -5; // add 5 with addtime - we are behind
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next day', () => {
|
||||||
|
const test = {
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
// this event will start the current day
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 10,
|
||||||
|
dayOffset: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(10);
|
||||||
|
|
||||||
|
// this event will start the next day
|
||||||
|
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
||||||
|
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 1,
|
||||||
|
totalGap: dayInMs - 20,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(dayInMs);
|
||||||
|
|
||||||
|
// advancing 100ms
|
||||||
|
test.clock = 200;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 1,
|
||||||
|
totalGap: dayInMs - 20,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(dayInMs - 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlap with negative total gap', () => {
|
||||||
const test = {
|
const test = {
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
dayOffset: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
currentDay: 0,
|
currentDay: 0,
|
||||||
totalGap: 0,
|
clock: 100,
|
||||||
clock: 90,
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
// the overlap will be pushed out to the expected available time
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||||
|
|
||||||
|
test.clock = 105;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('running behind', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 90,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running ahead', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 80,
|
|
||||||
offset: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with enough gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 20,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with to little gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 10,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
|
|
||||||
//TODO: more indepth testing,
|
|
||||||
// including day offset handling
|
|
||||||
// and more?
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
|
|
||||||
|
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const number = Number(value);
|
||||||
|
if (isNaN(number)) return defaultValue;
|
||||||
|
return number;
|
||||||
|
}
|
||||||
@@ -48,10 +48,12 @@ export const connectSocket = () => {
|
|||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
console.warn('WebSocket disconnected');
|
console.warn('WebSocket disconnected');
|
||||||
setOnlineStatus(false);
|
|
||||||
|
|
||||||
if (shouldReconnect) {
|
if (shouldReconnect) {
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
if (reconnectAttempts > 2) {
|
||||||
|
setOnlineStatus(false);
|
||||||
|
}
|
||||||
console.warn('WebSocket: attempting reconnect');
|
console.warn('WebSocket: attempting reconnect');
|
||||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||||
reconnectAttempts += 1;
|
reconnectAttempts += 1;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||||
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
const { offset, clock, currentDay } = useTimeUntilData();
|
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
|
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
clock: number;
|
clock: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
offsetMode: OffsetMode;
|
||||||
|
actualStart: MaybeNumber;
|
||||||
|
plannedStart: MaybeNumber;
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
|
const {
|
||||||
|
timeStart,
|
||||||
|
dayOffset,
|
||||||
|
currentDay,
|
||||||
|
totalGap,
|
||||||
|
isLinkedToLoaded,
|
||||||
|
clock,
|
||||||
|
offset,
|
||||||
|
delay,
|
||||||
|
offsetMode,
|
||||||
|
actualStart,
|
||||||
|
plannedStart,
|
||||||
|
} = data;
|
||||||
|
|
||||||
//How many days from the currently running event to this one
|
//How many days from the currently running event to this one
|
||||||
const relativeDayOffset = dayOffset - currentDay;
|
const relativeDayOffset = dayOffset - currentDay;
|
||||||
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
|
|||||||
//The normalised start time of this event relative to the currently running event
|
//The normalised start time of this event relative to the currently running event
|
||||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||||
|
|
||||||
const offsetTimestart = normalisedTimeStart - offset;
|
let relativeStartOffset = 0;
|
||||||
const offsetTimeUntil = offsetTimestart - clock;
|
|
||||||
|
if (offsetMode === OffsetMode.Relative) {
|
||||||
|
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
|
||||||
|
|
||||||
|
const offsetTimeUntil = scheduledTimeUntil - offset;
|
||||||
|
|
||||||
if (isLinkedToLoaded) {
|
if (isLinkedToLoaded) {
|
||||||
//if we are directly linked back to the loaded event we just follow the offset
|
//if we are directly linked back to the loaded event we just follow the offset
|
||||||
return offsetTimeUntil;
|
return offsetTimeUntil;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledTimeUntil = normalisedTimeStart - clock;
|
const gapsCanCompensateForOffset = totalGap + offset >= 0;
|
||||||
|
if (gapsCanCompensateForOffset) {
|
||||||
const isAheadOfSchedule = offset >= 0;
|
|
||||||
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
|
|
||||||
|
|
||||||
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
|
|
||||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||||
return scheduledTimeUntil;
|
return scheduledTimeUntil;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
|
|||||||
&.end {
|
&.end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
&.apart {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animloader {
|
@keyframes animloader {
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
|||||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Error({ children }: { children: ReactNode }) {
|
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||||
return <div className={style.fieldError}>{children}</div>;
|
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Divider() {
|
export function Divider() {
|
||||||
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
|
|||||||
type InlineProps<C extends AllowedInlineTags> = {
|
type InlineProps<C extends AllowedInlineTags> = {
|
||||||
as?: C;
|
as?: C;
|
||||||
relation?: 'inner' | 'component' | 'section';
|
relation?: 'inner' | 'component' | 'section';
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end' | 'apart';
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
import Editor from 'react-simple-code-editor';
|
||||||
|
import Prism from 'prismjs/components/prism-core';
|
||||||
|
|
||||||
|
import 'prismjs/components/prism-css';
|
||||||
|
import 'prismjs/themes/prism-tomorrow.min.css';
|
||||||
|
import style from './StyleEditor.module.scss';
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
language: string;
|
||||||
|
initialValue: string;
|
||||||
|
isDirty: boolean;
|
||||||
|
setIsDirty: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||||
|
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||||
|
|
||||||
|
const [code, setCode] = useState(initialValue);
|
||||||
|
|
||||||
|
const highlight = (code: string) => {
|
||||||
|
const grammar = Prism.languages[language];
|
||||||
|
return grammar ? Prism.highlight(code, grammar, language) : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (newCode: string) => {
|
||||||
|
setCode(newCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(cssRef, () => {
|
||||||
|
return {
|
||||||
|
getCss: () => code,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// add contents to editor on mount and any change in initialValue
|
||||||
|
useEffect(() => {
|
||||||
|
setCode(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
// handle dirty state on change
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
|
||||||
|
setIsDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialValue.trim() === code.trim() && isDirty) {
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
}, [initialValue, code, isDirty, setIsDirty]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.wrapper}>
|
||||||
|
<Editor
|
||||||
|
value={code}
|
||||||
|
padding={15}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
highlight={highlight}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 500,
|
||||||
|
background: '#2d2d2d', // Background of tomorrow theme
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CodeEditor.displayName = 'StyleEditor';
|
||||||
|
|
||||||
|
export default memo(CodeEditor);
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.editorActions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
align-self: end;
|
||||||
|
text-align: right
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { lazy, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './StyleEditorModal.module.scss';
|
||||||
|
|
||||||
|
const CodeEditor = lazy(() => import('./StyleEditor'));
|
||||||
|
|
||||||
|
interface CodeEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CSSRef {
|
||||||
|
getCss: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeEditorModal(props: CodeEditorModalProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
const [css, setCSS] = useState('');
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const cssRef = useRef<CSSRef>(null);
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
try {
|
||||||
|
setResetLoading(true);
|
||||||
|
const defaultCss = await restoreCSSContents();
|
||||||
|
setCSS(defaultCss);
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaveLoading(true);
|
||||||
|
if (cssRef.current) {
|
||||||
|
await postCSSContents(cssRef.current.getCss());
|
||||||
|
setCSS(cssRef.current.getCss());
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => setCSS('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchServerCSS() {
|
||||||
|
// check for isOpen to fetch recent css
|
||||||
|
if (isOpen) {
|
||||||
|
try {
|
||||||
|
const css = await getCSSContents();
|
||||||
|
setCSS(css);
|
||||||
|
} catch (_error) {
|
||||||
|
setError('Failed to load CSS from server');
|
||||||
|
/** no error handling for now */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchServerCSS();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxWidth='max(800px, 40vw)'>
|
||||||
|
<ModalHeader>Edit CSS override</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter className={style.column}>
|
||||||
|
<Info>Invalid CSS will be refused by the browser</Info>
|
||||||
|
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||||
|
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||||
|
<Button
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
onClick={handleRestore}
|
||||||
|
isDisabled={saveLoading || resetLoading}
|
||||||
|
isLoading={resetLoading}
|
||||||
|
>
|
||||||
|
Reset to example
|
||||||
|
</Button>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Button variant='ontime-ghosted' onClick={clear}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
onClick={handleSave}
|
||||||
|
isDisabled={saveLoading || resetLoading || !isDirty}
|
||||||
|
isLoading={saveLoading}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { Button, Input, Switch } from '@chakra-ui/react';
|
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
|||||||
import { isOntimeCloud } from '../../../../externals';
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import CodeEditorModal from './StyleEditorModal';
|
||||||
|
|
||||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||||
|
|
||||||
export default function ViewSettingsForm() {
|
export default function ViewSettingsForm() {
|
||||||
const { data, status, refetch } = useViewSettings();
|
const { data, status, refetch } = useViewSettings();
|
||||||
const { data: info, status: infoStatus } = useInfo();
|
const { data: info, status: infoStatus } = useInfo();
|
||||||
|
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<Panel.Loader isLoading={isLoading} />
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Override CSS styles'
|
title='Override CSS styles'
|
||||||
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
|
|||||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onCodeEditorOpen}
|
||||||
|
variant='ontime-subtle'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={!data.overrideStyles}
|
||||||
|
width='fit-content'
|
||||||
|
>
|
||||||
|
Edit CSS override
|
||||||
|
</Button>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
declare module 'prismjs/components/prism-core' {
|
||||||
|
export * from 'prismjs';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'prismjs/components/prism-css';
|
||||||
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||||
|
setError(''); // to clear previous error
|
||||||
if (importFlow === 'excel') {
|
if (importFlow === 'excel') {
|
||||||
try {
|
try {
|
||||||
const previewData = await importRundownPreviewExcel(importMap);
|
const previewData = await importRundownPreviewExcel(importMap);
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||||
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
|
setValue,
|
||||||
formState: { errors, isValid },
|
formState: { errors, isValid },
|
||||||
} = useForm<NamedImportMap>({
|
} = useForm<NamedImportMap>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||||
|
|
||||||
|
// Set first sheet as default worksheet when 'event schedule' sheet is not there
|
||||||
|
useEffect(() => {
|
||||||
|
if (!worksheetNames || worksheetNames.length === 0) return;
|
||||||
|
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
|
||||||
|
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
|
||||||
|
}
|
||||||
|
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
|
||||||
|
|
||||||
const handleExport = async (values: NamedImportMap) => {
|
const handleExport = async (values: NamedImportMap) => {
|
||||||
setLoading('export');
|
setLoading('export');
|
||||||
const importMap = convertToImportMap(values);
|
const importMap = convertToImportMap(values);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
|
||||||
import { Input } from '@chakra-ui/react';
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
import style from './InputRow.module.scss';
|
import style from './InputRow.module.scss';
|
||||||
|
|
||||||
@@ -12,50 +10,53 @@ interface InputRowProps {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
actionHandler: () => void;
|
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
const { label, placeholder, text, visible, changeHandler, children } = props;
|
||||||
|
|
||||||
|
const [value, setValue] = useState(text);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const cursorPositionRef = useRef(0);
|
const cursorPositionRef = useRef(0);
|
||||||
|
|
||||||
// sync cursor position with text
|
// sync cursor position with text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current && inputRef.current !== document.activeElement) {
|
||||||
inputRef.current.selectionStart = cursorPositionRef.current;
|
inputRef.current.selectionStart = cursorPositionRef.current;
|
||||||
inputRef.current.selectionEnd = cursorPositionRef.current;
|
inputRef.current.selectionEnd = cursorPositionRef.current;
|
||||||
}
|
}
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
|
// synchronise external text
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current !== document.activeElement) {
|
||||||
|
setValue(text);
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
||||||
|
setValue(event.target.value);
|
||||||
changeHandler(event.target.value);
|
changeHandler(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inputRow}>
|
<div className={style.inputRow}>
|
||||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
<div className={style.inputItems}>
|
<div className={style.inputItems}>
|
||||||
<Input
|
<Input
|
||||||
|
id={label}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
value={text}
|
value={value}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
{children}
|
||||||
clickHandler={actionHandler}
|
|
||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
|
||||||
aria-label={`Toggle ${label}`}
|
|
||||||
openDelay={tooltipDelayMid}
|
|
||||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
size='sm'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||||
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import InputRow from './InputRow';
|
import InputRow from './InputRow';
|
||||||
import TimerControlsPreview from './TimerViewControl';
|
import TimerControlsPreview from './TimerViewControl';
|
||||||
@@ -23,8 +27,17 @@ function TimerMessageInput() {
|
|||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
>
|
||||||
/>
|
<TooltipActionBtn
|
||||||
|
clickHandler={() => setMessage.timerVisible(!visible)}
|
||||||
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
|
aria-label='Toggle timer message visibility'
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
</InputRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +59,16 @@ function ExternalInput() {
|
|||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||||
actionHandler={toggleExternal}
|
>
|
||||||
/>
|
<TooltipActionBtn
|
||||||
|
clickHandler={toggleExternal}
|
||||||
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
|
aria-label='Toggle external message visibility'
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
</InputRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ function RuntimeOverview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
|
||||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
daySpan?: number;
|
daySpan?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<span className={style.label}>{label}</span>
|
<span className={style.label}>{label}</span>
|
||||||
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||||
if (!isPast) {
|
if (!isPast) {
|
||||||
totalGap += entry.gap;
|
totalGap += entry.gap;
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
|
||||||
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
|
||||||
}
|
}
|
||||||
if (isNewLatest(entry, lastEvent)) {
|
if (isNewLatest(entry, lastEvent)) {
|
||||||
// populate previous entry
|
// populate previous entry
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { CSSProperties, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
||||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
import EventEditorImage from './composite/EventEditorImage';
|
import EventCustom from './composite/EventEditorCustom';
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
import EventTextArea from './composite/EventTextArea';
|
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||||
import EventTextInput from './composite/EventTextInput';
|
|
||||||
import EventEditorEmpty from './EventEditorEmpty';
|
import EventEditorEmpty from './EventEditorEmpty';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<Editor.Title>
|
<Editor.Title>
|
||||||
Custom Fields
|
Custom Fields
|
||||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
|
||||||
</Editor.Title>
|
</Editor.Title>
|
||||||
|
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
|
||||||
{Object.keys(customFields).map((fieldKey) => {
|
</div>
|
||||||
const key = `${event.id}-${fieldKey}`;
|
<div className={style.column}>
|
||||||
const fieldName = `custom-${fieldKey}`;
|
<Editor.Title>
|
||||||
const initialValue = event.custom[fieldKey] ?? '';
|
Automations
|
||||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
|
||||||
const labelText = customFields[fieldKey].label;
|
</Editor.Title>
|
||||||
|
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||||
if (customFields[fieldKey].type === 'string') {
|
|
||||||
return (
|
|
||||||
<EventTextArea
|
|
||||||
key={key}
|
|
||||||
field={fieldName}
|
|
||||||
label={labelText}
|
|
||||||
initialValue={initialValue}
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
className={style.decorated}
|
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customFields[fieldKey].type === 'image') {
|
|
||||||
return (
|
|
||||||
<div key={key} className={style.customImage}>
|
|
||||||
<EventTextInput
|
|
||||||
key={key}
|
|
||||||
field={fieldName}
|
|
||||||
label={labelText}
|
|
||||||
initialValue={initialValue}
|
|
||||||
placeholder='Paste image URL'
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
className={style.decorated}
|
|
||||||
maxLength={255}
|
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
<EventEditorImage src={initialValue} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we should have exhausted all types by now
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { CSSProperties, Fragment } from 'react';
|
||||||
|
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
|
import EventEditorImage from './EventEditorImage';
|
||||||
|
import EventTextArea from './EventTextArea';
|
||||||
|
import EventTextInput from './EventTextInput';
|
||||||
|
|
||||||
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorCustomProps {
|
||||||
|
fields: CustomFields;
|
||||||
|
event: OntimeEvent;
|
||||||
|
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorCustom(props: EventEditorCustomProps) {
|
||||||
|
const { fields: customFields, handleSubmit, event } = props;
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{Object.keys(customFields).map((fieldKey) => {
|
||||||
|
const key = `${event.id}-${fieldKey}`;
|
||||||
|
const fieldName = `custom-${fieldKey}`;
|
||||||
|
const initialValue = event.custom[fieldKey] ?? '';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||||
|
const labelText = customFields[fieldKey].label;
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'string') {
|
||||||
|
return (
|
||||||
|
<EventTextArea
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'image') {
|
||||||
|
return (
|
||||||
|
<div key={key} className={style.customImage}>
|
||||||
|
<EventTextInput
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
maxLength={255}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<EventEditorImage src={initialValue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we should have exhausted all types by now
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
.triggerForm {
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background-color: $white-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorLabel {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: $green-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Fragment, useCallback, useState } from 'react';
|
||||||
|
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||||
|
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
|
||||||
|
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
|
||||||
|
import { generateId } from 'ontime-utils';
|
||||||
|
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||||
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
|
|
||||||
|
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||||
|
|
||||||
|
import style from './EventEditorTriggers.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorTriggersProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers?: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
||||||
|
const { triggers, eventId } = props;
|
||||||
|
const showTriggers = triggers !== undefined && triggers.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
|
||||||
|
<EventTriggerForm triggers={triggers} eventId={eventId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventTriggerFormProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers?: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventTriggerForm(props: EventTriggerFormProps) {
|
||||||
|
const { eventId, triggers } = props;
|
||||||
|
const { data: automationSettings } = useAutomationSettings();
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||||
|
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||||
|
|
||||||
|
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||||
|
const newTriggers = triggers ?? new Array<Trigger>();
|
||||||
|
const id = generateId();
|
||||||
|
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||||
|
updateEvent({ id: eventId, triggers: newTriggers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||||
|
if (automationId === undefined) {
|
||||||
|
return 'Select an automation';
|
||||||
|
}
|
||||||
|
if (!Object.keys(automationSettings.automations).includes(automationId)) {
|
||||||
|
return 'This automation does not exist';
|
||||||
|
}
|
||||||
|
if (triggers === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||||
|
? 'Automation can only be used once'
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationError = getValidationError(cycleValue, automationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.triggerForm}>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
variant='ontime'
|
||||||
|
value={cycleValue}
|
||||||
|
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
|
||||||
|
defaultValue={TimerLifeCycle.onStart}
|
||||||
|
>
|
||||||
|
<option disabled>Lifecycle Trigger</option>
|
||||||
|
{eventTriggerOptions.map((cycle) => (
|
||||||
|
<option key={cycle} value={cycle}>
|
||||||
|
{cycle}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
variant='ontime'
|
||||||
|
value={automationId}
|
||||||
|
defaultValue='«invalid»'
|
||||||
|
onChange={(e) => setAutomationId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option disabled value='«invalid»'>
|
||||||
|
Automation
|
||||||
|
</option>
|
||||||
|
{Object.values(automationSettings.automations).map(({ id, title }) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={validationError !== undefined}
|
||||||
|
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
{validationError !== undefined ? (
|
||||||
|
<Tooltip label={validationError} shouldWrapChildren>
|
||||||
|
<IoAlertCircle className={style.errorLabel} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<IoCheckmarkCircle className={style.success} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExistingEventTriggersProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
|
||||||
|
const { eventId, triggers } = props;
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
const { data: automationSettings } = useAutomationSettings();
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(triggerId: string) => {
|
||||||
|
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||||
|
updateEvent({ id: eventId, triggers: newTriggers });
|
||||||
|
},
|
||||||
|
[eventId, triggers, updateEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||||
|
|
||||||
|
// sort triggers out into groups by the Lifecycle they are on
|
||||||
|
timerLifecycleValues.forEach((triggerType) => {
|
||||||
|
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||||
|
if (thisTriggerType.length) {
|
||||||
|
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||||
|
<Fragment key={triggerLifeCycle}>
|
||||||
|
{triggerGroup.map((trigger) => {
|
||||||
|
const { id, automationId } = trigger;
|
||||||
|
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||||
|
return (
|
||||||
|
<div key={id} className={style.trigger}>
|
||||||
|
<Tag>{triggerLifeCycle}</Tag>
|
||||||
|
<Tag>{automationTitle}</Tag>
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
icon={<IoTrash />}
|
||||||
|
aria-label='Delete entry'
|
||||||
|
onClick={() => handleDelete(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
|
export const eventTriggerOptions: TimerLifeCycle[] = [
|
||||||
|
TimerLifeCycle.onLoad,
|
||||||
|
TimerLifeCycle.onStart,
|
||||||
|
TimerLifeCycle.onPause,
|
||||||
|
TimerLifeCycle.onFinish,
|
||||||
|
TimerLifeCycle.onWarning,
|
||||||
|
TimerLifeCycle.onDanger,
|
||||||
|
];
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||||
|
import { OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
|
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||||
|
|
||||||
import RundownMenu from './RundownMenu';
|
import RundownMenu from './RundownMenu';
|
||||||
@@ -12,6 +14,8 @@ export default function RundownHeader() {
|
|||||||
const setRunMode = () => setAppMode(AppMode.Run);
|
const setRunMode = () => setAppMode(AppMode.Run);
|
||||||
const setEditMode = () => setAppMode(AppMode.Edit);
|
const setEditMode = () => setAppMode(AppMode.Edit);
|
||||||
|
|
||||||
|
const { offsetMode } = useOffsetMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<ButtonGroup isAttached>
|
<ButtonGroup isAttached>
|
||||||
@@ -22,6 +26,22 @@ export default function RundownHeader() {
|
|||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
<ButtonGroup isAttached>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => setOffsetMode(OffsetMode.Absolute)}
|
||||||
|
>
|
||||||
|
Absolute
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => setOffsetMode(OffsetMode.Relative)}
|
||||||
|
>
|
||||||
|
Relative
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
<RundownMenu />
|
<RundownMenu />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,57 +9,9 @@
|
|||||||
top: 75vh;
|
top: 75vh;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
|
|
||||||
animation-fill-mode: both;
|
|
||||||
animation-timing-function: ease-out;
|
|
||||||
|
|
||||||
&--pre {
|
|
||||||
animation-name: in;
|
|
||||||
animation-play-state: paused;
|
|
||||||
|
|
||||||
.data-top {
|
|
||||||
animation-play-state: paused;
|
|
||||||
animation-name: top-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-lower {
|
|
||||||
animation-play-state: paused;
|
|
||||||
animation-name: bottom-in;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--in {
|
|
||||||
animation-name: in;
|
|
||||||
animation-play-state: running;
|
|
||||||
|
|
||||||
.data-top {
|
|
||||||
animation-play-state: running;
|
|
||||||
animation-name: top-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-bottom {
|
|
||||||
animation-play-state: running;
|
|
||||||
animation-name: bottom-in;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--out {
|
|
||||||
animation-name: out;
|
|
||||||
animation-play-state: running;
|
|
||||||
|
|
||||||
.data-top {
|
|
||||||
animation-play-state: running;
|
|
||||||
animation-name: top-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-bottom {
|
|
||||||
animation-play-state: running;
|
|
||||||
animation-name: bottom-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--bottomThird-line-height-override, 0.5vh);
|
height: var(--lowerThird-line-height-override, 0.5vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip {
|
.clip {
|
||||||
@@ -70,10 +22,10 @@
|
|||||||
.data-top {
|
.data-top {
|
||||||
padding: 0 3vw;
|
padding: 0 3vw;
|
||||||
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
||||||
text-align: var(--lowerThird-text-align-override, left);
|
text-align: var(--lowerThird-text-align-override, right);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@include ellipsis-text();
|
@include ellipsis-text();
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '\200b';
|
content: '\200b';
|
||||||
}
|
}
|
||||||
@@ -85,72 +37,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-bottom {
|
.data-bottom {
|
||||||
font-weight: var(--lowerThird-bottom-font-weight-override, normal);
|
font-weight: var(--lowerThird-bottom-font-weight-override, 540);
|
||||||
font-style: var(--lowerThird-bottom-font-style-override, normal);
|
font-style: var(--lowerThird-bottom-font-style-override, normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
transition-timing-function: cubic-bezier(0.25, 0.5, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
transition-timing-function: cubic-bezier(0.5, 0, 0.75, 0.5);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
.data-top {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
.data-bottom {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes in {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0%;
|
|
||||||
}
|
|
||||||
50%,
|
|
||||||
100% {
|
|
||||||
transform: translateX(0%);
|
|
||||||
opacity: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes out {
|
|
||||||
0%,
|
|
||||||
30% {
|
|
||||||
transform: translateX(0%);
|
|
||||||
opacity: 100%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes top-in {
|
|
||||||
0%,
|
|
||||||
50% {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes top-out {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
50%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bottom-in {
|
|
||||||
0%,
|
|
||||||
50% {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bottom-out {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
50%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,204 +1,123 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||||
|
import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
|
|
||||||
import { getLowerThirdOptions } from './lowerThird.options';
|
import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options';
|
||||||
|
|
||||||
import './LowerThird.scss';
|
import './LowerThird.scss';
|
||||||
|
|
||||||
type LowerOptions = {
|
|
||||||
width: number;
|
|
||||||
topSrc: string;
|
|
||||||
bottomSrc: string;
|
|
||||||
topColour: string;
|
|
||||||
bottomColour: string;
|
|
||||||
topBg: string;
|
|
||||||
bottomBg: string;
|
|
||||||
topSize: string;
|
|
||||||
bottomSize: string;
|
|
||||||
transition: number;
|
|
||||||
delay: number;
|
|
||||||
key: string;
|
|
||||||
lineColour: string;
|
|
||||||
lineHeight: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LowerProps {
|
interface LowerProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: Readonly<LowerOptions> = {
|
|
||||||
width: 45,
|
|
||||||
topSrc: 'title',
|
|
||||||
bottomSrc: 'lowerMsg',
|
|
||||||
topColour: '000000',
|
|
||||||
bottomColour: '000000',
|
|
||||||
topBg: 'FFF0',
|
|
||||||
bottomBg: 'FFF0',
|
|
||||||
topSize: '65px',
|
|
||||||
bottomSize: '40px',
|
|
||||||
transition: 3,
|
|
||||||
delay: 3,
|
|
||||||
key: 'FFF0',
|
|
||||||
lineColour: 'FF0000',
|
|
||||||
lineHeight: '0.4em',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LowerThird(props: LowerProps) {
|
export default function LowerThird(props: LowerProps) {
|
||||||
const { customFields, eventNow, viewSettings } = props;
|
const { customFields, eventNow, viewSettings, time } = props;
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const previousId = useRef<string>();
|
const previousId = useRef<string>();
|
||||||
const animationTimeout = useRef<NodeJS.Timeout>();
|
const animationTimeout = useRef<NodeJS.Timeout>();
|
||||||
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
|
const [playState, setPlayState] = useState<boolean>(false);
|
||||||
|
const [textValue, setTextValue] = useState<{ top: string; bottom: string }>({ top: '', bottom: '' });
|
||||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
|
const options = useLowerOptions();
|
||||||
|
const { playback } = time;
|
||||||
|
|
||||||
useWindowTitle('Lower Third');
|
useWindowTitle('Lower Third');
|
||||||
|
|
||||||
const options = useMemo(() => {
|
|
||||||
const newOptions = { ...defaultOptions };
|
|
||||||
|
|
||||||
const width = searchParams.get('width');
|
|
||||||
if (width !== null) {
|
|
||||||
newOptions.width = Number(width);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topSrc = searchParams.get('top-src');
|
|
||||||
if (topSrc) {
|
|
||||||
newOptions.topSrc = topSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottomSrc = searchParams.get('bottom-src');
|
|
||||||
if (bottomSrc) {
|
|
||||||
newOptions.bottomSrc = bottomSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topColour = searchParams.get('top-colour');
|
|
||||||
if (topColour !== null) {
|
|
||||||
newOptions.topColour = topColour;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottomColour = searchParams.get('bottom-colour');
|
|
||||||
if (bottomColour !== null) {
|
|
||||||
newOptions.bottomColour = bottomColour;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topBg = searchParams.get('top-bg');
|
|
||||||
if (topBg !== null) {
|
|
||||||
newOptions.topBg = topBg;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottomBg = searchParams.get('bottom-bg');
|
|
||||||
if (bottomBg !== null) {
|
|
||||||
newOptions.bottomBg = bottomBg;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topSize = searchParams.get('top-size');
|
|
||||||
if (topSize !== null) {
|
|
||||||
newOptions.topSize = topSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottomSize = searchParams.get('bottom-size');
|
|
||||||
if (bottomSize && bottomSize != newOptions.bottomSize) {
|
|
||||||
newOptions.bottomSize = bottomSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transition = searchParams.get('transition');
|
|
||||||
if (transition !== null) {
|
|
||||||
newOptions.transition = Number(transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = searchParams.get('delay');
|
|
||||||
if (delay !== null) {
|
|
||||||
newOptions.delay = Number(delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = searchParams.get('key');
|
|
||||||
if (key !== null) {
|
|
||||||
newOptions.key = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineColour = searchParams.get('line-colour');
|
|
||||||
if (lineColour !== null) {
|
|
||||||
newOptions.lineColour = lineColour;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOptions;
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// on unmount, cancel any ongoing animations
|
// on unmount, cancel any ongoing animations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
// if hold is negative then force animate in
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (options.hold < 0) {
|
||||||
clearTimeout(animationTimeout.current);
|
clearTimeout(animationTimeout.current);
|
||||||
};
|
setTextValue({
|
||||||
}, []);
|
top: getPropertyValue(eventNow, options.topSrc) ?? '',
|
||||||
|
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
|
||||||
|
});
|
||||||
|
setPlayState(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [eventNow, options.bottomSrc, options.hold, options.topSrc]);
|
||||||
|
|
||||||
|
const animateIn = useCallback(() => {
|
||||||
|
// if hold skip
|
||||||
|
if (options.hold < 0) return;
|
||||||
|
|
||||||
|
//clear any pending timeouts
|
||||||
|
clearTimeout(animationTimeout.current);
|
||||||
|
// set the values
|
||||||
|
setTextValue({
|
||||||
|
top: getPropertyValue(eventNow, options.topSrc) ?? '',
|
||||||
|
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
|
||||||
|
});
|
||||||
|
// start animation
|
||||||
|
setPlayState(true);
|
||||||
|
// reschedule out animation, should animate out after the in animation time + hold time
|
||||||
|
setTimeout(() => setPlayState(false), (options.hold + options.transitionIn) * MILLIS_PER_SECOND);
|
||||||
|
}, [eventNow, options.bottomSrc, options.hold, options.topSrc, options.transitionIn]);
|
||||||
|
|
||||||
|
const animateOut = useCallback(() => {
|
||||||
|
if (options.hold < 0) return; // if hold is negative then we never animate out
|
||||||
|
//clear any pending timeouts
|
||||||
|
clearTimeout(animationTimeout.current);
|
||||||
|
// start animation
|
||||||
|
setPlayState(false);
|
||||||
|
}, [options.hold]);
|
||||||
|
|
||||||
|
// check if playback has changed and schedule animations
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaybackActive(playback)) {
|
||||||
|
animateIn();
|
||||||
|
} else {
|
||||||
|
animateOut();
|
||||||
|
}
|
||||||
|
}, [animateIn, animateOut, playback]);
|
||||||
|
|
||||||
// check if data has changed and schedule animations
|
// check if data has changed and schedule animations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasChanged = eventNow?.id !== previousId.current;
|
const hasChanged = eventNow?.id !== previousId.current;
|
||||||
if (!hasChanged) {
|
if (hasChanged) {
|
||||||
return;
|
previousId.current = eventNow?.id;
|
||||||
|
if (eventNow?.id) animateIn();
|
||||||
}
|
}
|
||||||
|
}, [animateIn, eventNow?.id]);
|
||||||
|
|
||||||
previousId.current = eventNow?.id;
|
const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
|
||||||
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
|
const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`;
|
||||||
|
|
||||||
const reschedule = (newState: 'pre' | 'in' | 'out') => {
|
const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
|
||||||
clearTimeout(animationTimeout.current);
|
const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s';
|
||||||
animationTimeout.current = setTimeout(() => setPlayState(newState), animateOutInMs);
|
|
||||||
};
|
|
||||||
if (eventNow?.id == null) {
|
|
||||||
setPlayState('out');
|
|
||||||
reschedule('pre');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventNow.id && !previousId.current) {
|
|
||||||
setPlayState('in');
|
|
||||||
reschedule('out');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playState === 'in') {
|
|
||||||
// event has changed, we just reschedule the timeout
|
|
||||||
reschedule('out');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPlayState('in');
|
|
||||||
reschedule('out');
|
|
||||||
}, [eventNow?.id, options.delay, options.transition, playState, previousId]);
|
|
||||||
|
|
||||||
const topText = getPropertyValue(eventNow, options.topSrc) ?? '';
|
|
||||||
const bottomText = getPropertyValue(eventNow, options.bottomSrc) ?? '';
|
|
||||||
|
|
||||||
const transition = `${options.transition}s`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
||||||
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
|
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
|
||||||
<div
|
<div
|
||||||
className={`container container--${playState}`}
|
className={`container ${playState ? 'container--in' : 'container--out'}`}
|
||||||
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
|
style={{
|
||||||
|
minWidth: `${options.width}vw`,
|
||||||
|
transitionDuration: boxDuration,
|
||||||
|
transitionDelay: boxDelay,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className='clip'>
|
<div className='clip'>
|
||||||
<div
|
<div
|
||||||
className='data-top'
|
className='data-top'
|
||||||
style={{
|
style={{
|
||||||
animationDuration: transition,
|
transitionDuration: textDuration,
|
||||||
|
transitionDelay: textDelay,
|
||||||
color: `#${options.topColour}`,
|
color: `#${options.topColour}`,
|
||||||
backgroundColor: `#${options.topBg}`,
|
backgroundColor: `#${options.topBg}`,
|
||||||
fontSize: options.topSize,
|
fontSize: `${options.topSize}em`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{topText}
|
{textValue.top}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -211,13 +130,14 @@ export default function LowerThird(props: LowerProps) {
|
|||||||
<div
|
<div
|
||||||
className='data-bottom'
|
className='data-bottom'
|
||||||
style={{
|
style={{
|
||||||
animationDuration: transition,
|
transitionDuration: textDuration,
|
||||||
|
transitionDelay: textDelay,
|
||||||
color: `#${options.bottomColour}`,
|
color: `#${options.bottomColour}`,
|
||||||
backgroundColor: `#${options.bottomBg}`,
|
backgroundColor: `#${options.bottomBg}`,
|
||||||
fontSize: options.bottomSize,
|
fontSize: `${options.bottomSize}em`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{bottomText}
|
{textValue.bottom}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
import safeParseNumber from '../../../common/utils/safeParseNumber';
|
||||||
|
|
||||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||||
@@ -44,18 +47,32 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
|||||||
collapsible: true,
|
collapsible: true,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
id: 'transition',
|
id: 'transition-in',
|
||||||
title: 'Transition',
|
title: 'Transition In',
|
||||||
description: 'Transition in time in seconds (default 3)',
|
description: 'Transition in time (default 3 seconds)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transition-out',
|
||||||
|
title: 'Transition Out',
|
||||||
|
description: 'Transition out time (default 3 seconds)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hold',
|
||||||
|
title: 'Hold',
|
||||||
|
description: 'Time on screen before transition out. Set to -1 to stop transition (default 3 seconds) ',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
placeholder: '3 (default)',
|
placeholder: '3 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delay',
|
id: 'delay',
|
||||||
title: 'Delay',
|
title: 'Delay',
|
||||||
description: 'Delay between transition in and out in seconds (default 3)',
|
description: 'Delay between trigger and transition in (default 0 seconds)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
placeholder: '3 (default)',
|
placeholder: '0 (default)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -69,14 +86,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
|||||||
title: 'Top Text Size',
|
title: 'Top Text Size',
|
||||||
description: 'Font size of the top text',
|
description: 'Font size of the top text',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: '65px',
|
placeholder: '5em',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bottom-size',
|
id: 'bottom-size',
|
||||||
title: 'Bottom Text Size',
|
title: 'Bottom Text Size',
|
||||||
description: 'Font size of the bottom text',
|
description: 'Font size of the bottom text',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: '64px',
|
placeholder: '4em',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'width',
|
id: 'width',
|
||||||
@@ -132,3 +149,73 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LowerOptions = {
|
||||||
|
width: number;
|
||||||
|
topSrc: string;
|
||||||
|
bottomSrc: string;
|
||||||
|
topColour: string;
|
||||||
|
bottomColour: string;
|
||||||
|
topBg: string;
|
||||||
|
bottomBg: string;
|
||||||
|
topSize: number;
|
||||||
|
bottomSize: number;
|
||||||
|
transitionIn: number;
|
||||||
|
transitionOut: number;
|
||||||
|
hold: number;
|
||||||
|
delay: number;
|
||||||
|
key: string;
|
||||||
|
lineColour: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOptions: Readonly<LowerOptions> = {
|
||||||
|
width: 45,
|
||||||
|
topSrc: 'title',
|
||||||
|
bottomSrc: 'lowerMsg',
|
||||||
|
topColour: '000000',
|
||||||
|
bottomColour: '000000',
|
||||||
|
topBg: 'FFF0',
|
||||||
|
bottomBg: 'FFF0',
|
||||||
|
topSize: 5,
|
||||||
|
bottomSize: 4,
|
||||||
|
transitionIn: 3,
|
||||||
|
transitionOut: 3,
|
||||||
|
hold: 3,
|
||||||
|
delay: 0,
|
||||||
|
key: 'FFF0',
|
||||||
|
lineColour: 'FF0000',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility extract the view options from URL Params
|
||||||
|
* the names and fallbacks are manually matched with defaultOptions
|
||||||
|
*/
|
||||||
|
function getOptionsFromParams(searchParams: URLSearchParams): LowerOptions {
|
||||||
|
// we manually make an object that matches the key above
|
||||||
|
return {
|
||||||
|
width: safeParseNumber(searchParams.get('width'), defaultOptions.width),
|
||||||
|
topSrc: searchParams.get('top-src') ?? defaultOptions.topSrc,
|
||||||
|
bottomSrc: searchParams.get('bottom-src') ?? defaultOptions.bottomSrc,
|
||||||
|
topColour: searchParams.get('top-colour') ?? defaultOptions.topColour,
|
||||||
|
bottomColour: searchParams.get('bottom-colour') ?? defaultOptions.bottomColour,
|
||||||
|
topBg: searchParams.get('top-bg') ?? defaultOptions.topBg,
|
||||||
|
bottomBg: searchParams.get('bottom-bg') ?? defaultOptions.bottomBg,
|
||||||
|
topSize: safeParseNumber(searchParams.get('top-size'), defaultOptions.topSize),
|
||||||
|
bottomSize: safeParseNumber(searchParams.get('bottom-size'), defaultOptions.bottomSize),
|
||||||
|
transitionIn: safeParseNumber(searchParams.get('transition-in'), defaultOptions.transitionIn),
|
||||||
|
transitionOut: safeParseNumber(searchParams.get('transition-out'), defaultOptions.transitionOut),
|
||||||
|
hold: safeParseNumber(searchParams.get('hold'), defaultOptions.hold),
|
||||||
|
delay: safeParseNumber(searchParams.get('hold'), defaultOptions.delay),
|
||||||
|
key: searchParams.get('key') ?? defaultOptions.key,
|
||||||
|
lineColour: searchParams.get('line-colour') ?? defaultOptions.lineColour,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook exposes the timer view options
|
||||||
|
*/
|
||||||
|
export function useLowerOptions(): LowerOptions {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ $orange-active: #f60;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.studio-timer {
|
.studio-timer {
|
||||||
font-size: calc(var(--clock-size) / 5);
|
font-size: calc(var(--clock-size) / 6);
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
|
|
||||||
color: var(--studio-active, $red-active);
|
color: var(--studio-active, $red-active);
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export const ScheduleProvider = ({
|
|||||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||||
|
|
||||||
// we want to show the event after the current
|
// we want to show the event after the current
|
||||||
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
|
const viewEvents = events.slice(selectedEventIndex + 1);
|
||||||
selectedEventIndex = 0;
|
selectedEventIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
|||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
||||||
<CuesheetHeader headerGroups={headerGroups} />
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
|
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<CuesheetTableMenu showModal={showModal} />
|
<CuesheetTableMenu showModal={showModal} />
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ function BlockRow(props: BlockRowProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddingRows = new Array(columnCount - 1).fill(null);
|
// guard the use case where user has hidden all columns
|
||||||
|
const fillColumns = Math.min(columnCount, 1);
|
||||||
|
|
||||||
|
const paddingRows = new Array(fillColumns).fill(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.blockRow}>
|
<tr className={style.blockRow}>
|
||||||
|
|||||||
+14
-5
@@ -16,16 +16,24 @@ interface CuesheetBodyProps {
|
|||||||
rowModel: RowModel<OntimeRundownEntry>;
|
rowModel: RowModel<OntimeRundownEntry>;
|
||||||
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
|
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
|
||||||
table: Table<OntimeRundownEntry>;
|
table: Table<OntimeRundownEntry>;
|
||||||
columnSizing: Record<string, number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetBody(props: CuesheetBodyProps) {
|
export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||||
const { rowModel, selectedRef, table, columnSizing } = props;
|
const { rowModel, selectedRef, table } = props;
|
||||||
|
|
||||||
const { selectedEventId } = useSelectedEventId();
|
const { selectedEventId } = useSelectedEventId();
|
||||||
const { hideDelays, hidePast } = useCuesheetOptions();
|
const { hideDelays, hidePast } = useCuesheetOptions();
|
||||||
|
|
||||||
const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length);
|
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||||
|
const getColumnHash = lazyEvaluate(() => {
|
||||||
|
let columnHash = '';
|
||||||
|
const columns = getVisibleColumns();
|
||||||
|
|
||||||
|
for (let i = 0; i < columns.length; i++) {
|
||||||
|
columnHash += `${columns[i].getIndex()}-${columns[i].getSize()} `;
|
||||||
|
}
|
||||||
|
return columnHash;
|
||||||
|
});
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// for the first event, it will be past if there is something selected
|
// for the first event, it will be past if there is something selected
|
||||||
@@ -41,7 +49,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
if (isOntimeBlock(entry)) {
|
||||||
const columnCount = getColumnCount();
|
const columnCount = getVisibleColumns().length;
|
||||||
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||||
}
|
}
|
||||||
if (isOntimeDelay(entry)) {
|
if (isOntimeDelay(entry)) {
|
||||||
@@ -58,6 +66,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
|||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
const isSelected = key === selectedEventId;
|
const isSelected = key === selectedEventId;
|
||||||
|
const columnHash = getColumnHash();
|
||||||
|
|
||||||
if (isPast && hidePast) {
|
if (isPast && hidePast) {
|
||||||
return null;
|
return null;
|
||||||
@@ -87,7 +96,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
|||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
rowBgColour={rowBgColour}
|
rowBgColour={rowBgColour}
|
||||||
table={table}
|
table={table}
|
||||||
columnSizing={columnSizing}
|
columnHash={columnHash}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface EventRowProps {
|
|||||||
rowBgColour?: string;
|
rowBgColour?: string;
|
||||||
table: Table<OntimeRundownEntry>;
|
table: Table<OntimeRundownEntry>;
|
||||||
/** hack to force re-rendering of the row when the column sizes change */
|
/** hack to force re-rendering of the row when the column sizes change */
|
||||||
columnSizing: Record<string, number>;
|
columnHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EventRow, (prevProps, nextProps) => {
|
export default memo(EventRow, (prevProps, nextProps) => {
|
||||||
@@ -35,8 +35,7 @@ export default memo(EventRow, (prevProps, nextProps) => {
|
|||||||
prevProps.isPast === nextProps.isPast &&
|
prevProps.isPast === nextProps.isPast &&
|
||||||
prevProps.selectedRef === nextProps.selectedRef &&
|
prevProps.selectedRef === nextProps.selectedRef &&
|
||||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
prevProps.rowBgColour === nextProps.rowBgColour &&
|
||||||
prevProps.table === nextProps.table &&
|
prevProps.columnHash === nextProps.columnHash
|
||||||
prevProps.columnSizing === nextProps.columnSizing
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "3.14.3",
|
"version": "3.15.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.14.3",
|
"version": "3.15.2",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
"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:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||||
|
"prebuild": "tsx ./scripts/bundleCss.ts",
|
||||||
"build": "node esbuild.electron.js",
|
"build": "node esbuild.electron.js",
|
||||||
"build:electron": "node esbuild.electron.js",
|
"build:electron": "node esbuild.electron.js",
|
||||||
"build:local": "node esbuild.dev.js",
|
"build:local": "node esbuild.dev.js",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { defaultCss } from '../src/user/styles/bundledCss';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to write contents of bundledCss to override.css
|
||||||
|
*/
|
||||||
|
async function bundleCss() {
|
||||||
|
try {
|
||||||
|
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
|
||||||
|
const cssFile = path.resolve(stylesDir, 'override.css');
|
||||||
|
|
||||||
|
if (!existsSync(cssFile)) {
|
||||||
|
throw new Error('File does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed writing to CSS file: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleCss();
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { readCssFile, writeCssFile } from './assets.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes the contents of the cssOverride.css file
|
||||||
|
*/
|
||||||
|
export async function getCssOverride(_req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const data = await readCssFile();
|
||||||
|
res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows modifying the cssOverride.css file
|
||||||
|
*/
|
||||||
|
export async function postCssOverride(req: Request, res: Response) {
|
||||||
|
const { css } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeCssFile(css);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the default cssOverride.css file
|
||||||
|
*/
|
||||||
|
export async function restoreCss(_req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
await writeCssFile(defaultCss);
|
||||||
|
res.status(200).send(defaultCss);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
|
||||||
|
import { validatePostCss } from './assets.validation.js';
|
||||||
|
|
||||||
|
export const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/css', getCssOverride);
|
||||||
|
router.post('/css', validatePostCss, postCssOverride);
|
||||||
|
router.post('/css/restore', restoreCss);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { publicFiles } from '../../setup/index.js';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the user's css file
|
||||||
|
* @returns css contents in the file
|
||||||
|
*/
|
||||||
|
export async function readCssFile(): Promise<string> {
|
||||||
|
const path = publicFiles.cssOverride;
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
await writeFile(path, defaultCss, { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await readFile(path, { encoding: 'utf8' });
|
||||||
|
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the user's css file
|
||||||
|
* @param css the updated css to write to file
|
||||||
|
*/
|
||||||
|
export async function writeCssFile(css: string) {
|
||||||
|
const path = publicFiles.cssOverride;
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
await writeFile(path, css, { encoding: 'utf8' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(path, css, { encoding: 'utf8' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { body, validationResult } from 'express-validator';
|
||||||
|
|
||||||
|
export const validatePostCss = [
|
||||||
|
body('css').exists().isString().trim(),
|
||||||
|
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -9,12 +9,13 @@ import type {
|
|||||||
import { deleteAtIndex, generateId } from 'ontime-utils';
|
import { deleteAtIndex, generateId } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a copy of the stored automation settings
|
* Gets a copy of the stored automation settings
|
||||||
*/
|
*/
|
||||||
export function getAutomationSettings(): AutomationSettings {
|
export function getAutomationSettings(): AutomationSettings {
|
||||||
return structuredClone(getDataProvider().getAutomation());
|
return getDataProvider().getAutomation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,14 +139,25 @@ export async function deleteAutomation(id: string): Promise<void> {
|
|||||||
if (!Object.hasOwn(automations, id)) {
|
if (!Object.hasOwn(automations, id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// prevent deleting a automation that is in use
|
|
||||||
const triggers = getAutomationTriggers();
|
// prevent deleting a automation that is in use in triggers
|
||||||
for (let i = 0; i < triggers.length; i++) {
|
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id);
|
||||||
const trigger = triggers[i];
|
if (triggers.length) {
|
||||||
if (trigger.automationId === id) {
|
throw new Error(
|
||||||
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
|
`Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prevent deleting a automation that is in use in events
|
||||||
|
const events = getTimedEvents().filter(
|
||||||
|
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
|
||||||
|
);
|
||||||
|
if (events.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
delete automations[id];
|
delete automations[id];
|
||||||
await saveChanges({ automations });
|
await saveChanges({ automations });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import {
|
|||||||
isOntimeAction,
|
isOntimeAction,
|
||||||
isOSCOutput,
|
isOSCOutput,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
|
TimerLifeCycle,
|
||||||
type AutomationFilter,
|
type AutomationFilter,
|
||||||
type AutomationOutput,
|
type AutomationOutput,
|
||||||
type FilterRule,
|
type FilterRule,
|
||||||
type TimerLifeCycle,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { getPropertyFromPath } from 'ontime-utils';
|
import { getPropertyFromPath } from 'ontime-utils';
|
||||||
|
|
||||||
@@ -23,14 +23,21 @@ import { toOntimeAction } from './clients/ontime.client.js';
|
|||||||
/**
|
/**
|
||||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||||
*/
|
*/
|
||||||
export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
|
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||||
if (!getAutomationsEnabled()) {
|
if (!getAutomationsEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggers = getAutomationTriggers();
|
let triggers = getAutomationTriggers();
|
||||||
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
|
|
||||||
if (triggerAutomations.length === 0) {
|
// get triggers from event
|
||||||
|
if (state.eventNow?.triggers) {
|
||||||
|
triggers = triggers.concat(state.eventNow.triggers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: there are no onStop triggers in event
|
||||||
|
const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
|
||||||
|
if (filteredTrigger.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +46,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerAutomations.forEach((trigger) => {
|
filteredTrigger.forEach((trigger) => {
|
||||||
const automation = automations[trigger.automationId];
|
const automation = automations[trigger.automationId];
|
||||||
if (!automation || automation.outputs.length === 0) {
|
if (!automation || automation.outputs.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { router as excelRouter } from './excel/excel.router.js';
|
|||||||
import { router as sessionRouter } from './session/session.router.js';
|
import { router as sessionRouter } from './session/session.router.js';
|
||||||
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
|
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
|
||||||
import { router as reportRouter } from './report/report.router.js';
|
import { router as reportRouter } from './report/report.router.js';
|
||||||
|
import { router as assetsRouter } from './assets/assets.router.js';
|
||||||
|
|
||||||
export const appRouter = express.Router();
|
export const appRouter = express.Router();
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ appRouter.use('/url-presets', urlPresetsRouter);
|
|||||||
appRouter.use('/session', sessionRouter);
|
appRouter.use('/session', sessionRouter);
|
||||||
appRouter.use('/view-settings', viewSettingsRouter);
|
appRouter.use('/view-settings', viewSettingsRouter);
|
||||||
appRouter.use('/report', reportRouter);
|
appRouter.use('/report', reportRouter);
|
||||||
|
appRouter.use('/assets', assetsRouter);
|
||||||
|
|
||||||
//we don't want to redirect to react index when using api routes
|
//we don't want to redirect to react index when using api routes
|
||||||
appRouter.all('/*', (_req, res) => {
|
appRouter.all('/*', (_req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { DeepPartial } from 'ts-essentials';
|
import { DeepPartial } from 'ts-essentials';
|
||||||
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
|
|||||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||||
|
|
||||||
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
||||||
|
import { coerceEnum } from '../utils/coerceType.js';
|
||||||
|
|
||||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||||
let lastRequest: Date | null = null;
|
let lastRequest: Date | null = null;
|
||||||
@@ -286,6 +287,11 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
|
|
||||||
throw new Error('No matching method provided');
|
throw new Error('No matching method provided');
|
||||||
},
|
},
|
||||||
|
offsetmode: (payload) => {
|
||||||
|
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
|
||||||
|
runtimeService.setOffsetMode(mode);
|
||||||
|
return { payload: 'success' };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -124,6 +124,6 @@ function setSessionCookie(res: Response, token: string) {
|
|||||||
httpOnly: false, // allow websocket to access cookie
|
httpOnly: false, // allow websocket to access cookie
|
||||||
secure: true,
|
secure: true,
|
||||||
path: '/', // allow cookie to be accessed from any path
|
path: '/', // allow cookie to be accessed from any path
|
||||||
sameSite: 'strict',
|
sameSite: 'none', // allow cookies to be sent in cross-origin requests (e.g., iframes)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -740,8 +740,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
},
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(-50);
|
expect(absoluteOffset).toBe(-50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('added time subtracts time offset (positive offset)', () => {
|
it('added time subtracts time offset (positive offset)', () => {
|
||||||
@@ -763,8 +763,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
},
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(-60);
|
expect(absoluteOffset).toBe(-60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('considers running overtime (negative offset)', () => {
|
it('considers running overtime (negative offset)', () => {
|
||||||
@@ -787,8 +787,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
},
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(-10);
|
expect(absoluteOffset).toBe(-10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paused time is delayed time (negative offset)', () => {
|
it('paused time is delayed time (negative offset)', () => {
|
||||||
@@ -812,8 +812,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
},
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(-25);
|
expect(absoluteOffset).toBe(-25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offset doesnt exist if we havent started', () => {
|
it('offset doesnt exist if we havent started', () => {
|
||||||
@@ -850,8 +850,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(0);
|
expect(absoluteOffset).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles loaded event', () => {
|
it('handles loaded event', () => {
|
||||||
@@ -891,8 +891,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(81000000 - 79521653); // clock - timestart
|
expect(absoluteOffset).toBe(81000000 - 79521653); // clock - timestart
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
|
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
|
||||||
@@ -944,8 +944,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(0);
|
expect(absoluteOffset).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with time-to-end, offset is the overtime', () => {
|
it('with time-to-end, offset is the overtime', () => {
|
||||||
@@ -997,8 +997,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(offset).toBe(-400000); // <--- offset is always the overtime
|
expect(absoluteOffset).toBe(-400000); // <--- offset is always the overtime
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles time-to-end started after the end time', () => {
|
it('handles time-to-end started after the end time', () => {
|
||||||
@@ -1040,9 +1040,84 @@ describe('getRuntimeOffset()', () => {
|
|||||||
|
|
||||||
const updateCurrent = getCurrent(state);
|
const updateCurrent = getCurrent(state);
|
||||||
state.timer.current = updateCurrent;
|
state.timer.current = updateCurrent;
|
||||||
const offset = getRuntimeOffset(state);
|
const { absoluteOffset } = getRuntimeOffset(state);
|
||||||
expect(millisToString(offset)).toBe('-00:16:40');
|
expect(millisToString(absoluteOffset)).toBe('-00:16:40');
|
||||||
expect(offset).toBe(81000000 - 82000000); // <-- planned end - now
|
expect(absoluteOffset).toBe(81000000 - 82000000); // <-- planned end - now
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelativeOffset()', () => {
|
||||||
|
it('relative offset is 0 when starting at the planed time', () => {
|
||||||
|
const state = {
|
||||||
|
eventNow: {
|
||||||
|
id: '1',
|
||||||
|
timeStart: 150,
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
startedAt: 150,
|
||||||
|
addedTime: 0,
|
||||||
|
current: 0,
|
||||||
|
},
|
||||||
|
_timer: {
|
||||||
|
pausedAt: null,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
actualStart: 150,
|
||||||
|
plannedStart: 150,
|
||||||
|
},
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
|
||||||
|
expect(absoluteOffset).toBe(0);
|
||||||
|
expect(relativeOffset).toBe(0);
|
||||||
|
});
|
||||||
|
it('relative offset is 0 when starting after the planed time', () => {
|
||||||
|
const state = {
|
||||||
|
eventNow: {
|
||||||
|
id: '1',
|
||||||
|
timeStart: 100,
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
startedAt: 150,
|
||||||
|
addedTime: 0,
|
||||||
|
current: 0,
|
||||||
|
},
|
||||||
|
_timer: {
|
||||||
|
pausedAt: null,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
actualStart: 150,
|
||||||
|
plannedStart: 100,
|
||||||
|
},
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
|
||||||
|
expect(absoluteOffset).toBe(-50);
|
||||||
|
expect(relativeOffset).toBe(0);
|
||||||
|
});
|
||||||
|
it('relative offset is 0 when starting before the planed time', () => {
|
||||||
|
const state = {
|
||||||
|
eventNow: {
|
||||||
|
id: '1',
|
||||||
|
timeStart: 150,
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
startedAt: 100,
|
||||||
|
addedTime: 0,
|
||||||
|
current: 0,
|
||||||
|
},
|
||||||
|
_timer: {
|
||||||
|
pausedAt: null,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 150,
|
||||||
|
},
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
|
||||||
|
expect(absoluteOffset).toBe(50);
|
||||||
|
expect(relativeOffset).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1169,9 +1244,11 @@ describe('getTimerPhase()', () => {
|
|||||||
},
|
},
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
},
|
},
|
||||||
|
_rundown: {
|
||||||
|
totalDelay: 0,
|
||||||
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const phase = getTimerPhase(state);
|
const phase = getTimerPhase(state);
|
||||||
@@ -1208,9 +1285,11 @@ describe('getTimerPhase()', () => {
|
|||||||
},
|
},
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
},
|
},
|
||||||
|
_rundown: {
|
||||||
|
totalDelay: 0,
|
||||||
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const phase = getTimerPhase(state);
|
const phase = getTimerPhase(state);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export async function deleteEvent(eventIds: string[]) {
|
|||||||
const scopedMutation = cache.mutateCache(cache.remove);
|
const scopedMutation = cache.mutateCache(cache.remove);
|
||||||
const { didMutate } = await scopedMutation({ eventIds });
|
const { didMutate } = await scopedMutation({ eventIds });
|
||||||
|
|
||||||
if (didMutate === false) {
|
if (!didMutate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export async function editEvent(patch: PatchWithId) {
|
|||||||
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
|
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
|
||||||
|
|
||||||
// short circuit if nothing changed
|
// short circuit if nothing changed
|
||||||
if (didMutate === false) {
|
if (!didMutate) {
|
||||||
return newEvent;
|
return newEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export enum regenerateWhitelist {
|
|||||||
'timeWarning',
|
'timeWarning',
|
||||||
'timeDanger',
|
'timeDanger',
|
||||||
'custom',
|
'custom',
|
||||||
|
'triggers',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
|
OffsetMode,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
TimerLifeCycle,
|
TimerLifeCycle,
|
||||||
@@ -68,6 +69,11 @@ class RuntimeService {
|
|||||||
RuntimeService.previousState = {} as RuntimeState;
|
RuntimeService.previousState = {} as RuntimeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@broadcastResult
|
||||||
|
setOffsetMode(mode: OffsetMode) {
|
||||||
|
runtimeState.setOffsetMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks result of an update and notifies integrations as needed
|
* Checks result of an update and notifies integrations as needed
|
||||||
* This is the only exception of a private method that has broadcast result
|
* This is the only exception of a private method that has broadcast result
|
||||||
@@ -697,11 +703,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
// for the very fist run there will be nothing in the previousState so we force an update
|
// for the very fist run there will be nothing in the previousState so we force an update
|
||||||
const justStarted = !RuntimeService.previousState?.timer;
|
const justStarted = !RuntimeService.previousState?.timer;
|
||||||
|
|
||||||
|
// offset mode has been changed
|
||||||
|
const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode;
|
||||||
|
|
||||||
// if playback changes most things should update
|
// if playback changes most things should update
|
||||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||||
|
|
||||||
// combine all big changes
|
// combine all big changes
|
||||||
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
|
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timer should be updated if
|
* Timer should be updated if
|
||||||
|
|||||||
@@ -116,10 +116,10 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
|
|||||||
* Positive offset is time ahead
|
* Positive offset is time ahead
|
||||||
* Negative offset is time delayed
|
* Negative offset is time delayed
|
||||||
*/
|
*/
|
||||||
export function getRuntimeOffset(state: RuntimeState): number {
|
export function getRuntimeOffset(state: RuntimeState): { absoluteOffset: number; relativeOffset: number } {
|
||||||
// nothing to calculate if there are no loaded events or if we havent started
|
// nothing to calculate if there are no loaded events or if we havent started
|
||||||
if (state.eventNow === null || state.runtime.actualStart === null) {
|
if (state.eventNow === null || state.runtime.actualStart === null) {
|
||||||
return 0;
|
return { absoluteOffset: 0, relativeOffset: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||||
@@ -133,18 +133,16 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
|||||||
const { clock } = state;
|
const { clock } = state;
|
||||||
const { countToEnd, timeStart } = state.eventNow;
|
const { countToEnd, timeStart } = state.eventNow;
|
||||||
const { addedTime, current, startedAt } = state.timer;
|
const { addedTime, current, startedAt } = state.timer;
|
||||||
|
const { actualStart, plannedStart } = state.runtime;
|
||||||
|
|
||||||
// if we havent started, but the timer is armed
|
// if we havent started, but the timer is armed
|
||||||
// the offset is the difference to the schedule
|
// the offset is the difference to the schedule
|
||||||
if (startedAt === null) {
|
if (startedAt === null) {
|
||||||
return timeStart - clock;
|
return { absoluteOffset: timeStart - clock, relativeOffset: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const overtime = Math.min(current, 0);
|
const overtime = Math.min(current, 0);
|
||||||
// in time-to-end, offset is overtime
|
// in time-to-end, offset is overtime
|
||||||
if (countToEnd) {
|
|
||||||
return overtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startOffset = timeStart - startedAt;
|
const startOffset = timeStart - startedAt;
|
||||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||||
@@ -153,7 +151,20 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
|||||||
// addedTime - time added by user (negative offset)
|
// addedTime - time added by user (negative offset)
|
||||||
// pausedTime - time the playback was paused (negative offset)
|
// pausedTime - time the playback was paused (negative offset)
|
||||||
// overtime - how long the timer has been over-running (negative offset)
|
// overtime - how long the timer has been over-running (negative offset)
|
||||||
return startOffset - addedTime - pausedTime + overtime;
|
const offset = startOffset - addedTime - pausedTime + overtime;
|
||||||
|
|
||||||
|
// offset between planned rundown start and actual rundown start
|
||||||
|
const rundownStartOffset = actualStart - plannedStart;
|
||||||
|
|
||||||
|
// offset offset relative to the actual rundown start
|
||||||
|
const relativeOffset = offset + rundownStartOffset;
|
||||||
|
|
||||||
|
// in time-to-end, offset is overtime
|
||||||
|
if (countToEnd) {
|
||||||
|
return { absoluteOffset: overtime, relativeOffset };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { absoluteOffset: offset, relativeOffset };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,7 +175,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
|||||||
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
|
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
return state.runtime.plannedEnd - state.runtime.offset + state._rundown.totalDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TimerPhase, Playback } from 'ontime-types';
|
import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
|
||||||
import { deepmerge } from 'ontime-utils';
|
import { deepmerge } from 'ontime-utils';
|
||||||
import type { RuntimeState } from '../runtimeState.js';
|
import type { RuntimeState } from '../runtimeState.js';
|
||||||
|
|
||||||
@@ -16,10 +16,12 @@ const baseState: RuntimeState = {
|
|||||||
selectedEventIndex: null,
|
selectedEventIndex: null,
|
||||||
numEvents: 0,
|
numEvents: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
relativeOffset: 0,
|
||||||
plannedStart: 0,
|
plannedStart: 0,
|
||||||
plannedEnd: 0,
|
plannedEnd: 0,
|
||||||
actualStart: null,
|
actualStart: null,
|
||||||
expectedEnd: null,
|
expectedEnd: null,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
@@ -35,10 +37,12 @@ const baseState: RuntimeState = {
|
|||||||
},
|
},
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
secondaryTarget: null,
|
secondaryTarget: null,
|
||||||
},
|
},
|
||||||
|
_rundown: {
|
||||||
|
totalDelay: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
|
|||||||
import {
|
import {
|
||||||
type RuntimeState,
|
type RuntimeState,
|
||||||
addTime,
|
addTime,
|
||||||
clear,
|
clearState,
|
||||||
getState,
|
getState,
|
||||||
load,
|
load,
|
||||||
loadBlock,
|
loadBlock,
|
||||||
@@ -71,7 +71,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
describe('mutation on runtimeState', () => {
|
describe('mutation on runtimeState', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clear();
|
clearState();
|
||||||
|
|
||||||
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
|
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
|
||||||
const actual = (await importOriginal()) as object;
|
const actual = (await importOriginal()) as object;
|
||||||
@@ -246,7 +246,7 @@ describe('roll mode', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime('jan 1 00:00');
|
vi.setSystemTime('jan 1 00:00');
|
||||||
clear();
|
clearState();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
|
OffsetMode,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
@@ -45,10 +46,12 @@ export type RuntimeState = {
|
|||||||
// private properties of the timer calculations
|
// private properties of the timer calculations
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
||||||
totalDelay: number; // this value comes from rundown service
|
|
||||||
pausedAt: MaybeNumber;
|
pausedAt: MaybeNumber;
|
||||||
secondaryTarget: MaybeNumber;
|
secondaryTarget: MaybeNumber;
|
||||||
};
|
};
|
||||||
|
_rundown: {
|
||||||
|
totalDelay: number; // this value comes from rundown service
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeState: RuntimeState = {
|
const runtimeState: RuntimeState = {
|
||||||
@@ -62,10 +65,12 @@ const runtimeState: RuntimeState = {
|
|||||||
timer: { ...runtimeStorePlaceholder.timer },
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
secondaryTarget: null,
|
secondaryTarget: null,
|
||||||
},
|
},
|
||||||
|
_rundown: {
|
||||||
|
totalDelay: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState(): Readonly<RuntimeState> {
|
export function getState(): Readonly<RuntimeState> {
|
||||||
@@ -79,10 +84,36 @@ export function getState(): Readonly<RuntimeState> {
|
|||||||
runtime: { ...runtimeState.runtime },
|
runtime: { ...runtimeState.runtime },
|
||||||
timer: { ...runtimeState.timer },
|
timer: { ...runtimeState.timer },
|
||||||
_timer: { ...runtimeState._timer },
|
_timer: { ...runtimeState._timer },
|
||||||
|
_rundown: { ...runtimeState._rundown },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clear() {
|
/* clear data related to the current event, but leave in place data about the global run state
|
||||||
|
* used when loading a new event but the playback is not interrupted
|
||||||
|
*/
|
||||||
|
export function clearEventData() {
|
||||||
|
runtimeState.eventNow = null;
|
||||||
|
runtimeState.publicEventNow = null;
|
||||||
|
runtimeState.eventNext = null;
|
||||||
|
runtimeState.publicEventNext = null;
|
||||||
|
|
||||||
|
runtimeState.runtime.offset = 0;
|
||||||
|
runtimeState.runtime.relativeOffset = 0;
|
||||||
|
runtimeState.runtime.expectedEnd = null;
|
||||||
|
runtimeState.runtime.selectedEventIndex = null;
|
||||||
|
|
||||||
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
|
runtimeState.clock = clock.timeNow();
|
||||||
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
|
// when clearing, we maintain the total delay from the rundown
|
||||||
|
runtimeState._timer.forceFinish = null;
|
||||||
|
runtimeState._timer.pausedAt = null;
|
||||||
|
runtimeState._timer.secondaryTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear all necessary data when doing a full stop and the event is unloaded
|
||||||
|
export function clearState() {
|
||||||
runtimeState.eventNow = null;
|
runtimeState.eventNow = null;
|
||||||
runtimeState.publicEventNow = null;
|
runtimeState.publicEventNow = null;
|
||||||
runtimeState.eventNext = null;
|
runtimeState.eventNext = null;
|
||||||
@@ -93,6 +124,7 @@ export function clear() {
|
|||||||
runtimeState.publicEventNext = null;
|
runtimeState.publicEventNext = null;
|
||||||
|
|
||||||
runtimeState.runtime.offset = 0;
|
runtimeState.runtime.offset = 0;
|
||||||
|
runtimeState.runtime.relativeOffset = 0;
|
||||||
runtimeState.runtime.actualStart = null;
|
runtimeState.runtime.actualStart = null;
|
||||||
runtimeState.runtime.expectedEnd = null;
|
runtimeState.runtime.expectedEnd = null;
|
||||||
runtimeState.runtime.selectedEventIndex = null;
|
runtimeState.runtime.selectedEventIndex = null;
|
||||||
@@ -137,7 +169,7 @@ type RundownData = {
|
|||||||
*/
|
*/
|
||||||
export function updateRundownData(rundownData: RundownData) {
|
export function updateRundownData(rundownData: RundownData) {
|
||||||
// we keep this in private state since there is no UI use case for it
|
// we keep this in private state since there is no UI use case for it
|
||||||
runtimeState._timer.totalDelay = rundownData.totalDelay;
|
runtimeState._rundown.totalDelay = rundownData.totalDelay;
|
||||||
|
|
||||||
runtimeState.runtime.numEvents = rundownData.numEvents;
|
runtimeState.runtime.numEvents = rundownData.numEvents;
|
||||||
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
||||||
@@ -154,10 +186,7 @@ export function load(
|
|||||||
rundown: OntimeRundown,
|
rundown: OntimeRundown,
|
||||||
initialData?: Partial<TimerState & RestorePoint>,
|
initialData?: Partial<TimerState & RestorePoint>,
|
||||||
): boolean {
|
): boolean {
|
||||||
// we need to persist the current block state across loads
|
clearEventData();
|
||||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
|
||||||
clear();
|
|
||||||
runtimeState.currentBlock = prevCurrentBlock;
|
|
||||||
|
|
||||||
// filter rundown
|
// filter rundown
|
||||||
const timedEvents = filterTimedEvents(rundown);
|
const timedEvents = filterTimedEvents(rundown);
|
||||||
@@ -184,7 +213,9 @@ export function load(
|
|||||||
const firstStart = initialData?.firstStart;
|
const firstStart = initialData?.firstStart;
|
||||||
if (firstStart === null || typeof firstStart === 'number') {
|
if (firstStart === null || typeof firstStart === 'number') {
|
||||||
runtimeState.runtime.actualStart = firstStart;
|
runtimeState.runtime.actualStart = firstStart;
|
||||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState);
|
||||||
|
runtimeState.runtime.offset = absoluteOffset;
|
||||||
|
runtimeState.runtime.relativeOffset = relativeOffset;
|
||||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||||
}
|
}
|
||||||
if (typeof initialData.blockStartAt === 'number') {
|
if (typeof initialData.blockStartAt === 'number') {
|
||||||
@@ -389,7 +420,9 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||||
|
|
||||||
// update offset
|
// update offset
|
||||||
state.runtime.offset = getRuntimeOffset(state);
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState);
|
||||||
|
runtimeState.runtime.offset = absoluteOffset;
|
||||||
|
runtimeState.runtime.relativeOffset = relativeOffset;
|
||||||
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
|
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -409,9 +442,7 @@ export function stop(state: RuntimeState = runtimeState): boolean {
|
|||||||
if (state.timer.playback === Playback.Stop) {
|
if (state.timer.playback === Playback.Stop) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
clear();
|
clearState();
|
||||||
runtimeState.runtime.actualStart = null;
|
|
||||||
runtimeState.runtime.expectedEnd = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +483,9 @@ export function addTime(amount: number) {
|
|||||||
runtimeState.timer.current += amount;
|
runtimeState.timer.current += amount;
|
||||||
|
|
||||||
// update runtime delays: over - under
|
// update runtime delays: over - under
|
||||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState);
|
||||||
|
runtimeState.runtime.offset = absoluteOffset;
|
||||||
|
runtimeState.runtime.relativeOffset = relativeOffset;
|
||||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -497,7 +530,9 @@ export function update(): UpdateResult {
|
|||||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||||
|
|
||||||
// update runtime, needs up-to-date timer state
|
// update runtime, needs up-to-date timer state
|
||||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState);
|
||||||
|
runtimeState.runtime.offset = absoluteOffset;
|
||||||
|
runtimeState.runtime.relativeOffset = relativeOffset;
|
||||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||||
|
|
||||||
const finishedNow =
|
const finishedNow =
|
||||||
@@ -604,9 +639,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
|
|||||||
}
|
}
|
||||||
|
|
||||||
// we need to persist the current block state across loads
|
// we need to persist the current block state across loads
|
||||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
clearEventData();
|
||||||
clear();
|
|
||||||
runtimeState.currentBlock = prevCurrentBlock;
|
|
||||||
|
|
||||||
//account for offset but we only keep it if passed to us
|
//account for offset but we only keep it if passed to us
|
||||||
runtimeState.runtime.offset = offset;
|
runtimeState.runtime.offset = offset;
|
||||||
@@ -696,3 +729,7 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
|
|||||||
// update the block anyway
|
// update the block anyway
|
||||||
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setOffsetMode(mode: OffsetMode) {
|
||||||
|
runtimeState.runtime.offsetMode = mode;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export const defaultCss = `
|
||||||
|
/**
|
||||||
|
* This CSS file allows user customisation of the UI
|
||||||
|
* We expose some CSS properties to facilitate this (see below in :root)
|
||||||
|
* In the cases where this is missing, you can add your selectors here
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/** Background colour for the views */
|
||||||
|
--background-color-override: #ececec;
|
||||||
|
|
||||||
|
/** Main text colour for the views */
|
||||||
|
--color-override: #101010;
|
||||||
|
|
||||||
|
/** Text colour for the views */
|
||||||
|
--secondary-color-override: #404040;
|
||||||
|
|
||||||
|
/** Accent text colour, used on active elements */
|
||||||
|
--accent-color-override: #fa5656;
|
||||||
|
|
||||||
|
/** Label text colour, used on active elements */
|
||||||
|
--label-color-override: #6c6c6c;
|
||||||
|
|
||||||
|
/** Timer text colour */
|
||||||
|
--timer-color-override: #202020;
|
||||||
|
--timer-warning-color-override: #ffbc56;
|
||||||
|
--timer-danger-color-override: #e69000;
|
||||||
|
--timer-overtime-color-override: #fa5656;
|
||||||
|
--timer-pending-color-override: #578AF4;
|
||||||
|
|
||||||
|
/** Background for card elements on background */
|
||||||
|
--card-background-color-override: #fff;
|
||||||
|
|
||||||
|
/** Font used for all text in views */
|
||||||
|
--font-family-override: 'Open Sans';
|
||||||
|
|
||||||
|
/** Font used for clock in /minimal and /clock views */
|
||||||
|
--font-family-bold-override: 'Arial Black';
|
||||||
|
|
||||||
|
/** Colour used for external message and aux timer in /timer */
|
||||||
|
--external-color-override: #161616;
|
||||||
|
|
||||||
|
/** View specific features: /backstage */
|
||||||
|
/** ---- Background highlight for blink behaviour */
|
||||||
|
--card-background-color-blink-override: #339e4e;
|
||||||
|
/** ---- Colour used for progress bar background */
|
||||||
|
--timer-progress-bg-override: #fff;
|
||||||
|
/** ---- Colour used for progress bar progress */
|
||||||
|
--timer-progress-override: #202020;
|
||||||
|
|
||||||
|
/** View specific features: /op */
|
||||||
|
--operator-customfield-font-size-override: 1.25rem;
|
||||||
|
--operator-running-bg-override: #339e4e;
|
||||||
|
|
||||||
|
/** View specific features: /studio */
|
||||||
|
--studio-active: #101010;
|
||||||
|
--studio-idle: #cfcfcf;
|
||||||
|
--studio-active-label: #101010;
|
||||||
|
--studio-idle-label: #595959;
|
||||||
|
--studio-overtime: #101010;
|
||||||
|
|
||||||
|
/** View specific features: /lower */
|
||||||
|
--lowerThird-font-family-override: 'Courier New';
|
||||||
|
--lowerThird-top-font-weight-override: bold;
|
||||||
|
--lowerThird-bottom-font-weight-override: bold;
|
||||||
|
--lowerThird-top-font-style-override: normal;
|
||||||
|
--lowerThird-bottom-font-style-override: italic;
|
||||||
|
--lowerThird-line-height-override: 1vh;
|
||||||
|
--lowerThird-text-align-override: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can inspect the page in your browser and add the selectors here.
|
||||||
|
* In the below example, we change the colour of the overlay message in the stage-timer view.
|
||||||
|
*/
|
||||||
|
.stage-timer > .message-overlay--active > div {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -838,6 +838,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
lighting: 'lx',
|
lighting: 'lx',
|
||||||
sound: 'sound',
|
sound: 'sound',
|
||||||
video: 'av',
|
video: 'av',
|
||||||
|
ontime_label: 'excel label',
|
||||||
},
|
},
|
||||||
entryId: 'id',
|
entryId: 'id',
|
||||||
} as ImportMap;
|
} as ImportMap;
|
||||||
@@ -845,6 +846,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
const customFields: CustomFields = {
|
const customFields: CustomFields = {
|
||||||
lighting: { label: 'lx', type: 'string', colour: 'red' },
|
lighting: { label: 'lx', type: 'string', colour: 'red' },
|
||||||
sound: { label: 'sound', type: 'string', colour: 'green' },
|
sound: { label: 'sound', type: 'string', colour: 'green' },
|
||||||
|
ontime_key: { label: 'ontime_label', type: 'string', colour: 'blue' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, customFields);
|
const result = getCustomFieldData(importMap, customFields);
|
||||||
@@ -864,6 +866,11 @@ describe('getCustomFieldData()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
label: 'video',
|
label: 'video',
|
||||||
},
|
},
|
||||||
|
ontime_key: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
label: 'ontime_label',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// it is an inverted record of <importKey, ontimeKey>
|
// it is an inverted record of <importKey, ontimeKey>
|
||||||
@@ -871,6 +878,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
lx: 'lighting',
|
lx: 'lighting',
|
||||||
sound: 'sound',
|
sound: 'sound',
|
||||||
av: 'video',
|
av: 'video',
|
||||||
|
'excel label': 'ontime_key',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1022,8 +1030,8 @@ describe('parseExcel()', () => {
|
|||||||
user2: { type: 'string', colour: 'blue', label: 'user2' },
|
user2: { type: 'string', colour: 'blue', label: 'user2' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsedData = parseExcel(testdata, existingCustomFields, importMap);
|
const { customFields, rundown } = parseExcel(testdata, existingCustomFields, importMap);
|
||||||
expect(parsedData.customFields).toStrictEqual({
|
expect(customFields).toStrictEqual({
|
||||||
user0: {
|
user0: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
@@ -1075,9 +1083,9 @@ describe('parseExcel()', () => {
|
|||||||
label: 'user9',
|
label: 'user9',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(parsedData.rundown.length).toBe(2);
|
expect(rundown.length).toBe(2);
|
||||||
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
expect(rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
||||||
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
|
expect(rundown[1]).toMatchObject(expectedParsedRundown[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a file without custom fields', () => {
|
it('parses a file without custom fields', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Server } from 'http';
|
|||||||
import { networkInterfaces } from 'os';
|
import { networkInterfaces } from 'os';
|
||||||
import type { AddressInfo } from 'net';
|
import type { AddressInfo } from 'net';
|
||||||
|
|
||||||
import { isDocker, isProduction } from '../externals.js';
|
import { isDocker, isOntimeCloud, isProduction } from '../externals.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +42,10 @@ export function getNetworkInterfaces(): { name: string; address: string }[] {
|
|||||||
* @throws any other server errors will result in a throw
|
* @throws any other server errors will result in a throw
|
||||||
*/
|
*/
|
||||||
export function serverTryDesiredPort(server: Server, desiredPort: number): Promise<number> {
|
export function serverTryDesiredPort(server: Server, desiredPort: number): Promise<number> {
|
||||||
|
if (isOntimeCloud) {
|
||||||
|
return forceCloudPort(server);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
server.once('error', (error) => {
|
server.once('error', (error) => {
|
||||||
// we should only move ports if we are in a desktop environment
|
// we should only move ports if we are in a desktop environment
|
||||||
@@ -84,6 +88,19 @@ export function serverTryDesiredPort(server: Server, desiredPort: number): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forceCloudPort(server: Server): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.listen(4001, '0.0.0.0', () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!isAddressInfo(address)) {
|
||||||
|
reject(new Error('Unknown port type, unable to proceed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guard verifies that the given address is a usable AddressInfo object
|
* Guard verifies that the given address is a usable AddressInfo object
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
customFieldLabelToKey,
|
customFieldLabelToKey,
|
||||||
|
customKeyFromLabel,
|
||||||
defaultImportMap,
|
defaultImportMap,
|
||||||
generateId,
|
generateId,
|
||||||
type ImportMap,
|
type ImportMap,
|
||||||
@@ -60,7 +61,7 @@ export function getCustomFieldData(
|
|||||||
const customFields = {};
|
const customFields = {};
|
||||||
const customFieldImportKeys = {};
|
const customFieldImportKeys = {};
|
||||||
for (const ontimeLabel in importMap.custom) {
|
for (const ontimeLabel in importMap.custom) {
|
||||||
const ontimeKey = customFieldLabelToKey(ontimeLabel);
|
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
|
||||||
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
|
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
|
||||||
const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '';
|
const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '';
|
||||||
customFields[ontimeKey] = {
|
customFields[ontimeKey] = {
|
||||||
@@ -198,9 +199,9 @@ export const parseExcel = (
|
|||||||
entryIdIndex = col;
|
entryIdIndex = col;
|
||||||
rundownMetadata['id'] = { row, col };
|
rundownMetadata['id'] = { row, col };
|
||||||
},
|
},
|
||||||
custom: (row: number, col: number, columnText: string) => {
|
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||||
customFieldIndexes[col] = columnText;
|
customFieldIndexes[col] = columnText;
|
||||||
rundownMetadata[`custom:${columnText}`] = { row, col };
|
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -266,12 +267,13 @@ export const parseExcel = (
|
|||||||
|
|
||||||
// check if it is an ontime column
|
// check if it is an ontime column
|
||||||
if (handlers[columnText]) {
|
if (handlers[columnText]) {
|
||||||
handlers[columnText](rowIndex, j, undefined);
|
handlers[columnText](rowIndex, j, undefined, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if it is a custom field
|
// check if it is a custom field
|
||||||
if (columnText in customFieldImportKeys) {
|
if (columnText in customFieldImportKeys) {
|
||||||
handlers.custom(rowIndex, j, columnText);
|
const ontimeKey = customFieldImportKeys[columnText];
|
||||||
|
handlers.custom(rowIndex, j, columnText, ontimeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// else. we don't know how to handle this column
|
// else. we don't know how to handle this column
|
||||||
@@ -387,15 +389,16 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
note: makeString(patchEvent.note, originalEvent.note),
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||||
delay: 0, // is always regenerated by the cache
|
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
||||||
dayOffset: 0, // is always regenerated by the cache
|
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
||||||
gap: 0, // is always regenerated by the cache
|
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
||||||
// short circuit empty string
|
// short circuit empty string
|
||||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||||
revision: originalEvent.revision,
|
revision: originalEvent.revision,
|
||||||
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
||||||
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
||||||
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
||||||
|
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
test('linked time until', async ({ page }) => {
|
test('time until absolute', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
@@ -11,7 +11,46 @@ test('linked time until', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Absolute' }).click();
|
||||||
await page.getByTestId('entry-1').getByLabel('Start event').click();
|
await page.getByTestId('entry-1').getByLabel('Start event').click();
|
||||||
|
await expect(page.getByTestId('offset')).not.toContainText('00:00:00'); // This might be a bad test requires that the test is not run at 0h
|
||||||
|
await page.getByLabel('Pause event').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
|
||||||
|
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('19m');
|
||||||
|
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('29m');
|
||||||
|
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('6h');
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5h59m');
|
||||||
|
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('6h9m');
|
||||||
|
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('6h19m');
|
||||||
|
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('30s');
|
||||||
|
await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('29s');
|
||||||
|
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('10m');
|
||||||
|
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('20m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time until relative', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Relative' }).click();
|
||||||
|
await page.getByTestId('entry-1').getByLabel('Start event').click();
|
||||||
|
await expect(page.getByTestId('offset')).toContainText('00:00:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||||
await page.getByLabel('Pause event').click();
|
await page.getByLabel('Pause event').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
|
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.14.3",
|
"version": "3.15.2",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { EndAction, EventCustomFields, MaybeString, TimerType, TimeStrategy } from '../../index.js';
|
import type { EndAction, EventCustomFields, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js';
|
||||||
|
|
||||||
export enum SupportedEvent {
|
export enum SupportedEvent {
|
||||||
Event = 'event',
|
Event = 'event',
|
||||||
@@ -44,6 +44,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
timeWarning: number;
|
timeWarning: number;
|
||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
custom: EventCustomFields;
|
custom: EventCustomFields;
|
||||||
|
triggers?: Trigger[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlayableEvent = OntimeEvent & { skip: false };
|
export type PlayableEvent = OntimeEvent & { skip: false };
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||||
|
|
||||||
|
export enum OffsetMode {
|
||||||
|
Absolute = 'absolute',
|
||||||
|
Relative = 'relative',
|
||||||
|
}
|
||||||
|
|
||||||
export type Runtime = {
|
export type Runtime = {
|
||||||
numEvents: number;
|
numEvents: number;
|
||||||
selectedEventIndex: MaybeNumber;
|
selectedEventIndex: MaybeNumber;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
relativeOffset: number;
|
||||||
plannedStart: MaybeNumber;
|
plannedStart: MaybeNumber;
|
||||||
actualStart: MaybeNumber;
|
actualStart: MaybeNumber;
|
||||||
plannedEnd: MaybeNumber;
|
plannedEnd: MaybeNumber;
|
||||||
expectedEnd: MaybeNumber;
|
expectedEnd: MaybeNumber;
|
||||||
|
offsetMode: OffsetMode;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js';
|
import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js';
|
||||||
import { Playback } from './Playback.type.js';
|
import { Playback } from './Playback.type.js';
|
||||||
|
import { OffsetMode } from './Runtime.type.js';
|
||||||
import type { RuntimeStore } from './RuntimeStore.type.js';
|
import type { RuntimeStore } from './RuntimeStore.type.js';
|
||||||
import { TimerPhase } from './TimerState.type.js';
|
import { TimerPhase } from './TimerState.type.js';
|
||||||
|
|
||||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||||
clock: 0,
|
clock: 0,
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
@@ -32,10 +33,12 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
||||||
numEvents: 0, // change initiated by user
|
numEvents: 0, // change initiated by user
|
||||||
offset: 0, // changes at runtime
|
offset: 0, // changes at runtime
|
||||||
|
relativeOffset: 0, // changes at runtime
|
||||||
plannedStart: 0, // only changes if event changes
|
plannedStart: 0, // only changes if event changes
|
||||||
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
|
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
|
||||||
actualStart: null, // set once we start the timer
|
actualStart: null, // set once we start the timer
|
||||||
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
},
|
},
|
||||||
currentBlock: {
|
currentBlock: {
|
||||||
block: null,
|
block: null,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLi
|
|||||||
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
|
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
|
||||||
|
|
||||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||||
|
export { OffsetMode } from './definitions/runtime/Runtime.type.js';
|
||||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||||
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
||||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||||
@@ -104,10 +105,10 @@ export {
|
|||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
isOntimeCycle,
|
|
||||||
isKeyOfType,
|
isKeyOfType,
|
||||||
isOSCOutput,
|
isOSCOutput,
|
||||||
isHTTPOutput,
|
isHTTPOutput,
|
||||||
isOntimeAction,
|
isOntimeAction,
|
||||||
|
isTimerLifeCycle,
|
||||||
} from './utils/guards.js';
|
} from './utils/guards.js';
|
||||||
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../d
|
|||||||
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
|
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
|
||||||
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
|
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
|
||||||
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
|
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
|
||||||
import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js';
|
import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js';
|
||||||
import { TimerLifeCycle } from '../definitions/core/TimerLifecycle.type.js';
|
|
||||||
|
|
||||||
type MaybeEvent = OntimeRundownEntry | Partial<OntimeRundownEntry> | null | undefined;
|
type MaybeEvent = OntimeRundownEntry | Partial<OntimeRundownEntry> | null | undefined;
|
||||||
|
|
||||||
@@ -29,11 +28,6 @@ export function isKeyOfType<T extends object>(key: PropertyKey, obj: T): key is
|
|||||||
return key in obj;
|
return key in obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOntimeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycleKey {
|
|
||||||
if (typeof maybeCycle !== 'string') return false;
|
|
||||||
return Object.values(TimerLifeCycle).includes(maybeCycle as TimerLifeCycle);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isOSCOutput(output: AutomationOutput): output is OSCOutput {
|
export function isOSCOutput(output: AutomationOutput): output is OSCOutput {
|
||||||
return output.type === 'osc';
|
return output.type === 'osc';
|
||||||
}
|
}
|
||||||
@@ -45,3 +39,8 @@ export function isHTTPOutput(output: AutomationOutput): output is HTTPOutput {
|
|||||||
export function isOntimeAction(output: AutomationOutput): output is OntimeAction {
|
export function isOntimeAction(output: AutomationOutput): output is OntimeAction {
|
||||||
return output.type === 'ontime';
|
return output.type === 'ontime';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTimerLifeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycle {
|
||||||
|
if (typeof maybeCycle !== 'string') return false;
|
||||||
|
return timerLifecycleValues.includes(maybeCycle);
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export { isAlphanumeric, isAlphanumericWithSpace } from './src/regex-utils/isAlp
|
|||||||
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
||||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||||
|
|
||||||
export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelToKey.js';
|
export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-utils/customFieldLabelToKey.js';
|
||||||
|
|
||||||
// helpers from externals
|
// helpers from externals
|
||||||
export { deepmerge } from './src/externals/deepmerge.js';
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
|
|||||||
* @param array
|
* @param array
|
||||||
*/
|
*/
|
||||||
export function deleteAtIndex<T>(index: number, array: T[]) {
|
export function deleteAtIndex<T>(index: number, array: T[]) {
|
||||||
return array.toSpliced(index, 1);
|
return array.filter((_, i) => i !== index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js';
|
import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,3 +11,11 @@ export const customFieldLabelToKey = (label: string): string | null => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const customKeyFromLabel = (label: string, fields: CustomFields): string | null => {
|
||||||
|
const maybeMatchingKey = Object.keys(fields).find((key) => fields[key].label === label);
|
||||||
|
if (maybeMatchingKey) {
|
||||||
|
return maybeMatchingKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
Generated
+40
-9
@@ -126,8 +126,8 @@ importers:
|
|||||||
specifier: ^5.62.7
|
specifier: ^5.62.7
|
||||||
version: 5.62.7(@tanstack/react-query@5.62.7(react@18.3.1))(react@18.3.1)
|
version: 5.62.7(@tanstack/react-query@5.62.7(react@18.3.1))(react@18.3.1)
|
||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.20.5
|
specifier: ^8.21.3
|
||||||
version: 8.20.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
autosize:
|
autosize:
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.1
|
version: 6.0.1
|
||||||
@@ -143,6 +143,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^10.10.0
|
specifier: ^10.10.0
|
||||||
version: 10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
prismjs:
|
||||||
|
specifier: ^1.29.0
|
||||||
|
version: 1.29.0
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -167,6 +170,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react-simple-code-editor:
|
||||||
|
specifier: ^0.14.1
|
||||||
|
version: 0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
web-vitals:
|
web-vitals:
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.1.1
|
version: 3.1.1
|
||||||
@@ -183,6 +189,9 @@ importers:
|
|||||||
'@types/color':
|
'@types/color':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
'@types/prismjs':
|
||||||
|
specifier: ^1.26.5
|
||||||
|
version: 1.26.5
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.0.26
|
specifier: ^18.0.26
|
||||||
version: 18.0.26
|
version: 18.0.26
|
||||||
@@ -1977,15 +1986,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tanstack/react-table@8.20.6':
|
'@tanstack/react-table@8.21.3':
|
||||||
resolution: {integrity: sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==}
|
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
'@tanstack/table-core@8.20.5':
|
'@tanstack/table-core@8.21.3':
|
||||||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@tootallnate/once@2.0.0':
|
'@tootallnate/once@2.0.0':
|
||||||
@@ -2076,6 +2085,9 @@ packages:
|
|||||||
'@types/plist@3.0.5':
|
'@types/plist@3.0.5':
|
||||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5':
|
||||||
|
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.5':
|
'@types/prop-types@15.7.5':
|
||||||
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
||||||
|
|
||||||
@@ -4123,6 +4135,10 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prismjs@1.29.0:
|
||||||
|
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
@@ -4278,6 +4294,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
|
|
||||||
|
react-simple-code-editor@0.14.1:
|
||||||
|
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
react-style-singleton@2.2.1:
|
react-style-singleton@2.2.1:
|
||||||
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6728,13 +6750,13 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.62.7
|
'@tanstack/query-core': 5.62.7
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
'@tanstack/react-table@8.20.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/table-core': 8.20.5
|
'@tanstack/table-core': 8.21.3
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
'@tanstack/table-core@8.20.5': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
'@tootallnate/once@2.0.0': {}
|
'@tootallnate/once@2.0.0': {}
|
||||||
|
|
||||||
@@ -6850,6 +6872,8 @@ snapshots:
|
|||||||
xmlbuilder: 15.1.1
|
xmlbuilder: 15.1.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5': {}
|
||||||
|
|
||||||
'@types/prop-types@15.7.5': {}
|
'@types/prop-types@15.7.5': {}
|
||||||
|
|
||||||
'@types/qs@6.9.7': {}
|
'@types/qs@6.9.7': {}
|
||||||
@@ -9287,6 +9311,8 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.3.1: {}
|
prettier@3.3.1: {}
|
||||||
|
|
||||||
|
prismjs@1.29.0: {}
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
@@ -9431,6 +9457,11 @@ snapshots:
|
|||||||
'@remix-run/router': 1.2.1
|
'@remix-run/router': 1.2.1
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
react-simple-code-editor@0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react-style-singleton@2.2.1(@types/react@18.0.26)(react@18.3.1):
|
react-style-singleton@2.2.1(@types/react@18.0.26)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user