Compare commits

..

60 Commits

Author SHA1 Message Date
arc-alex bf3d426649 cleanup: millisToSeconds 2025-05-14 21:58:08 +02:00
arc-alex 55c6e70f4a rename 2025-05-14 21:58:08 +02:00
arc-alex f9f1679e8c tweek update rate 2025-05-14 21:58:08 +02:00
arc-alex fca54ab793 clean unused functions 2025-05-14 21:58:07 +02:00
arc-alex 5a38326fb4 clock "is" a count up timer 2025-05-14 21:58:07 +02:00
arc-alex 108db7c87a apply to Timer view 2025-05-14 21:58:07 +02:00
arc-alex 68080d0bc6 change millisToSeconds function 2025-05-14 21:58:07 +02:00
Alex Christoffer Rasmussen abd4369049 assert non null and update test (#1604) 2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 75c6016f4e Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen bdcec7a472 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 0a596624d5 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 94b80d3b0b fix test (#1601)
* fix test

* add comment
2025-05-14 20:07:42 +02:00
Carlos Valente 92fbef3515 fix: rebase master 2025-05-14 20:07:42 +02:00
Carlos Valente 10c2f0cf6c chore: correct test path 2025-05-14 20:07:42 +02:00
Carlos Valente e30b4fa8d5 refactor: extract rundown parsing
refactor: implement groups in editor
2025-05-14 20:07:42 +02:00
Carlos Valente 52358d75d3 chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente a55dfba4ef refactor: maintain flat orders 2025-05-14 20:07:42 +02:00
Carlos Valente ed7f8a0aa2 refactor: implement operations on nested events 2025-05-14 20:07:42 +02:00
Carlos Valente ba1292141b refactor: process events in rundown 2025-05-14 20:07:42 +02:00
Carlos Valente 57bd317dcf chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente 0cae37d6c8 chore: rename currentBlock > parent 2025-05-14 20:07:42 +02:00
Carlos Valente 6c8ddeec23 refactor: fix delay positioning in gaps 2025-05-14 20:07:42 +02:00
Carlos Valente 3f3ecce7b2 refactor(e2e): skip flaky test 2025-05-14 20:07:42 +02:00
Carlos Valente 402d10055b refactor: improve project loading 2025-05-14 20:07:42 +02:00
Carlos Valente 8f5a4f4a0a refactor: gather group metadata 2025-05-14 20:07:42 +02:00
Carlos Valente 7a78ed4720 refactor: swap maintains schedule 2025-05-14 20:07:42 +02:00
Carlos Valente de0ac92052 chore: rename files 2025-05-14 20:07:42 +02:00
Carlos Valente 58023dce0d refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:42 +02:00
Carlos Valente 0ae7178046 refactor: clearer relationship on rundown elements 2025-05-14 20:07:42 +02:00
Carlos Valente 6b01338118 refactor: use strict typing 2025-05-14 20:07:42 +02:00
Carlos Valente 7287cf8511 refactor: remove stop as a possible end action 2025-05-14 20:07:11 +02:00
Carlos Valente 6d17f188a6 refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:11 +02:00
Carlos Valente 440a9b81b6 chore: remove IDE files 2025-05-14 19:58:30 +02:00
Carlos Valente 5fafa08723 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-05-14 19:58:30 +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
176 changed files with 5235 additions and 3048 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.14.3",
"version": "3.15.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+8 -5
View File
@@ -1,28 +1,29 @@
{
"name": "ontime-ui",
"version": "3.14.3",
"version": "3.15.2",
"private": true,
"type": "module",
"dependencies": {
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3",
"@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0",
"@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;
}
+14 -13
View File
@@ -1,5 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
@@ -29,16 +30,16 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
}
/**
* HTTP request to post new event
* HTTP request to post new entry
*/
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
* HTTP request to edit an entry
*/
export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data);
}
@@ -48,9 +49,9 @@ type BatchEditEntry = {
};
/**
* HTTP request to put multiple events
* HTTP request to edit multiple events
*/
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data);
}
@@ -61,9 +62,9 @@ export type ReorderEntry = {
};
/**
* HTTP request to reorder events
* HTTP request to reorder an entry
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
@@ -82,15 +83,15 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
export async function requestApplyDelay(delayId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
}
/**
* HTTP request to delete given event
* HTTP request to delete entries
*/
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
}
/**
+2 -2
View File
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
/**
* Utility unrwap a potential axios error
* Utility unwrap a potential axios error
* @param error
* @returns
*/
@@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) {
}
/**
* Utility unrwaps a potential axios error and sends to logger
* Utility unwraps a potential axios error and sends to logger
* @param prepend
* @param error
*/
@@ -4,12 +4,13 @@ import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps {
copyValue: string;
label: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
size?: Size;
disabled?: boolean;
onClick?: () => void;
}
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction';
import { useEntryActions } from '../../../hooks/useEntryAction';
import style from './DelayInput.module.scss';
@@ -13,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props;
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
};
const submitChange = (value: number) => {
updateEvent({
updateEntry({
id: eventId,
duration: value,
});
@@ -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;
@@ -13,6 +13,7 @@ const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
isOntimeEvent,
MaybeString,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
Rundown,
@@ -12,19 +12,19 @@ import {
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
patchReorderEntry,
postAddEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
@@ -41,9 +41,9 @@ export type EventOptions = Partial<{
}>;
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
* Gather utilities for actions on entries
*/
export const useEventAction = () => {
export const useEntryActions = () => {
const queryClient = useQueryClient();
const {
defaultPublic,
@@ -56,8 +56,8 @@ export const useEventAction = () => {
defaultEndAction,
} = useEditorSettings();
const getEventById = useCallback(
(eventId: string) => {
const getEntryById = useCallback(
(eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.entries) {
return;
@@ -68,11 +68,12 @@ export const useEventAction = () => {
);
/**
* Calls mutation to add new event
* Calls mutation to add new entry
* @private
*/
const _addEventMutation = useMutation({
mutationFn: requestPostEvent,
const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
@@ -80,14 +81,14 @@ export const useEventAction = () => {
});
/**
* Adds an event to rundown
* Adds an entry to rundown
*/
const addEvent = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event };
const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
if (isOntimeEvent(newEntry)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
@@ -97,65 +98,60 @@ export const useEventAction = () => {
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEntry.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
newEntry.linkStart = applicationOptions.linkPrevious;
newEntry.isPublic = applicationOptions.defaultPublic;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = parseUserTime(defaultDuration);
if (newEntry.timeDanger === undefined) {
newEntry.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = parseUserTime(defaultDangerTime);
if (newEntry.timeWarning === undefined) {
newEntry.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime);
if (newEntry.timerType === undefined) {
newEntry.timerType = defaultTimerType;
}
if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType;
if (newEntry.endAction === undefined) {
newEntry.endAction = defaultEndAction;
}
if (newEvent.endAction === undefined) {
newEvent.endAction = defaultEndAction;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
if (newEntry.timeStrategy === undefined) {
newEntry.timeStrategy = defaultTimeStrategy;
}
}
// handle adding options that concern all event type
if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after;
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
(newEntry as TransientEventPayload).before = options.before;
}
try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
await _addEntryMutation.mutateAsync(newEntry);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
_addEventMutation,
_addEntryMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
@@ -169,11 +165,11 @@ export const useEventAction = () => {
);
/**
* Calls mutation to update existing event
* Calls mutation to update existing entry
* @private
*/
const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
const _updateEntryMutation = useMutation({
mutationFn: putEditEntry,
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
@@ -192,6 +188,7 @@ export const useEventAction = () => {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
@@ -213,35 +210,35 @@ export const useEventAction = () => {
});
/**
* Updates existing event
* Updates existing entry
*/
const updateEvent = useCallback(
const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
await _updateEntryMutation.mutateAsync(event);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation],
[_updateEntryMutation],
);
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: value } });
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEvent],
[updateEntry],
);
/**
* Updates time of existing event
* @param eventId {string} - id of the event
* @param eventId {EntryId} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
@@ -263,7 +260,7 @@ export const useEventAction = () => {
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.linkStart = value === '';
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
@@ -271,7 +268,7 @@ export const useEventAction = () => {
}
try {
await _updateEventMutation.mutateAsync(newEvent);
await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -323,7 +320,7 @@ export const useEventAction = () => {
return previousEnd;
}
},
[_updateEventMutation, queryClient],
[_updateEntryMutation, queryClient],
);
/**
@@ -331,7 +328,7 @@ export const useEventAction = () => {
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -359,10 +356,12 @@ export const useEventAction = () => {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
flatOrder: previousRundown.flatOrder,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous rundown
return { previousRundown };
},
@@ -387,13 +386,13 @@ export const useEventAction = () => {
);
/**
* Calls mutation to delete an event
* Calls mutation to delete an entry
* @private
*/
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
const _deleteEntryMutation = useMutation({
mutationFn: deleteEntries,
// we optimistically update here
onMutate: async (eventIds: string[]) => {
onMutate: async (entryIds: EntryId[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -402,17 +401,14 @@ export const useEventAction = () => {
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.entries };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder,
entries: newRundown,
order,
flatOrder,
entries,
revision: -1,
});
}
@@ -422,7 +418,7 @@ export const useEventAction = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
onError: (_error, _entryIds, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
@@ -434,24 +430,24 @@ export const useEventAction = () => {
});
/**
* Deletes an event form the list
* Deletes an event entry from the rundown
*/
const deleteEvent = useCallback(
async (eventIds: string[]) => {
const deleteEntry = useCallback(
async (entryIds: EntryId[]) => {
try {
await _deleteEventMutation.mutateAsync(eventIds);
await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[_deleteEventMutation],
[_deleteEntryMutation],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation({
const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
@@ -465,8 +461,9 @@ export const useEventAction = () => {
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
entries: {},
order: [],
flatOrder: [],
entries: {},
revision: -1,
});
@@ -474,8 +471,8 @@ export const useEventAction = () => {
return { previousData };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
// Mutation fails, rollback optimist update
onError: (_error, _, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
@@ -487,15 +484,15 @@ export const useEventAction = () => {
});
/**
* Deletes all events from list
* Deletes all entries in the rundown
*/
const deleteAllEvents = useCallback(async () => {
const deleteAllEntries = useCallback(async () => {
try {
await _deleteAllEventsMutation.mutateAsync();
await _deleteAllEntriesMutation.mutateAsync();
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [_deleteAllEventsMutation]);
}, [_deleteAllEntriesMutation]);
/**
* Calls mutation to apply a delay
@@ -511,7 +508,7 @@ export const useEventAction = () => {
});
/**
* Applies a given delay block
* Applies a given delay
*/
const applyDelay = useCallback(
async (delayEventId: string) => {
@@ -525,11 +522,11 @@ export const useEventAction = () => {
);
/**
* Calls mutation to reorder an event
* Calls mutation to reorder an entry
* @private
*/
const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
@@ -545,6 +542,7 @@ export const useEventAction = () => {
id: previousData.id,
title: previousData.title,
order: newOrder,
flatOrder: previousData.flatOrder,
entries: previousData.entries,
revision: -1,
});
@@ -555,7 +553,7 @@ export const useEventAction = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
onError: (_error, _data, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
@@ -567,22 +565,22 @@ export const useEventAction = () => {
});
/**
* Reorders a given event
* Reorders a given entry
*/
const reorderEvent = useCallback(
async (eventId: string, from: number, to: number) => {
const reorderEntry = useCallback(
async (entryId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId,
eventId: entryId,
from,
to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_reorderEventMutation],
[_reorderEntryMutation],
);
/**
@@ -616,6 +614,7 @@ export const useEventAction = () => {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
@@ -652,16 +651,45 @@ export const useEventAction = () => {
);
return {
addEvent,
addEntry,
applyDelay,
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
getEventById,
reorderEvent,
deleteEntry,
deleteAllEntries,
getEntryById,
reorderEntry,
swapEvents,
updateEvent,
updateEntry,
updateTimer,
updateCustomField,
};
};
/**
* Utility to optimistically delete entries from client cache
*/
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
const entries = { ...rundown.entries };
let order = [...rundown.order];
let flatOrder = [...rundown.flatOrder];
for (let i = 0; i < entryIds.length; i++) {
const entry = entries[entryIds[i]];
deleteEntry(entry);
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeEvent(entry) && entry.parent) {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.numEvents -= 1;
} else {
order = order.filter((id) => id !== entry.id);
}
delete entries[entry.id];
flatOrder = flatOrder.filter((id) => id !== entry.id);
}
return { entries, order, flatOrder };
}
+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';
@@ -150,7 +150,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,
}));
@@ -162,8 +162,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) => ({
@@ -179,6 +182,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,
+1 -1
View File
@@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
version: '4.0.0',
serverPort: 4001,
publicDir: '',
};
@@ -1,8 +1,7 @@
import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: '2.0.0',
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
@@ -0,0 +1 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
-12
View File
@@ -14,18 +14,6 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/**
* Allows patching a property of the runtime store
*/
@@ -22,6 +22,7 @@ describe('aggregateRundowns()', () => {
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
@@ -32,6 +33,7 @@ describe('aggregateRundowns()', () => {
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
@@ -1,4 +1,4 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
@@ -6,7 +6,7 @@ describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEvent.Event,
type: SupportedEntry.Event,
title: 'title',
cue: 'cue',
note: 'note',
@@ -15,7 +15,8 @@ describe('cloneEvent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
isPublic: false,
@@ -37,7 +38,7 @@ describe('cloneEvent()', () => {
expect(cloned.custom).not.toBe(original.custom);
expect(cloned).toMatchObject({
type: SupportedEvent.Event,
type: SupportedEntry.Event,
title: original.title,
note: original.note,
timeStart: original.timeStart,
@@ -45,6 +46,7 @@ describe('cloneEvent()', () => {
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
@@ -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?
});
@@ -1,4 +1,4 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
@@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEvent.Event,
type: SupportedEntry.Event,
title: event.title,
note: event.note,
timeStart: event.timeStart,
@@ -23,7 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
currentBlock: event.currentBlock,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
@@ -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;
}
+10 -57
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -47,10 +47,12 @@ export const connectSocket = () => {
websocket.onclose = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
@@ -139,59 +141,10 @@ export const connectSocket = () => {
updateDevTools(serverPayload);
break;
}
case 'ontime-clock': {
addToBatchUpdates('clock', payload);
updateDevTools({ clock: payload });
break;
}
case 'ontime-timer': {
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
case 'ontime-patch': {
const patch = payload as Partial<RuntimeStore>;
patchRuntime(patch);
updateDevTools(patch);
break;
}
case 'ontime-refetch': {
@@ -211,8 +164,8 @@ export const connectSocket = () => {
}
break;
}
case 'ontime-flush': {
flushBatchUpdates();
default: {
console.log('unknown WS message', type);
break;
}
}
+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;
}
@@ -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';
@@ -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>
);
}
@@ -2,7 +2,7 @@ import { IoAdd, IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks';
import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import { MILLIS_PER_HOUR, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -17,29 +17,29 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const [timeInMs, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => {
const newTime = parseUserTime(value);
const newTimeInMs = parseUserTime(value);
// cap add time to 1 hour
setTime(Math.min(newTime, MILLIS_PER_HOUR));
setTime(Math.min(newTimeInMs, MILLIS_PER_HOUR));
};
const handleAddTime = (direction: 'add' | 'remove') => {
// API expects input in seconds
// API expects input in milliseconds
if (direction === 'add') {
setPlayback.addTime(time / MILLIS_PER_SECOND);
setPlayback.addTime(timeInMs);
} else {
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND);
setPlayback.addTime(-1 * timeInMs);
}
};
const canAddTime = playback === Playback.Play || playback === Playback.Pause;
const doDisableButtons = !canAddTime || time === 0;
const doDisableButtons = !canAddTime || timeInMs === 0;
return (
<div className={style.addTime}>
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' />
<TimeInput name='addtime' submitHandler={handleTimeChange} time={timeInMs} placeholder='Add time' />
<div className={style.addButtons}>
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
@@ -59,15 +59,20 @@ export function AuxTimer() {
}
function AuxTimerInput() {
const time = useAuxTimerTime();
const newTimeInMs = useAuxTimerTime();
const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value);
setDuration(newTime / 1000); // frontend api is seconds based
const newTimeInMs = parseUserTime(value);
setDuration(newTimeInMs);
};
return (
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
<TimeInput<'auxTimer'>
submitHandler={handleTimeUpdate}
name='auxTimer'
time={newTimeInMs}
placeholder='Aux Timer 1'
/>
);
}
@@ -1,7 +1,7 @@
import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEvent } from 'ontime-types';
import { SupportedEntry } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection';
@@ -67,9 +67,9 @@ export default function Finder(props: FinderProps) {
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : '';
const colour = entry.type === SupportedEvent.Event ? entry.colour : '';
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
return (
<li
@@ -1,19 +1,19 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12;
type FilterableBlock = {
type: SupportedEvent.Block;
type: SupportedEntry.Block;
id: string;
index: number;
title: string;
};
type FilterableEvent = {
type: SupportedEvent.Event;
type: SupportedEntry.Event;
id: string;
index: number;
eventIndex: number;
@@ -59,7 +59,7 @@ export default function useFinder() {
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEvent.Event,
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
@@ -93,7 +93,7 @@ export default function useFinder() {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
@@ -126,7 +126,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
@@ -141,7 +141,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Block,
type: SupportedEntry.Block,
id: event.id,
index: i,
title: event.title,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeEvent, SupportedEntry } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -197,7 +197,7 @@ export default function Operator() {
);
}
if (entry.type === SupportedEvent.Block) {
if (entry.type === SupportedEntry.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />;
}
return null;
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss';
@@ -15,7 +15,7 @@ interface EditModalProps {
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
@@ -36,7 +36,7 @@ export default function EditModal(props: EditModalProps) {
});
if (patchObject.custom) {
await updateEvent(patchObject);
await updateEntry(patchObject);
}
setLoading(false);
@@ -170,7 +170,7 @@ function RuntimeOverview() {
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>
);
}
@@ -0,0 +1,4 @@
.empty {
padding-block: 1.5rem;
text-align: center;
}
@@ -23,15 +23,6 @@
align-self: center;
}
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 1.5rem;
}
}
.spacer {
min-height: 50vh;
}
@@ -40,6 +31,7 @@
display: flex;
gap: 0.5rem;
align-items: center;
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
}
.entryIndex {
+224 -112
View File
@@ -1,38 +1,48 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import {
type EntryId,
type MaybeString,
type PlayableEvent,
type Rundown,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
OntimeBlock,
OntimeEntry,
Playback,
SupportedEvent,
SupportedEntry,
} from 'ontime-types';
import {
checkIsNextDay,
getFirstNormal,
getLastNormal,
getNextBlockNormal,
getNextNormal,
getPreviousBlockNormal,
getPreviousNormal,
isNewLatest,
reorderArray,
} from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/eventsManager';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -45,11 +55,17 @@ interface RundownProps {
}
export default function Rundown({ data }: RundownProps) {
const { order, entries } = data;
const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
const { order, flatOrder, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(flatOrder, entries));
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy();
@@ -68,12 +84,12 @@ export default function Rundown({ data }: RundownProps) {
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]);
deleteEntry([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[entries, order, deleteEvent, setSelectedEvents],
[entries, order, deleteEntry, setSelectedEvents],
);
const insertCopyAtId = useCallback(
@@ -84,17 +100,17 @@ export default function Rundown({ data }: RundownProps) {
return;
}
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) {
if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined });
addEntry(newEvent, { after: adjustedCursor ?? undefined });
}
},
[addEvent, order, entries],
[addEntry, order, entries],
);
const insertAtId = useCallback(
(type: SupportedEvent, id: MaybeString, above = false) => {
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
const options: EventOptions =
id === null
? {}
@@ -103,19 +119,12 @@ export default function Rundown({ data }: RundownProps) {
before: above ? id : undefined,
};
if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
if (!above && id) {
options.lastEventId = id;
}
addEvent(newEvent, options);
} else {
addEvent({ type }, options);
if (!above && id) {
options.lastEventId = id;
}
addEntry(patch, options);
},
[addEvent],
[addEntry],
);
const selectBlock = useCallback(
@@ -188,10 +197,10 @@ export default function Rundown({ data }: RundownProps) {
if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index);
reorderEntry(cursor, offsetIndex, index);
}
},
[order, reorderEvent, entries],
[order, reorderEntry, entries],
);
// shortcuts
@@ -209,14 +218,14 @@ export default function Rundown({ data }: RundownProps) {
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + E', () => insertAtId({ type: SupportedEntry.Event }, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId({ type: SupportedEntry.Block }, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
@@ -228,11 +237,11 @@ export default function Rundown({ data }: RundownProps) {
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
setStatefulEntries(order);
}, [order]);
setSortableData(makeSortableList(flatOrder, entries));
}, [flatOrder, entries]);
// in run mode, we follow selection
useEffect(() => {
// in run mode, we follow selection
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
@@ -240,6 +249,36 @@ export default function Rundown({ data }: RundownProps) {
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
/**
* Checks whether a block is collapsed
*/
const getIsCollapsed = useCallback(
(blockId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === blockId));
},
[collapsedGroups],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
},
[getIsCollapsed, setCollapsedGroups],
);
/**
* On drag end, we reorder the events
*/
@@ -252,103 +291,176 @@ export default function Rundown({ data }: RundownProps) {
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setStatefulEntries((currentEntries) => {
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEvent(String(active.id), fromIndex, toIndex);
reorderEntry(String(active.id), fromIndex, toIndex);
}
}
};
if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
/**
* When we drag a block, we force collapse it
* This avoids strange scenarios like dropping a block inside itself
*/
const collapseDraggedBlocks = (event: DragStartEvent) => {
const isBlock = event.active.data.current?.type === 'block';
if (isBlock) {
handleCollapseGroup(true, event.active.id as EntryId);
}
};
/**
* When we drag over a block, we expand it if it is collapsed
*/
const expandOverBlock = (event: DragOverEvent) => {
// if we are dragging a block, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'block') {
return;
}
if (event.over?.data.current?.type !== 'block') {
return;
}
const blockId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(blockId);
if (isCollapsed) {
handleCollapseGroup(false, blockId);
}
};
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
}
// last event is used to calculate relative timings
let lastEvent: PlayableEvent | null = null; // used by indicators
let thisEvent: PlayableEvent | null = null;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: MaybeString = null;
let thisId: MaybeString = null;
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
let totalGap = 0;
// 1. gather presentation options
const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
// 2. initialise rundown metadata
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
// keep a single reference to the metadata which we override for every entry
let rundownMetadata = metadata;
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
<DndContext
onDragEnd={handleOnDragEnd}
onDragStart={collapseDraggedBlocks}
onDragOver={expandOverBlock}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{statefulEntries.map((entryId, index) => {
// we iterate through a stateful copy of order to make the operations smoother
{sortableData.map((entryId, index) => {
const isFirst = index === 0;
const isLast = index === sortableData.length - 1;
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
const isBlockCollapsed = getIsCollapsed(parentId);
if (isBlockCollapsed && isEditMode && isLast) {
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
} else {
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddBlock
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
return (
<Fragment key={entryId}>
{showPrependingQuickAdd && (
<QuickAddBlock
previousEventId={rundownMetadata.previousEntryId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
</Fragment>
);
}
}
// we iterate through a stateful copy of order to make the dnd operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) {
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Block &&
rundownMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId)
) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
// event indexes are 1 based in frontend
eventIndex++;
lastEvent = thisEvent;
if (isPlayableEvent(entry)) {
isNextDay = checkIsNextDay(entry, lastEvent);
if (!isPast) {
totalGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
}
if (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry;
}
}
}
const isFirst = index === 0;
const isLast = index === order.length - 1;
const isLoaded = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false;
}
/**
* Outside a block, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a block, but there is no defined colour
* we default to $gray-1050 #303030
*/
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
return (
<Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={isPast}
eventIndex={eventIndex}
data={entry}
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={previousEntryId}
previousEventId={lastEvent?.id}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
{isEditMode && (hasCursor || isFirst) && (
<QuickAddBlock
previousEventId={rundownMetadata.previousEntryId}
parentBlock={isFirst ? null : rundownMetadata.groupId}
backgroundColor={isFirst ? undefined : blockColour}
/>
)}
{isOntimeBlock(entry) ? (
<BlockBlock
data={entry}
hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)}
onCollapse={handleCollapseGroup}
/>
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${rundownMetadata.eventIndex}`}
style={blockColour ? { '--user-bg': blockColour } : {}}
>
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={rundownMetadata.isPast}
eventIndex={rundownMetadata.eventIndex}
data={entry}
loaded={rundownMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.previousEvent?.id}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay}
totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
/>
</div>
</div>
</div>
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
)}
{isEditMode && (hasCursor || isLast) && (
<QuickAddBlock
previousEventId={entry.id}
parentBlock={rundownMetadata.groupId}
backgroundColor={blockColour}
/>
)}
</Fragment>
);
})}
@@ -3,7 +3,7 @@ import { Button } from '@chakra-ui/react';
import Empty from '../../common/components/state/Empty';
import style from './Rundown.module.scss';
import style from './Empty.module.scss';
interface RundownEmptyProps {
handleAddNew: () => void;
@@ -13,9 +13,9 @@ export default function RundownEmpty(props: RundownEmptyProps) {
const { handleAddNew } = props;
return (
<div className={style.alignCenter}>
<Empty style={{ marginTop: '7vh' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
<div className={style.empty}>
<Empty style={{ marginTop: '7vh', marginBottom: '1.5rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event
</Button>
</div>
@@ -1,21 +1,19 @@
import { useCallback } from 'react';
import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEvent,
SupportedEntry,
} from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
@@ -35,7 +33,7 @@ export type EventItemActions =
| 'clear-report';
interface RundownEntryProps {
type: SupportedEvent;
type: SupportedEntry;
isPast: boolean;
data: OntimeEntry;
loaded: boolean;
@@ -68,7 +66,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
@@ -88,31 +86,31 @@ export default function RundownEntry(props: RundownEntryProps) {
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
switch (action) {
case 'event': {
const newEvent = { type: SupportedEvent.Event };
const newEvent = { type: SupportedEntry.Event };
const options = {
after: data.id,
lastEventId: previousEventId,
};
return addEvent(newEvent, options);
return addEntry(newEvent, options);
}
case 'event-before': {
const newEvent = { type: SupportedEvent.Event };
const newEvent = { type: SupportedEntry.Event };
const options = {
after: previousEntryId,
};
return addEvent(newEvent, options);
return addEntry(newEvent, options);
}
case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
}
case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId });
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
}
case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
return addEntry({ type: SupportedEntry.Block }, { after: data.id });
}
case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId });
return addEntry({ type: SupportedEntry.Block }, { after: previousEntryId });
}
case 'swap': {
const { value } = payload as FieldValue;
@@ -121,14 +119,14 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'delete': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return deleteEvent(Array.from(selectedEvents));
return deleteEntry(Array.from(selectedEvents));
}
removeOpenEvent();
return deleteEvent([data.id]);
return deleteEntry([data.id]);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent);
addEvent(newEvent, { after: data.id });
addEntry(newEvent, { after: data.id });
break;
}
case 'update': {
@@ -149,7 +147,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (field in data) {
// @ts-expect-error -- not sure how to type this
newData[field] = value;
return updateEvent(newData);
return updateEntry(newData);
}
return emitError(`Unknown field: ${field}`);
@@ -181,6 +179,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isPast={isPast}
isNext={isNext}
skip={data.skip}
parent={data.parent}
loaded={loaded}
hasCursor={hasCursor}
playback={playback}
@@ -193,14 +192,6 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler}
/>
);
} else if (isOntimeBlock(data)) {
return (
<BlockBlock data={data} hasCursor={hasCursor}>
{data.events.map((eventId) => {
return <div key={eventId}>{eventId}</div>;
})}
</BlockBlock>
);
} else if (isOntimeDelay(data)) {
return <DelayBlock data={data} hasCursor={hasCursor} />;
}
@@ -0,0 +1,314 @@
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = makeRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['block'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['block'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['block'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: true,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
});
it('populates previousEntries in blocks', () => {
const rundownStartsWithBlock = {
block: {
id: 'block',
type: SupportedEntry.Block,
colour: 'red',
events: ['1', '2'],
} as OntimeBlock,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = makeRundownMetadata(null);
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithBlock.block.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithBlock['1'],
previousEntryId: rundownStartsWithBlock.block.id,
thisId: rundownStartsWithBlock['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
previousEvent: rundownStartsWithBlock['1'],
latestEvent: rundownStartsWithBlock['2'],
previousEntryId: rundownStartsWithBlock['1'].id,
thisId: rundownStartsWithBlock['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
});
});
describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toEqual([
'block-1',
'11',
'end-block-1',
'2',
'block-3',
'31',
'end-block-3',
'block-4',
'end-block-4',
]);
});
it('closes dangling blocks', () => {
const flatOrder = ['block', '11', '12'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const flatOrder = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
@@ -1,7 +1,7 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
$block-width: 33rem;
$block-width: 32rem;
$block-gap: 0.25rem;
$block-element-spacing: 0.25rem;
@@ -20,7 +20,6 @@ $block-cursor-color: $orange-400;
box-sizing: content-box;
border: 1px solid $white-7;
border-radius: $block-border-radius;
margin-block: 0.25rem;
position: relative;
color: $block-text-color;
@@ -33,9 +32,11 @@ $block-cursor-color: $orange-400;
opacity: 0.3;
cursor: grab;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
&:focus {
box-shadow: none;
outline: none;
@@ -2,21 +2,73 @@
.block {
@include block-styling;
overflow: hidden;
background-color: $block-bg2;
min-width: 34rem;
display: grid;
grid-template-columns: 2rem 1fr auto;
grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
align-items: center;
height: $secondary-block-height;
gap: 0.5rem;
// TODO(style fix): groups have an extra bottom margin which interrupt colour
margin-block: 0.25rem;
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.expanded {
border-radius: $block-border-radius $block-border-radius 0 0;
}
.binder {
grid-area: binder;
height: 100%;
background-color: var(--block-color, $gray-1050);
color: $section-white;
font-size: 1rem;
display: grid;
justify-content: center;
padding-top: 0.25rem;
}
.header {
grid-area: header;
padding-inline: 0.5rem;
background-color: $block-bg2;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metaRow {
display: flex;
gap: 3rem;
margin-bottom: 0.25rem;
}
.metaEntry {
width: 4.5em;
:first-child {
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
}
.drag {
@include drag-style;
&.isDragging {
cursor: grabbing;
}
&.notAllowed {
cursor: not-allowed;
}
}
@@ -1,22 +1,25 @@
import { PropsWithChildren, useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5';
import { useRef } from 'react';
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { OntimeBlock } from 'ontime-types';
import { EntryId, OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { canDrop } from '../rundown.utils';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
collapsed: boolean;
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
}
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
const { data, hasCursor, children } = props;
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
@@ -25,26 +28,72 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
setNodeRef,
transform,
transition,
isDragging,
isOver,
over,
} = useSortable({
id: data.id,
data: {
type: 'block',
},
animateLayoutChanges: () => false,
});
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
};
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<button>+++</button>
<div>{children}</div>
<div
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
ref={setNodeRef}
style={{
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
...dragStyle,
}}
>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
ref={handleRef}
{...dragAttributes}
{...dragListeners}
>
<IoReorderTwo />
</span>
</div>
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<button onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</button>
</div>
<div className={style.metaRow}>
<div className={style.metaEntry}>
<div>Start</div>
<div>{formatTime(data.startTime)}</div>
</div>
<div className={style.metaEntry}>
<div>End</div>
<div>{formatTime(data.endTime)}</div>
</div>
<div className={style.metaEntry}>
<div>Duration</div>
<div>{formatDuration(data.duration)}</div>
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.numEvents}</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
@use '../blockMixins' as *;
.blockEnd {
cursor: default;
height: 0.5rem;
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
margin-bottom: 0.25rem;
}
@@ -0,0 +1,42 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import style from './BlockEnd.module.scss';
interface BlockEndProps {
id: string;
colour?: string;
}
export default function BlockEnd(props: BlockEndProps) {
const { id, colour } = props;
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id,
animateLayoutChanges: () => false,
disabled: true, // we do not want to drag end blocks
});
const dragStyle = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
className={style.blockEnd}
ref={setNodeRef}
{...dragAttributes}
{...dragListeners}
style={{
...dragStyle,
...(colour ? { '--user-bg': colour } : {}),
}}
/>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss';
@@ -16,7 +16,7 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props;
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback(
(text: string) => {
@@ -25,9 +25,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
}
const cleanVal = text.trim();
updateEvent({ id: eventId, title: cleanVal });
updateEntry({ id: eventId, title: cleanVal });
},
[title, updateEvent, eventId],
[title, updateEntry, eventId],
);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
@@ -3,6 +3,7 @@
.delay {
@include block-styling;
margin-block: 0.25rem;
background-color: $block-bg2;
padding-right: 0.5rem;
@@ -6,7 +6,7 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss';
@@ -18,21 +18,26 @@ interface DelayBlockProps {
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction();
const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
isDragging,
transform,
transition,
} = useSortable({
id: data.id,
data: {
type: 'delay',
},
animateLayoutChanges: () => false,
});
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -48,7 +53,7 @@ export default function DelayBlock(props: DelayBlockProps) {
};
const cancelDelayHandler = () => {
deleteEvent([data.id]);
deleteEntry([data.id]);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
.eventBlock {
@include block-styling;
background-color: $block-bg;
margin-block: 0.25rem;
display: grid;
grid-template-areas:
@@ -12,7 +12,7 @@ import {
} from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -32,7 +32,7 @@ interface EventBlockProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
isPublic: boolean;
@@ -45,6 +45,7 @@ interface EventBlockProps {
isPast: boolean;
isNext: boolean;
skip: boolean;
parent: EntryId | null;
loaded: boolean;
hasCursor: boolean;
playback?: Playback;
@@ -86,6 +87,7 @@ export default function EventBlock(props: EventBlockProps) {
isPast,
isNext,
skip = false,
parent,
loaded,
hasCursor,
playback,
@@ -151,7 +153,7 @@ export default function EventBlock(props: EventBlockProps) {
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: linkStart ? null : 'true',
value: linkStart,
}),
},
{
@@ -193,6 +195,10 @@ export default function EventBlock(props: EventBlockProps) {
transition,
} = useSortable({
id: eventId,
data: {
type: 'event',
parent,
},
animateLayoutChanges: () => false,
});
@@ -11,7 +11,7 @@ import {
IoTime,
} from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -30,7 +30,7 @@ interface EventBlockInnerProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
isPublic: boolean;
@@ -2,7 +2,7 @@ import { memo, MouseEvent } from 'react';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,11 +34,11 @@ interface EventBlockPlaybackProps {
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => {
event.stopPropagation();
updateEvent({ id: eventId, skip: !skip });
updateEntry({ id: eventId, skip: !skip });
};
const actionHandler = (event: MouseEvent) => {
@@ -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 { useEntryActions } from '../../../common/hooks/useEntryAction';
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';
@@ -25,7 +23,7 @@ interface EventEditorProps {
export default function EventEditor(props: EventEditorProps) {
const { event } = props;
const { data: customFields } = useCustomFields();
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor');
@@ -33,12 +31,12 @@ export default function EventEditor(props: EventEditorProps) {
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: value } });
updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
} else {
updateEvent({ id: event?.id, [field]: value });
updateEntry({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
[event?.id, updateEntry],
);
if (!event) {
@@ -75,52 +73,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>
);
@@ -22,7 +22,7 @@ export default function RundownEventEditor() {
return;
}
const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId));
const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) {
setEvent(null);
return;
@@ -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>
);
}
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
linkStart: boolean;
countToEnd: boolean;
delay: number;
isPublic: boolean;
@@ -46,27 +46,27 @@ function EventEditorTimes(props: EventEditorTimesProps) {
timeWarning,
timeDanger,
} = props;
const { updateEvent } = useEventAction();
const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') {
updateEvent({ id: eventId, isPublic: !(value as boolean) });
updateEntry({ id: eventId, isPublic: !(value as boolean) });
return;
}
if (field === 'countToEnd') {
updateEvent({ id: eventId, countToEnd: !(value as boolean) });
updateEntry({ id: eventId, countToEnd: !(value as boolean) });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime });
updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEvent({ id: eventId, [field]: value });
updateEntry({ id: eventId, [field]: value });
return;
}
};
@@ -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 { useEntryActions } from '../../../../common/hooks/useEntryAction';
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 { updateEntry } = useEntryActions();
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 });
updateEntry({ 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 { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEntry({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEntry],
);
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,
];
@@ -3,8 +3,10 @@
align-items: center;
gap: 1rem;
margin: 0.25rem 0;
padding-block: 0.5rem;
font-size: calc(1rem - 3px);
padding-left: calc(2em + 0.5rem);
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
}
.quickBtn {
@@ -1,73 +1,76 @@
import { memo, useCallback, useRef } from 'react';
import { memo, useRef } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { MaybeString, SupportedEvent } from 'ontime-types';
import { MaybeString, SupportedEntry } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEmitLog } from '../../../common/stores/logger';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps {
previousEventId: MaybeString;
parentBlock: MaybeString;
backgroundColor?: string;
}
export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId } = props;
const { addEvent } = useEventAction();
const { emitError } = useEmitLog();
const { previousEventId, parentBlock, backgroundColor } = props;
const { addEntry } = useEntryActions();
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null);
const handleCreateEvent = useCallback(
(eventType: SupportedEvent) => {
switch (eventType) {
case 'event': {
const defaultPublic = doPublic?.current?.checked;
const linkPrevious = doLinkPrevious?.current?.checked;
const addEvent = () => {
addEntry(
{
type: SupportedEntry.Event,
parent: parentBlock,
},
{
after: previousEventId,
defaultPublic: doPublic?.current?.checked,
lastEventId: previousEventId,
linkPrevious: doLinkPrevious?.current?.checked,
},
);
};
const newEvent = { type: SupportedEvent.Event };
const options = {
after: previousEventId,
defaultPublic,
lastEventId: previousEventId,
linkPrevious,
};
addEvent(newEvent, options);
break;
}
case 'delay': {
const options = {
lastEventId: previousEventId,
after: previousEventId,
};
addEvent({ type: SupportedEvent.Delay }, options);
break;
}
case 'block': {
const options = {
lastEventId: previousEventId,
after: previousEventId,
};
addEvent({ type: SupportedEvent.Block }, options);
break;
}
default: {
emitError(`Cannot create unknown event type: ${eventType}`);
break;
}
}
},
[previousEventId, addEvent, emitError],
);
const addDelay = () => {
addEntry(
{ type: SupportedEntry.Delay, parent: parentBlock },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addBlock = () => {
if (parentBlock !== null) {
return;
}
addEntry(
{ type: SupportedEntry.Block },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
/**
* If the colour is empty string ''
* ie: we are inside a block, but there is no defined colour
* we default to $gray-1050 #303030
*/
const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
return (
<div className={style.quickAdd}>
<div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Event)}
onClick={addEvent}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
@@ -77,7 +80,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
Event
</Button>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
onClick={addDelay}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
@@ -86,16 +89,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
>
Delay
</Button>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Block)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
leftIcon={<IoAdd />}
color='#b1b1b1' // $gray-400
>
Block
</Button>
{parentBlock === null && (
<Button
onClick={addBlock}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
leftIcon={<IoAdd />}
color='#b1b1b1' // $gray-400
>
Block
</Button>
)}
</div>
);
}
@@ -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>
);
@@ -11,23 +11,23 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction();
const { deleteAllEntries } = useEntryActions();
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement | null>(null);
const deleteAll = useCallback(() => {
deleteAllEvents();
deleteAllEntries();
clearSelectedEvents();
onClose();
}, [clearSelectedEvents, deleteAllEvents, onClose]);
}, [clearSelectedEvents, deleteAllEntries, onClose]);
return (
<>
@@ -0,0 +1,180 @@
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
PlayableEvent,
RundownEntries,
SupportedEntry,
} from 'ontime-types';
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
type RundownMetadata = {
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
thisId: MaybeString;
eventIndex: number;
isPast: boolean;
isNextDay: boolean;
totalGap: number;
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
isLoaded: boolean;
groupId: MaybeString;
groupColour: string | undefined;
};
/**
* Creates a process function which aggregates the rundown metadata and event metadata
*/
export function makeRundownMetadata(selectedEventId: MaybeString) {
let rundownMeta: RundownMetadata = {
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
rundownMeta = processedRundownMetadata;
return rundownMeta;
}
return { metadata: rundownMeta, process };
}
/**
* Receives a rundown entry and processes its place in the rundown
*/
function processEntry(
rundownMetadata: RundownMetadata,
selectedEventId: MaybeString,
entry: Readonly<OntimeEntry>,
): Readonly<RundownMetadata> {
const processedData = { ...rundownMetadata };
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
}
if (isOntimeBlock(entry)) {
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
} else {
// for delays and blocks, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
if ((entry as OntimeEvent | OntimeDelay).parent === null) {
// if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined;
}
}
if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI
processedData.eventIndex += 1;
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
}
if (isNewLatest(entry, processedData.latestEvent)) {
// this event is the forward most event in rundown, for next iteration
processedData.latestEvent = entry;
}
}
}
}
return processedData;
}
/**
* Creates a sortable list of entries
* ------------------------------------
* Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: block ends)
*/
export function makeSortableList(flatOrder: EntryId[], entries: RundownEntries): EntryId[] {
const entryIds: EntryId[] = [];
let lastSeenBlock: MaybeString = null;
for (let i = 0; i < flatOrder.length; i++) {
const entry = entries[flatOrder[i]];
if (!entry) {
continue;
}
if (isOntimeBlock(entry)) {
// close any previous blocks
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.id;
}
if (isOntimeEvent(entry)) {
// Close the previous block if the parent changes
if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.parent;
}
entryIds.push(entry.id);
}
// double check that we close any dangling blocks
// - if the last element is a block
// - if a rundown only has a top level block
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
return entryIds;
}
/**
* Checks whether a drop operation is valid
* Currently only used for validating dropping blocks
*/
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
if (targetType === 'event' || targetType === 'delay') {
return targetParent === null;
}
// remaining events will be block or end-block
// we can swap places with other blocks
return targetType == 'block';
}
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5';
import { InputRightElement, Tooltip } from '@chakra-ui/react';
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
import { TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -19,14 +19,14 @@ interface EventBlockTimerProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
linkStart: boolean;
delay: number;
showLabels?: boolean;
}
function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
const { updateEvent, updateTimer } = useEventAction();
const { updateEntry, updateTimer } = useEntryActions();
// In sync with EventEditorTimes
const handleSubmit = (field: TimeField, value: string) => {
@@ -34,11 +34,11 @@ function TimeInputFlow(props: EventBlockTimerProps) {
};
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
updateEvent({ id: eventId, timeStrategy });
updateEntry({ id: eventId, timeStrategy });
};
const handleLink = (doLink: boolean) => {
updateEvent({ id: eventId, linkStart: doLink ? 'true' : null });
updateEntry({ id: eventId, linkStart: doLink });
};
const warnings = [];
@@ -55,9 +55,9 @@ function TimeInputFlow(props: EventBlockTimerProps) {
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
const activeStart = cx([style.timeAction, linkStart && style.active]);
const activeEnd = cx([style.timeAction, isLockedEnd && style.active]);
const activeDuration = cx([style.timeAction, isLockedDuration && style.active]);
return (
<>
@@ -69,7 +69,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
disabled={Boolean(linkStart)}
disabled={linkStart}
>
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types';
import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
@@ -66,11 +66,11 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
if (!rundownData) return;
// get list of rundown with only ontime events
const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => {
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
events.push(event);
eventIds.push(event.id);
}
});
@@ -78,7 +78,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = events.slice(start, end).map((event) => event.id);
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
@@ -8,7 +8,7 @@ import {
Runtime,
Settings,
SimpleTimerState,
SupportedEvent,
SupportedEntry,
TimerType,
ViewSettings,
} from 'ontime-types';
@@ -63,7 +63,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic);
return rundownData.filter((e) => e.type === SupportedEntry.Event && e.title && e.isPublic);
}
return [];
}, [rundownData]);
@@ -81,7 +81,7 @@ export function getFormattedTimer(
localisedMinutes: string,
options: FormattingOptions,
): string {
if (timer == null) {
if (timer == null || timerType === TimerType.None) {
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
}
@@ -98,7 +98,7 @@ export function getFormattedTimer(
}
}
let display = millisToString(timeToParse);
let display = millisToString(timeToParse, { direction: timerType });
if (options.removeLeadingZero) {
display = removeLeadingZero(display);
}
@@ -7,7 +7,7 @@ import {
ProjectData,
Runtime,
Settings,
SupportedEvent,
SupportedEntry,
TimerPhase,
} from 'ontime-types';
@@ -63,7 +63,7 @@ export default function Countdown(props: CountdownProps) {
}
let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEntry.Event);
if (eventId !== null) {
followThis = events.find((event) => event.id === eventId) || null;
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { OntimeEntry, OntimeEvent, SupportedEvent } from 'ontime-types';
import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time';
@@ -19,7 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const { getLocalizedString } = useTranslation();
const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEntry.Event) as OntimeEvent[];
return (
<div className='event-select' data-testid='countdown__select'>
@@ -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 (
@@ -3,7 +3,7 @@ import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent';
import { useCuesheetOptions } from '../cuesheet.options';
@@ -24,7 +24,7 @@ interface CuesheetTableProps {
export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props;
const { updateEvent, updateTimer } = useEventAction();
const { updateEntry, updateTimer } = useEntryActions();
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
@@ -64,11 +64,11 @@ export default function CuesheetTable(props: CuesheetTableProps) {
}
if (isCustom) {
updateEvent({ id: event.id, custom: { [accessor]: payload } });
updateEntry({ id: event.id, custom: { [accessor]: payload } });
return;
}
updateEvent({ id: event.id, [accessor]: payload });
updateEntry({ id: event.id, [accessor]: payload });
},
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
// the timer element already contains logic to avoid submitting a unchanged value
@@ -104,7 +104,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<OntimeEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeEntry>;
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<OntimeEntry>;
/** 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
);
});
@@ -22,7 +22,7 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
const startTime = getValue() as number;
const isStartLocked = (row.original as OntimeEvent).linkStart === null;
const isStartLocked = !(row.original as OntimeEvent).linkStart;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
@@ -1,8 +1,8 @@
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { cloneEvent } from '../../../../common/utils/eventsManager';
interface CuesheetTableMenuActionsProps {
@@ -13,17 +13,17 @@ interface CuesheetTableMenuActionsProps {
export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) {
const { eventId, entryIndex, showModal } = props;
const { addEvent, getEventById, reorderEvent, deleteEvent } = useEventAction();
const { addEntry, getEntryById, reorderEntry, deleteEntry } = useEntryActions();
const handleCloneEvent = () => {
const currentEvent = getEventById(eventId);
const currentEvent = getEntryById(eventId);
if (!currentEvent || !isOntimeEvent(currentEvent)) {
return;
}
const newEvent = cloneEvent(currentEvent);
try {
addEvent(newEvent, { after: eventId });
addEntry(newEvent, { after: eventId });
} catch (_error) {
// we do not handle errors here
}
@@ -35,10 +35,10 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
Edit ...
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { before: eventId })}>
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
Add event above
</MenuItem>
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { after: eventId })}>
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
Add event below
</MenuItem>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
@@ -48,14 +48,14 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
<MenuItem
isDisabled={entryIndex < 1}
icon={<IoArrowUp />}
onClick={() => reorderEvent(eventId, entryIndex, entryIndex - 1)}
onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)}
>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEvent(eventId, entryIndex, entryIndex + 1)}>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEvent([eventId])}>
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
Delete
</MenuItem>
</MenuList>
@@ -6,7 +6,7 @@ import {
getEventWithId,
getFirstEvent,
getNextEvent,
getTimeFromPrevious,
getTimeFrom,
isNewLatest,
MILLIS_PER_HOUR,
} from 'ontime-utils';
@@ -134,7 +134,7 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.14.3",
"version": "3.15.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -49,9 +49,9 @@ async function startBackend() {
const ontimeServer = require(nodePath);
const { initAssets, startServer, startIntegrations } = ontimeServer;
await initAssets();
await initAssets(escalateError);
const result = await startServer(escalateError);
const result = await startServer();
loaded = result.message;
await startIntegrations();
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.14.3",
"version": "3.15.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -53,6 +53,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 });
}

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