Compare commits

...

34 Commits

Author SHA1 Message Date
arc-alex 6c460988f1 fix test 2025-05-28 17:06:08 +02:00
arc-alex 00dfdb6d9f option to hide custom fields 2025-05-28 17:02:26 +02:00
arc-alex 0ff5f1b79b deffiretiate main and custom edits 2025-05-28 17:02:26 +02:00
arc-alex 549c291f56 bump version 2025-05-28 08:15:03 +02:00
arc-alex d89309a120 lock cusheet edits 2025-05-27 15:33:39 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
Shobhit-Nagpal ac61f257c0 fix: import preview enable after error 2025-05-13 21:24:14 +02:00
Carlos Valente 8d8625aa77 bump version to 3.15.2 2025-05-13 21:24:14 +02:00
Alex Christoffer Rasmussen 1e1aa4bca5 Fix: sheet export (#1606)
* add util to get keys from labels

* look for existing keys first then create new

* save ontime key to rundown metadata

* update test
2025-05-12 13:56:25 +02:00
Alex Christoffer Rasmussen 1f0cde6d0d fix: relative offset with count to end (#1602)
* refactor merge absolute and relative offset calculation

* update test
2025-05-07 22:34:25 +02:00
Carlos Valente c4b66a9ed3 bump version to 3.15.1 2025-04-30 20:28:42 +02:00
Carlos Valente 133b3aa819 refactor: create hash of table state 2025-04-30 20:23:49 +02:00
Carlos Valente 9566fadbbb chore: upgrade table dependencies 2025-04-30 20:23:49 +02:00
Carlos Valente 62570899e8 fix: issue when user hides all columns 2025-04-30 20:23:49 +02:00
Carlos Valente c1f25f8dba bump version to 3.15.0 2025-04-20 13:52:43 +02:00
Alex Christoffer Rasmussen 37a91026d8 FIX: editing event data clears delay value (#1591)
* leave data in place in case it is not regenerated

* update comments
2025-04-20 13:51:25 +02:00
Bastian Widmer c62ce213f5 fix - fix studio clock sizing when timer seconds are hidden !1585 2025-04-20 13:28:22 +02:00
Alex Christoffer Rasmussen 529751578d refactor: lower third (#1507)
* refactor

* separate in and out time

* keep element on screen

* animate on play

* spelling

* explain animation decisions

* guard numbers from url params

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename safeParseNumber

* use view data

* refactor getOptionsFromParams

* rebalance animation timing

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-04-20 12:50:16 +02:00
Alex Christoffer Rasmussen dce87c3a81 Relative run mode (#1512)
* inti relative mode

* send imediat update on mode change

* add relative to test

* don't persist offset mode

* add test relative and update calc function

* pass data from ssocket

* add dev guard

* spelling and comments

* show relative in offset overview

* remove comments

* refactor: move totalDelay to _rundown

* update  clear naming and comments

* remove old testing values
2025-04-19 21:36:54 +02:00
Alex Christoffer Rasmussen a94b10cb0f Event trigger (#1557)
* crude UI

* add server functionalety

* better event trigger list

* fix default value

* remove log

* add trigger to recalculate whitelist

* use DTO type

* fix rebase

* one callback for delete and submit

* refactor

* add invalid description

* clean up commits

* cleanup

* refactor: find event triggers in `triggerAutomations`

* refactor: switch to flat array for event triggers

* prevent deleting a automation that is in use

* refactor

* refactor: extract EventEditorCustom

* move trigger options to separate file

* move trigger edit down a file level

* cleanup

* spelling

* refactor: ui review proposal

* change default value

* fix delete filter

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-04-19 21:27:07 +02:00
Shobhit Nagpal 6817263123 feat: css editor (#1542)
* feat: allow editing CSS from settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

refactor: style tweaks
2025-04-19 20:40:18 +02:00
Alex Christoffer Rasmussen ddd629d7db in cloud force port 4001 (#1575)
* force 4001

* only log if there is anything to change

* skip port retry in cloud
2025-04-15 21:36:59 +02:00
Carlos Valente 9d2a3d5a12 bump version to 3.14.5 2025-04-15 21:25:46 +02:00
Carlos Valente 4bc271208e refactor: improve synchronisation of external text 2025-04-15 21:25:33 +02:00
Carlos Valente dbd535dd6c refactor: timeout for offline status 2025-04-15 21:25:33 +02:00
Carlos Valente 57b7fe214a refactor: avoid old language features 2025-04-15 21:25:33 +02:00
cv b136e2856a refactor: simplify logic 2025-04-15 21:25:33 +02:00
Carlos Valente 03e68c03a9 refactor: allow cloud in iframe 2025-04-10 21:28:43 +02:00
Carlos Valente 44048a889d bump version to 3.14.4 2025-04-10 21:28:27 +02:00
arc-alex f72db62e08 add comment 2025-04-10 21:21:33 +02:00
arc-alex 333f2623a5 Fix: timeUntil past coutToEnd events 2025-04-10 21:21:33 +02:00
Alex Christoffer Rasmussen 3ebb3451e4 better browser compatibility for view params (#1566) 2025-04-09 12:39:59 +02:00
118 changed files with 2234 additions and 652 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.14.3",
"version": "3.16.1-alpha.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.14.3",
"version": "3.16.1-alpha.2",
"private": true,
"type": "module",
"dependencies": {
@@ -17,12 +17,13 @@
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^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",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
@@ -31,6 +32,7 @@
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
},
@@ -65,6 +67,7 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
+30
View File
@@ -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;
}
+10 -1
View File
@@ -20,8 +20,17 @@ export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
lockMainFields: boolean,
lockCustomFields: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
const res = await axios.post(`${sessionPath}/url`, {
baseUrl,
path,
lock,
lockMainFields,
lockCustomFields,
authenticate,
});
return res.data.url;
}
@@ -25,4 +25,4 @@ export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknow
{...props}
/>
);
};
};
@@ -3,8 +3,9 @@
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
color: $blue-400;
&:hover {
color: $blue-400;
color: $blue-500;
}
}
@@ -4,10 +4,10 @@ import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
) => {
const options = structuredClone(additionalOptions);
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
+12 -3
View File
@@ -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 { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
currentBlock: state.currentBlock,
}));
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
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
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
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 = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
};
@@ -1,3 +1,6 @@
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
});
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 = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
// the overlap will be pushed out to the expected available time
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?
});
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
});
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
});
@@ -79,29 +79,30 @@ describe('generatePathFromPreset()', () => {
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
expect(generatePathFromPreset(path, alias, null, null, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123', null, null)).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
});
});
describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => {
it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
})
});
it("checks whether the params match", () => {
it('checks whether the params match', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
});
it("considers edge cases for the url sharing feature", () => {
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
});
});
@@ -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;
}
+3 -1
View File
@@ -48,10 +48,12 @@ export const connectSocket = () => {
websocket.onclose = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
+30 -12
View File
@@ -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 { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): 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
const relativeDayOffset = dayOffset - currentDay;
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
const offsetTimestart = normalisedTimeStart - offset;
const offsetTimeUntil = offsetTimestart - clock;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const scheduledTimeUntil = normalisedTimeStart - clock;
const isAheadOfSchedule = offset >= 0;
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// 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;
}
+24 -6
View File
@@ -45,11 +45,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
const locked = searchParams.get('locked');
const token = searchParams.get('token');
const lmain = searchParams.get('lmain');
const lcustom = searchParams.get('lcustom');
// we need to check if the whole url is an alias
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token, lmain, lcustom);
}
// if the current url is not an alias, we check if the alias is in the search parameters
@@ -63,7 +66,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect
if (preset.alias === presetOnPage && preset.enabled) {
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token, lmain, lcustom);
if (!arePathsEquivalent(currentPath, newPath)) {
// if current path is out of date
// return new path so we can redirect
@@ -77,7 +80,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
/**
* Handles generating a path and search parameters from a preset
*/
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
export function generatePathFromPreset(
pathAndParams: string,
alias: string,
locked: string | null,
token: string | null,
lmain: string | null,
lcustom: string | null,
): string {
const path = resolvePath(pathAndParams);
const searchParams = new URLSearchParams(path.search);
@@ -93,6 +103,14 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
searchParams.set('token', token);
}
if (lmain) {
searchParams.set('lmain', lmain);
}
if (lcustom) {
searchParams.set('lcustom', lcustom);
}
// return path concatenated without the leading slash
return `${path.pathname}?${searchParams}`.substring(1);
}
@@ -106,16 +124,16 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// check path
if (currentUrl.pathname !== newUrl.pathname) {
return false
return false;
}
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true
return true;
}
// if there is no match, we check the edge cases for the url sharing feature
+2
View File
@@ -31,6 +31,8 @@ declare module '@tanstack/react-table' {
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
allowMainEdits: boolean;
allowCustomEdits: boolean;
};
}
}
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
&.end {
justify-content: flex-end;
}
&.apart {
justify-content: space-between;
}
}
@keyframes animloader {
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return <div className={cx([style.fieldError, className])}>{children}</div>;
}
export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end';
align?: 'start' | 'end' | 'apart';
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);
@@ -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 { 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 { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const {
control,
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
<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.ListGroup>
<Panel.ListGroup>
@@ -0,0 +1,5 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -20,6 +20,8 @@ interface GenerateLinkFormOptions {
baseUrl: string;
path: string;
lock: boolean;
lockMainFields: boolean;
lockCustomFields: boolean;
authenticate: boolean;
}
@@ -35,13 +37,15 @@ export default function GenerateLinkForm() {
handleSubmit,
register,
setError,
formState: { errors },
formState: { errors, dirtyFields },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
lock: false,
lockMainFields: false,
lockCustomFields: false,
authenticate: false,
},
resetOptions: {
@@ -53,7 +57,14 @@ export default function GenerateLinkForm() {
try {
setFormState('loading');
const baseUrl = linkToOtherHost(options.baseUrl);
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
const url = await generateUrl(
baseUrl,
options.path,
options.lock,
options.lockMainFields,
options.lockCustomFields,
options.authenticate,
);
await copyToClipboard(url);
setUrl(url);
setFormState('success');
@@ -119,6 +130,18 @@ export default function GenerateLinkForm() {
/>
<Switch variant='ontime' size='lg' {...register('lock')} />
</Panel.ListItem>
{dirtyFields.lock && (
<>
<Panel.ListItem>
<Panel.Field title='Lock main field edits' description='Prevent edits to main fields' />
<Switch variant='ontime' size='lg' {...register('lockMainFields')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Lock custom field edits' description='Prevent edits to custom fields' />
<Switch variant='ontime' size='lg' {...register('lockCustomFields')} />
</Panel.ListItem>
</>
)}
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch variant='ontime' size='lg' {...register('authenticate')} />
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const {
handleSubmit,
register,
control,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field
useEffect(() => {
setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values,
filename,
});
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return (
<Panel.Section
as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section>
</Panel.Section>
);
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types';
@@ -25,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors },
setError,
watch,
control,
setValue,
} = useForm({
defaultValues: data,
@@ -32,6 +33,12 @@ export default function ProjectData() {
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// reset form values if data changes
@@ -77,6 +84,10 @@ export default function ProjectData() {
});
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
@@ -231,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')}
/>
</label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section>
</Panel.Card>
</Panel.Section>
@@ -57,3 +57,18 @@
height: auto;
}
}
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
};
const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') {
try {
const previewData = await importRundownPreviewExcel(importMap);
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control,
handleSubmit,
register,
setValue,
formState: { errors, isValid },
} = useForm<NamedImportMap>({
mode: 'onChange',
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
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) => {
setLoading('export');
const importMap = convertToImportMap(values);
@@ -1,9 +1,7 @@
import { useEffect, useRef } from 'react';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { tooltipDelayMid } from '../../../ontimeConfig';
import { cx } from '../../../common/utils/styleUtils';
import style from './InputRow.module.scss';
@@ -12,50 +10,53 @@ interface InputRowProps {
placeholder: string;
text: string;
visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0);
// sync cursor position with text
useEffect(() => {
if (inputRef.current) {
if (inputRef.current && inputRef.current !== document.activeElement) {
inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current;
}
}, [text]);
// synchronise external text
useEffect(() => {
if (inputRef.current !== document.activeElement) {
setValue(text);
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0;
setValue(event.target.value);
changeHandler(event.target.value);
};
return (
<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}>
<Input
id={label}
ref={inputRef}
size='sm'
variant='ontime-filled'
value={text}
value={value}
onChange={handleInputChange}
placeholder={placeholder}
/>
<TooltipActionBtn
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'
/>
{children}
</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 { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
@@ -23,8 +27,17 @@ function TimerMessageInput() {
text={text}
visible={visible}
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}
visible={visible}
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>
);
}
@@ -45,6 +45,10 @@
@include ellipsis-overflow;
}
.offset {
color: $muted-gray;
}
.ahead {
color: $playback-ahead;
}
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,14 +164,14 @@ function ProgressOverview() {
}
function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview();
const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return (
<>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} />
</>
);
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
muted?: boolean;
daySpan?: number;
className?: string;
testId?: string;
}
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
return (
<div className={style.column}>
<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>
);
}
+2 -1
View File
@@ -300,7 +300,8 @@ export default function Rundown({ data }: RundownProps) {
isNextDay = checkIsNextDay(entry, lastEvent);
if (!isPast) {
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)) {
// populate previous entry
@@ -1,17 +1,15 @@
import { CSSProperties, useCallback } from 'react';
import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventEditorImage from './composite/EventEditorImage';
import EventCustom from './composite/EventEditorCustom';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea';
import EventTextInput from './composite/EventTextInput';
import EventEditorTriggers from './composite/EventEditorTriggers';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
{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;
})}
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
</Editor.Title>
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
</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>
);
}
@@ -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 { OffsetMode } from 'ontime-types';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
import RundownMenu from './RundownMenu';
@@ -12,6 +14,8 @@ export default function RundownHeader() {
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
const { offsetMode } = useOffsetMode();
return (
<div className={style.header}>
<ButtonGroup isAttached>
@@ -22,6 +26,22 @@ export default function RundownHeader() {
Edit
</Button>
</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 />
</div>
);
@@ -9,57 +9,9 @@
top: 75vh;
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 {
width: 100%;
height: var(--bottomThird-line-height-override, 0.5vh);
height: var(--lowerThird-line-height-override, 0.5vh);
}
.clip {
@@ -70,10 +22,10 @@
.data-top {
padding: 0 3vw;
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;
@include ellipsis-text();
&::after {
content: '\200b';
}
@@ -85,72 +37,24 @@
}
.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);
}
&--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 { useSearchParams } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions } from './lowerThird.options';
import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options';
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 {
customFields: CustomFields;
eventNow: OntimeEvent | null;
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) {
const { customFields, eventNow, viewSettings } = props;
const [searchParams] = useSearchParams();
const { customFields, eventNow, viewSettings, time } = props;
const previousId = useRef<string>();
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);
const options = useLowerOptions();
const { playback } = time;
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
useEffect(() => {
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
// if hold is negative then force animate in
if (options.hold < 0) {
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
useEffect(() => {
const hasChanged = eventNow?.id !== previousId.current;
if (!hasChanged) {
return;
if (hasChanged) {
previousId.current = eventNow?.id;
if (eventNow?.id) animateIn();
}
}, [animateIn, eventNow?.id]);
previousId.current = eventNow?.id;
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`;
const reschedule = (newState: 'pre' | 'in' | 'out') => {
clearTimeout(animationTimeout.current);
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`;
const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s';
return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div
className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
className={`container ${playState ? 'container--in' : 'container--out'}`}
style={{
minWidth: `${options.width}vw`,
transitionDuration: boxDuration,
transitionDelay: boxDelay,
}}
>
<div className='clip'>
<div
className='data-top'
style={{
animationDuration: transition,
transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`,
fontSize: options.topSize,
fontSize: `${options.topSize}em`,
}}
>
{topText}
{textValue.top}
</div>
</div>
<div
@@ -211,13 +130,14 @@ export default function LowerThird(props: LowerProps) {
<div
className='data-bottom'
style={{
animationDuration: transition,
transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`,
fontSize: options.bottomSize,
fontSize: `${options.bottomSize}em`,
}}
>
{bottomText}
{textValue.bottom}
</div>
</div>
</div>
@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
import safeParseNumber from '../../../common/utils/safeParseNumber';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
@@ -44,18 +47,32 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
collapsible: true,
options: [
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
id: 'transition-in',
title: 'Transition In',
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',
placeholder: '3 (default)',
},
{
id: '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',
placeholder: '3 (default)',
placeholder: '0 (default)',
},
],
},
@@ -69,14 +86,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
placeholder: '5em',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
placeholder: '4em',
},
{
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 {
font-size: calc(var(--clock-size) / 5);
font-size: calc(var(--clock-size) / 6);
line-height: 1em;
color: var(--studio-active, $red-active);
@@ -140,7 +140,7 @@ export const ScheduleProvider = ({
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
const viewEvents = events.slice(selectedEventIndex + 1);
selectedEventIndex = 0;
return (
@@ -16,7 +16,7 @@ import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { cuesheetOptions } from './cuesheet.options';
import { cuesheetOptions, useCuesheetOptions } from './cuesheet.options';
import styles from './CuesheetPage.module.scss';
@@ -28,8 +28,9 @@ export default function CuesheetPage() {
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
const [eventId, setEventId] = useState<string | null>(null);
const { hideCustom } = useCuesheetOptions();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const columns = useMemo(() => makeCuesheetColumns(hideCustom ? {} : customFields), [customFields, hideCustom]);
useWindowTitle('Cuesheet');
@@ -100,7 +100,7 @@ describe('makeTable()', () => {
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"",
@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types';
@@ -25,6 +26,10 @@ export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props;
const { updateEvent, updateTimer } = useEventAction();
const [searchParams] = useSearchParams();
const allowMainEdits = !searchParams.get('lmain');
const allowCustomEdits = !searchParams.get('lcustom');
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
@@ -77,6 +82,8 @@ export default function CuesheetTable(props: CuesheetTableProps) {
options: {
showDelayedTimes,
hideTableSeconds,
allowMainEdits,
allowCustomEdits,
},
},
});
@@ -104,7 +111,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet} id='cuesheet' {...listeners}>
<CuesheetHeader headerGroups={headerGroups} />
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
</table>
</div>
<CuesheetTableMenu showModal={showModal} />
@@ -19,7 +19,10 @@ function BlockRow(props: BlockRowProps) {
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 (
<tr className={style.blockRow}>
@@ -16,16 +16,24 @@ interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>;
columnSizing: Record<string, number>;
}
export default function CuesheetBody(props: CuesheetBodyProps) {
const { rowModel, selectedRef, table, columnSizing } = props;
const { rowModel, selectedRef, table } = props;
const { selectedEventId } = useSelectedEventId();
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;
// 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)) {
const columnCount = getColumnCount();
const columnCount = getVisibleColumns().length;
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
}
if (isOntimeDelay(entry)) {
@@ -58,6 +66,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
const columnHash = getColumnHash();
if (isPast && hidePast) {
return null;
@@ -87,7 +96,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour}
table={table}
columnSizing={columnSizing}
columnHash={columnHash}
/>
);
}
@@ -23,7 +23,7 @@ interface EventRowProps {
rowBgColour?: string;
table: Table<OntimeRundownEntry>;
/** 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) => {
@@ -35,8 +35,7 @@ export default memo(EventRow, (prevProps, nextProps) => {
prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.table === nextProps.table &&
prevProps.columnSizing === nextProps.columnSizing
prevProps.columnHash === nextProps.columnHash
);
});
@@ -6,12 +6,13 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface MultiLineCellProps {
initialValue: string;
handleUpdate: (newValue: string) => void;
allowEdits?: boolean;
}
export default memo(MultiLineCell);
function MultiLineCell(props: MultiLineCellProps) {
const { initialValue, handleUpdate } = props;
const { initialValue, handleUpdate, allowEdits } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -38,6 +39,7 @@ function MultiLineCell(props: MultiLineCellProps) {
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
isDisabled={!allowEdits}
/>
);
}
@@ -1,17 +1,18 @@
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
import { Input } from '@chakra-ui/react';
import { Input, Text } from '@chakra-ui/react';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
interface SingleLineCellProps {
initialValue: string;
allowSubmitSameValue?: boolean;
allowEdits?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
}
const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
const { initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate } = props;
const { initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate, allowEdits } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -38,6 +39,14 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
};
}, [ref]);
if (allowEdits === false) {
return (
<Text ref={ref} size='sm' variant='ontime-transparent' padding={0} fontSize='md'>
{initialValue}
</Text>
);
}
return (
<Input
ref={ref}
@@ -8,6 +8,7 @@ interface TimeInputDurationProps {
initialValue: number;
lockedValue: boolean;
delayed?: boolean;
allowEdits?: boolean;
onSubmit: (value: string) => void;
}
@@ -18,7 +19,7 @@ interface ParentFocusableInput extends HTMLInputElement {
export default memo(TimeInputDuration);
function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
const { initialValue, lockedValue, delayed, onSubmit, children } = props;
const { initialValue, lockedValue, delayed, onSubmit, children, allowEdits } = props;
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue);
@@ -86,7 +87,7 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
const timeString = millisToString(value);
return isEditing ? (
return isEditing && allowEdits ? (
<SingleLineCell
ref={inputRef}
initialValue={timeString}
@@ -32,7 +32,13 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unk
}
return (
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
<TimeInput
initialValue={startTime}
onSubmit={update}
lockedValue={isStartLocked}
delayed={delayValue !== 0}
allowEdits={table.options.meta?.options.allowMainEdits}
>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
</TimeInput>
@@ -60,7 +66,13 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unkno
}
return (
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
<TimeInput
initialValue={endTime}
onSubmit={update}
lockedValue={isEndLocked}
delayed={delayValue !== 0}
allowEdits={table.options.meta?.options.allowMainEdits}
>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
</TimeInput>
@@ -81,7 +93,12 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry,
const formattedDuration = formatDuration(duration, false);
return (
<TimeInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
<TimeInput
initialValue={duration}
onSubmit={update}
lockedValue={isDurationLocked}
allowEdits={table.options.meta?.options.allowMainEdits}
>
{formattedDuration}
</TimeInput>
);
@@ -103,7 +120,13 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
return (
<MultiLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowMainEdits}
/>
);
}
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
@@ -140,7 +163,13 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEn
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />;
return (
<SingleLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowMainEdits}
/>
);
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
@@ -158,7 +187,13 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
}
const initialValue = event.custom[column.id] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
return (
<MultiLineCell
initialValue={initialValue}
handleUpdate={update}
allowEdits={table.options.meta?.options.allowCustomEdits}
/>
);
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
@@ -49,6 +49,13 @@ export const cuesheetOptions: ViewOption[] = [
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCustomColumns',
title: 'Hide custom data columns',
description: 'Whether the hide the custom data in the table',
type: 'boolean',
defaultValue: false,
},
],
},
{
@@ -81,6 +88,7 @@ type CuesheetOptions = {
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
hideCustom: boolean;
};
/**
@@ -97,6 +105,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
hideCustom: isStringBoolean(searchParams.get('hideCustomColumns')),
};
}
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber);
return millisToString(data as MaybeNumber, { fallback: '' });
}
if (field === 'isPublic' || field === 'skip') {
@@ -25,7 +25,9 @@
flex: 1;
max-height: 100%;
overflow-y: auto;
width: min(calc(100vw - 4rem), 800px);
width: min(calc(100vw - 4rem), 960px);
padding-bottom: 10vh;
}
.info__label {
@@ -33,11 +35,15 @@
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
margin-top: $view-element-gap;
white-space: pre;
}
.info__value {
white-space: break-spaces;
}
a.info__value {
color: $action-text-color;
&:hover {
color: $ontime-color;
}
@@ -8,6 +8,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options';
@@ -66,6 +67,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)}
<BackstageInfo general={general} />
<PublicInfo general={general} />
<CustomInfo general={general} />
</div>
</div>
);
@@ -0,0 +1,36 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
{
id: 'showBackstage',
title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views',
description: 'Whether to show fields related to the backstage views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showPublic',
title: 'Show Public Data',
description: 'Weather to show fields related to the public views',
description: 'Whether to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean',
defaultValue: false,
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.14.3",
"version": "3.16.1-alpha.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.14.3",
"version": "3.16.1-alpha.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -52,6 +52,7 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "tsx ./scripts/bundleCss.ts",
"build": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js",
+24
View File
@@ -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 { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
/**
* Gets a copy of the stored automation settings
*/
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)) {
return;
}
// prevent deleting a automation that is in use
const triggers = getAutomationTriggers();
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (trigger.automationId === id) {
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
}
// prevent deleting a automation that is in use in triggers
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id);
if (triggers.length) {
throw new Error(
`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];
await saveChanges({ automations });
}
@@ -3,10 +3,10 @@ import {
isOntimeAction,
isOSCOutput,
LogOrigin,
TimerLifeCycle,
type AutomationFilter,
type AutomationOutput,
type FilterRule,
type TimerLifeCycle,
} from 'ontime-types';
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
*/
export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
if (!getAutomationsEnabled()) {
return;
}
const triggers = getAutomationTriggers();
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
if (triggerAutomations.length === 0) {
let triggers = getAutomationTriggers();
// 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;
}
@@ -39,7 +46,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
return;
}
triggerAutomations.forEach((trigger) => {
filteredTrigger.forEach((trigger) => {
const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) {
return;
+2 -1
View File
@@ -58,6 +58,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
},
});
@@ -203,7 +204,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/**
* Loads the demo project
*/
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const projectName = await projectService.loadDemoProject();
@@ -16,6 +16,7 @@ export const validateNewProject = [
body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
+2
View File
@@ -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 viewSettingsRouter } from './view-settings/viewSettings.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();
@@ -27,6 +28,7 @@ appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/session', sessionRouter);
appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => {
@@ -5,11 +5,11 @@ import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
import * as projectDao from './project.dao.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(getDataProvider().getProjectData());
res.json(projectDao.getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -27,6 +27,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo,
custom: req.body?.custom,
});
const updatedData = await editCurrentProjectData(newData);
@@ -0,0 +1,9 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
}
@@ -10,6 +10,9 @@ export const projectSanitiser = [
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -3,17 +3,26 @@ import { generateAuthenticatedUrl } from '../session.service.js';
describe('generateAuthenticatedUrl()', () => {
describe('for local IP addresses', () => {
it('generates a link without locking or authentication', () => {
const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false);
const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false, false, false);
expect(localhostNotLocked.toString()).toBe('http://localhost:3000/timer');
});
it('generates a link with IP locking enabled', () => {
const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false);
const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false, false, false);
expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?locked=true');
});
it('generates a link with authentication token and IP locking', () => {
const withAuth = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, true, undefined, '1234');
const withAuth = generateAuthenticatedUrl(
'http://192.168.10.173:4001',
'timer',
true,
false,
false,
true,
undefined,
'1234',
);
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&locked=true');
});
});
@@ -25,13 +34,23 @@ describe('generateAuthenticatedUrl()', () => {
'timer',
false,
false,
false,
false,
'prefix',
);
expect(cloudNotLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer');
});
it('generates a link with IP locking enabled', () => {
const ipLocked = generateAuthenticatedUrl('https://cloud.getontime.no/prefix', 'timer', true, false, 'prefix');
const ipLocked = generateAuthenticatedUrl(
'https://cloud.getontime.no/prefix',
'timer',
true,
false,
false,
false,
'prefix',
);
expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?locked=true');
});
@@ -40,6 +59,8 @@ describe('generateAuthenticatedUrl()', () => {
'https://cloud.getontime.no/prefix',
'timer',
true,
false,
false,
true,
'prefix',
'1234',
@@ -31,6 +31,8 @@ export async function generateUrl(req: Request, res: Response<GetUrl | ErrorResp
req.body.baseUrl,
req.body.path,
req.body.lock,
req.body.lockMainFields,
req.body.lockCustomFields,
req.body.authenticate,
);
res.status(200).send({ url: url.toString() });
@@ -59,6 +59,8 @@ export function generateAuthenticatedUrl(
baseUrl: string,
path: string,
lock: boolean,
lockMainFields: boolean,
lockCustomFields: boolean,
authenticate: boolean,
prefix = routerPrefix,
hash = hashedPassword,
@@ -72,5 +74,11 @@ export function generateAuthenticatedUrl(
if (lock) {
url.searchParams.append('locked', 'true');
}
if (lockMainFields) {
url.searchParams.append('lmain', 'true');
}
if (lockCustomFields) {
url.searchParams.append('lcustom', 'true');
}
return url;
}
@@ -5,6 +5,8 @@ export const validateGenerateUrl = [
body('baseUrl').exists().isString().notEmpty().trim(),
body('path').exists().isString().trim(),
body('lock').exists().isBoolean(),
body('lockMainFields').exists().isBoolean(),
body('lockCustomFields').exists().isBoolean(),
body('authenticate').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
@@ -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 { DeepPartial } from 'ts-essentials';
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null;
@@ -286,6 +287,11 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('No matching method provided');
},
offsetmode: (payload) => {
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
};
/**
@@ -63,7 +63,7 @@ function getData(): Readonly<DatabaseModel> {
}
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData };
db.data.project = { ...structuredClone(db.data.project), ...structuredClone(newData) }; // Performing deep copy as we're updating / merging data
await persist();
return db.data.project;
}
@@ -4,24 +4,27 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
const {
rundown = existing.rundown,
rundown = deepExisting.rundown,
project = {},
settings = {},
viewSettings = {},
urlPresets = existing.urlPresets,
customFields = existing.customFields,
automation = existing.automation,
} = newData;
urlPresets = deepExisting.urlPresets,
customFields = deepExisting.customFields,
automation = deepExisting.automation,
} = deepNewData;
return {
...existing,
...deepExisting,
rundown,
project: { ...existing.project, ...project },
settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? existing.urlPresets,
customFields: customFields ?? existing.customFields,
automation: { ...existing.automation, ...automation },
project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? deepExisting.urlPresets,
customFields: customFields ?? deepExisting.customFields,
automation: { ...deepExisting.automation, ...automation },
};
}
@@ -12,6 +12,12 @@ describe('safeMerge', () => {
publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
custom: [
{
title: 'existing custom title',
value: 'existing custom value',
},
],
},
settings: {
app: 'ontime',
@@ -62,6 +68,12 @@ describe('safeMerge', () => {
project: {
title: 'new title',
publicInfo: 'new public info',
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
},
};
// @ts-expect-error -- just testing
@@ -74,6 +86,12 @@ describe('safeMerge', () => {
backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
});
});
@@ -107,6 +125,7 @@ describe('safeMerge', () => {
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
},
settings: {
app: 'ontime',
+1 -1
View File
@@ -124,6 +124,6 @@ function setSessionCookie(res: Response, token: string) {
httpOnly: false, // allow websocket to access cookie
secure: true,
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)
});
}
+1
View File
@@ -11,6 +11,7 @@ export const dbModel: DatabaseModel = {
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
},
settings: {
app: 'ontime',
+1
View File
@@ -413,6 +413,7 @@ export const demoDb: DatabaseModel = {
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null,
custom: [],
},
settings: {
app: 'ontime',
@@ -740,8 +740,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(-50);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50);
});
it('added time subtracts time offset (positive offset)', () => {
@@ -763,8 +763,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(-60);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-60);
});
it('considers running overtime (negative offset)', () => {
@@ -787,8 +787,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(-10);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-10);
});
it('paused time is delayed time (negative offset)', () => {
@@ -812,8 +812,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(-25);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-25);
});
it('offset doesnt exist if we havent started', () => {
@@ -850,8 +850,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(0);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
});
it('handles loaded event', () => {
@@ -891,8 +891,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(81000000 - 79521653); // clock - timestart
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(81000000 - 79521653); // clock - timestart
});
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
@@ -944,8 +944,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(0);
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
@@ -997,8 +997,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(-400000); // <--- offset is always the overtime
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-400000); // <--- offset is always the overtime
});
it('handles time-to-end started after the end time', () => {
@@ -1040,9 +1040,84 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent;
const offset = getRuntimeOffset(state);
expect(millisToString(offset)).toBe('-00:16:40');
expect(offset).toBe(81000000 - 82000000); // <-- planned end - now
const { absoluteOffset } = getRuntimeOffset(state);
expect(millisToString(absoluteOffset)).toBe('-00:16:40');
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: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as RuntimeState;
const phase = getTimerPhase(state);
@@ -1208,9 +1285,11 @@ describe('getTimerPhase()', () => {
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as RuntimeState;
const phase = getTimerPhase(state);
@@ -314,17 +314,13 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
}
/**
* Changes the title of a project
* it handles invalidating the necessary data
* Patches the current project data
* Handles deleting the local logo if the logo has been removed
*/
export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData);
if (currentProjectData.title !== updatedProjectData.title) {
// something
}
// Delete the old logo if the logo has been removed
if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) {
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
@@ -108,7 +108,7 @@ export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventIds });
if (didMutate === false) {
if (!didMutate) {
return;
}
@@ -146,7 +146,7 @@ export async function editEvent(patch: PatchWithId) {
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
// short circuit if nothing changed
if (didMutate === false) {
if (!didMutate) {
return newEvent;
}
@@ -118,6 +118,7 @@ export enum regenerateWhitelist {
'timeWarning',
'timeDanger',
'custom',
'triggers',
}
/**
@@ -4,6 +4,7 @@ import {
isPlayableEvent,
LogOrigin,
MaybeNumber,
OffsetMode,
OntimeEvent,
Playback,
TimerLifeCycle,
@@ -68,6 +69,11 @@ class RuntimeService {
RuntimeService.previousState = {} as RuntimeState;
}
@broadcastResult
setOffsetMode(mode: OffsetMode) {
runtimeState.setOffsetMode(mode);
}
/**
* Checks result of an update and notifies integrations as needed
* 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
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
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
// combine all big changes
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;
/**
* Timer should be updated if
+19 -8
View File
@@ -116,10 +116,10 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is time ahead
* 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
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
@@ -133,18 +133,16 @@ export function getRuntimeOffset(state: RuntimeState): number {
const { clock } = state;
const { countToEnd, timeStart } = state.eventNow;
const { addedTime, current, startedAt } = state.timer;
const { actualStart, plannedStart } = state.runtime;
// if we havent started, but the timer is armed
// the offset is the difference to the schedule
if (startedAt === null) {
return timeStart - clock;
return { absoluteOffset: timeStart - clock, relativeOffset: 0 };
}
const overtime = Math.min(current, 0);
// in time-to-end, offset is overtime
if (countToEnd) {
return overtime;
}
const startOffset = timeStart - startedAt;
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)
// pausedTime - time the playback was paused (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) {
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 type { RuntimeState } from '../runtimeState.js';
@@ -16,10 +16,12 @@ const baseState: RuntimeState = {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
relativeOffset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
offsetMode: OffsetMode.Absolute,
},
timer: {
addedTime: 0,
@@ -35,10 +37,12 @@ const baseState: RuntimeState = {
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
_rundown: {
totalDelay: 0,
},
};
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
import {
type RuntimeState,
addTime,
clear,
clearState,
getState,
load,
loadBlock,
@@ -71,7 +71,7 @@ beforeAll(() => {
describe('mutation on runtimeState', () => {
beforeEach(() => {
clear();
clearState();
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
const actual = (await importOriginal()) as object;
@@ -246,7 +246,7 @@ describe('roll mode', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime('jan 1 00:00');
clear();
clearState();
});
afterEach(() => {
vi.useRealTimers();
+55 -18
View File
@@ -3,6 +3,7 @@ import {
isPlayableEvent,
MaybeNumber,
MaybeString,
OffsetMode,
OntimeEvent,
OntimeRundown,
PlayableEvent,
@@ -45,10 +46,12 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
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;
secondaryTarget: MaybeNumber;
};
_rundown: {
totalDelay: number; // this value comes from rundown service
};
};
const runtimeState: RuntimeState = {
@@ -62,10 +65,12 @@ const runtimeState: RuntimeState = {
timer: { ...runtimeStorePlaceholder.timer },
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
_rundown: {
totalDelay: 0,
},
};
export function getState(): Readonly<RuntimeState> {
@@ -79,10 +84,36 @@ export function getState(): Readonly<RuntimeState> {
runtime: { ...runtimeState.runtime },
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.publicEventNow = null;
runtimeState.eventNext = null;
@@ -93,6 +124,7 @@ export function clear() {
runtimeState.publicEventNext = null;
runtimeState.runtime.offset = 0;
runtimeState.runtime.relativeOffset = 0;
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
runtimeState.runtime.selectedEventIndex = null;
@@ -137,7 +169,7 @@ type RundownData = {
*/
export function updateRundownData(rundownData: RundownData) {
// we keep this in private state since there is no UI use case for it
runtimeState._timer.totalDelay = rundownData.totalDelay;
runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.runtime.numEvents = rundownData.numEvents;
runtimeState.runtime.plannedStart = rundownData.firstStart;
@@ -154,10 +186,7 @@ export function load(
rundown: OntimeRundown,
initialData?: Partial<TimerState & RestorePoint>,
): boolean {
// we need to persist the current block state across loads
const prevCurrentBlock = { ...runtimeState.currentBlock };
clear();
runtimeState.currentBlock = prevCurrentBlock;
clearEventData();
// filter rundown
const timedEvents = filterTimedEvents(rundown);
@@ -184,7 +213,9 @@ export function load(
const firstStart = initialData?.firstStart;
if (firstStart === null || typeof firstStart === 'number') {
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);
}
if (typeof initialData.blockStartAt === 'number') {
@@ -389,7 +420,9 @@ export function start(state: RuntimeState = runtimeState): boolean {
runtimeState.timer.phase = getTimerPhase(runtimeState);
// 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;
return true;
}
@@ -409,9 +442,7 @@ export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
clear();
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
clearState();
return true;
}
@@ -452,7 +483,9 @@ export function addTime(amount: number) {
runtimeState.timer.current += amount;
// 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);
return true;
@@ -497,7 +530,9 @@ export function update(): UpdateResult {
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
// 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);
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
const prevCurrentBlock = { ...runtimeState.currentBlock };
clear();
runtimeState.currentBlock = prevCurrentBlock;
clearEventData();
//account for offset but we only keep it if passed to us
runtimeState.runtime.offset = offset;
@@ -696,3 +729,7 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
// update the block anyway
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
}
export function setOffsetMode(mode: OffsetMode) {
runtimeState.runtime.offsetMode = mode;
}
+79
View File
@@ -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;
}
`;
+13 -5
View File
@@ -838,6 +838,7 @@ describe('getCustomFieldData()', () => {
lighting: 'lx',
sound: 'sound',
video: 'av',
ontime_label: 'excel label',
},
entryId: 'id',
} as ImportMap;
@@ -845,6 +846,7 @@ describe('getCustomFieldData()', () => {
const customFields: CustomFields = {
lighting: { label: 'lx', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
ontime_key: { label: 'ontime_label', type: 'string', colour: 'blue' },
};
const result = getCustomFieldData(importMap, customFields);
@@ -864,6 +866,11 @@ describe('getCustomFieldData()', () => {
colour: '',
label: 'video',
},
ontime_key: {
type: 'string',
colour: 'blue',
label: 'ontime_label',
},
});
// it is an inverted record of <importKey, ontimeKey>
@@ -871,6 +878,7 @@ describe('getCustomFieldData()', () => {
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
});
@@ -1022,8 +1030,8 @@ describe('parseExcel()', () => {
user2: { type: 'string', colour: 'blue', label: 'user2' },
};
const parsedData = parseExcel(testdata, existingCustomFields, importMap);
expect(parsedData.customFields).toStrictEqual({
const { customFields, rundown } = parseExcel(testdata, existingCustomFields, importMap);
expect(customFields).toStrictEqual({
user0: {
type: 'string',
colour: 'red',
@@ -1075,9 +1083,9 @@ describe('parseExcel()', () => {
label: 'user9',
},
});
expect(parsedData.rundown.length).toBe(2);
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
expect(rundown.length).toBe(2);
expect(rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
it('parses a file without custom fields', () => {

Some files were not shown because too many files have changed in this diff Show More