Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente e6e1c38355 refactor: UI indexes are 1 based 2025-03-28 22:18:22 +01:00
124 changed files with 1301 additions and 3938 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v22.15.1 v20.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~22) - __node__ (~20)
- __pnpm__ (~10) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:22-bullseye AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0 RUN npm install -g pnpm@9.5.0
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine FROM node:20-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.16.0", "version": "3.14.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+7 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.16.0", "version": "3.14.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -17,13 +17,12 @@
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -32,7 +31,6 @@
"react-icons": "5.4.0", "react-icons": "5.4.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@@ -67,12 +65,11 @@
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
@@ -86,10 +83,10 @@
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "6.3.1", "vite": "^5.2.11",
"vite-plugin-compression2": "1.4.0", "vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "4.3.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
-30
View File
@@ -1,30 +0,0 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
@@ -3,9 +3,8 @@
font-size: $text-body-size; font-size: $text-body-size;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
color: $blue-400;
&:hover { &:hover {
color: $blue-500; color: $blue-400;
} }
} }
@@ -4,10 +4,10 @@ import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {}, additionalOptions: Record<string, string> = {},
filterImageType = true, filterImageType = true,
) => { ) => {
const options = { ...additionalOptions }; const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) { for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') { if (filterImageType && value.type === 'image') {
continue; continue;
@@ -41,7 +41,7 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown); setFlatRunDown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
+3 -12
View File
@@ -1,4 +1,4 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offset,
currentBlock: state.currentBlock, currentBlock: state.currentBlock,
})); }));
@@ -172,11 +172,8 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
})); }));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
@@ -192,12 +189,6 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0, isOnline: state.ping > 0,
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -8,5 +8,4 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`; exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
@@ -1,6 +1,3 @@
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time'; import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
@@ -43,255 +40,82 @@ describe('formatTime()', () => {
}); });
describe('calculateTimeUntilStart()', () => { describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => { test('ontime', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = { const test = {
timeStart: 100,
dayOffset: 0, dayOffset: 0,
delay: 0, delay: 0,
currentDay: 0, currentDay: 0,
clock: 100, totalGap: 0,
actualStart: 100, clock: 90,
plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
}; };
// the overlap will be pushed out to the expected available time expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10); expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).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,8 +0,0 @@
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;
}
+1 -3
View File
@@ -48,12 +48,10 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
+12 -30
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
}, },
): number { ): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData(); const { offset, clock, currentDay } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart }); return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
} }
/** /**
@@ -160,24 +160,9 @@ export function calculateTimeUntilStart(
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
clock: number; clock: number;
offset: number; offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
}, },
): number { ): number {
const { const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one //How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay; const relativeDayOffset = dayOffset - currentDay;
@@ -187,23 +172,20 @@ export function calculateTimeUntilStart(
//The normalised start time of this event relative to the currently running event //The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs; const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0; const offsetTimestart = normalisedTimeStart - offset;
const offsetTimeUntil = offsetTimestart - clock;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) { if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset //if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil; return offsetTimeUntil;
} }
const gapsCanCompensateForOffset = totalGap + offset >= 0; const scheduledTimeUntil = normalisedTimeStart - clock;
if (gapsCanCompensateForOffset) {
const isAheadOfSchedule = offset >= 0;
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time // if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil; return scheduledTimeUntil;
} }
@@ -207,9 +207,6 @@ $inner-padding: 1rem;
&.end { &.end {
justify-content: flex-end; justify-content: flex-end;
} }
&.apart {
justify-content: space-between;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) { export function Error({ children }: { children: ReactNode }) {
return <div className={cx([style.fieldError, className])}>{children}</div>; return <div className={style.fieldError}>{children}</div>;
} }
export function Divider() { export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = { type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart'; align?: 'start' | 'end';
className?: string; className?: string;
}; };
@@ -94,7 +94,7 @@ export default function ReportSettings() {
})(); })();
return ( return (
<tr key={entry.index}> <tr key={entry.index}>
<th>{entry.index}</th> <th>{entry.index + 1}</th>
<th>{entry.cue}</th> <th>{entry.cue}</th>
<th>{entry.title}</th> <th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th> <th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
@@ -1,4 +0,0 @@
.wrapper {
max-height: 500px;
overflow-y: auto;
}
@@ -1,72 +0,0 @@
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);
@@ -1,14 +0,0 @@
.editorActions {
width: 100%;
}
.right {
align-self: end;
text-align: right
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -1,129 +0,0 @@
import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss';
const CodeEditor = lazy(() => import('./StyleEditor'));
interface CodeEditorModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
setResetLoading(false);
}
};
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
} catch (_error) {
/** no error handling for now */
} finally {
setSaveLoading(false);
}
};
const clear = () => setCSS('');
useEffect(() => {
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
const css = await getCSSContents();
setCSS(css);
} catch (_error) {
setError('Failed to load CSS from server');
/** no error handling for now */
}
}
}
fetchServerCSS();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
<ModalOverlay />
<ModalContent maxWidth='max(800px, 40vw)'>
<ModalHeader>Edit CSS override</ModalHeader>
<ModalCloseButton />
<ModalBody>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</ModalBody>
<ModalFooter className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ontime-ghosted' onClick={clear}>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button
variant='ontime-filled'
onClick={handleSave}
isDisabled={saveLoading || resetLoading || !isDirty}
isLoading={saveLoading}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react'; import { Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,14 +14,11 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings(); const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo(); const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const { const {
control, control,
@@ -106,7 +103,6 @@ export default function ViewSettingsForm() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title='Override CSS styles'
@@ -119,15 +115,6 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} /> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)} )}
/> />
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
width='fit-content'
>
Edit CSS override
</Button>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
@@ -1,5 +0,0 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -24,7 +23,6 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -36,7 +34,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -47,11 +44,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -67,7 +59,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -75,10 +66,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -164,42 +151,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
@@ -25,7 +25,6 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -33,12 +32,6 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -84,10 +77,6 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -242,69 +231,6 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -57,18 +57,3 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -103,7 +103,6 @@ export default function SourcesPanel() {
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') { if (importFlow === 'excel') {
try { try {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
@@ -28,7 +28,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onChange',
@@ -46,14 +45,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => { const handleExport = async (values: NamedImportMap) => {
setLoading('export'); setLoading('export');
const importMap = convertToImportMap(values); const importMap = convertToImportMap(values);
@@ -1,7 +1,9 @@
import { PropsWithChildren, useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { cx } from '../../../common/utils/styleUtils'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss'; import style from './InputRow.module.scss';
@@ -10,53 +12,50 @@ interface InputRowProps {
placeholder: string; placeholder: string;
text: string; text: string;
visible: boolean; visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
} }
export default function InputRow(props: PropsWithChildren<InputRowProps>) { export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, changeHandler, children } = props; const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
// sync cursor position with text // sync cursor position with text
useEffect(() => { useEffect(() => {
if (inputRef.current && inputRef.current !== document.activeElement) { if (inputRef.current) {
inputRef.current.selectionStart = cursorPositionRef.current; inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current; inputRef.current.selectionEnd = cursorPositionRef.current;
} }
}, [text]); }, [text]);
// synchronise external text
useEffect(() => {
if (inputRef.current !== document.activeElement) {
setValue(text);
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0; cursorPositionRef.current = event.target.selectionStart ?? 0;
setValue(event.target.value);
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
return ( return (
<div className={style.inputRow}> <div className={style.inputRow}>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}> <label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
{label}
</label>
<div className={style.inputItems}> <div className={style.inputItems}>
<Input <Input
id={label}
ref={inputRef} ref={inputRef}
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
value={value} value={text}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
/> />
{children} <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'
/>
</div> </div>
</div> </div>
); );
@@ -1,8 +1,4 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import TimerControlsPreview from './TimerViewControl';
@@ -27,17 +23,8 @@ function TimerMessageInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)} changeHandler={(newValue) => setMessage.timerText(newValue)}
> actionHandler={() => setMessage.timerVisible(!visible)}
<TooltipActionBtn />
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -59,16 +46,7 @@ function ExternalInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)} changeHandler={(newValue) => setMessage.externalText(newValue)}
> actionHandler={toggleExternal}
<TooltipActionBtn />
clickHandler={toggleExternal}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle external message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -45,10 +45,6 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,5 +1,4 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -164,14 +163,14 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset, playback } = useRuntimePlaybackOverview(); const { clock, offset } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]); const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
return ( return (
<> <>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' /> <TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
<TimeColumn label='Time now' value={formatedTime(clock)} /> <TimeColumn label='Time now' value={formatedTime(clock)} />
</> </>
); );
@@ -10,16 +10,13 @@ interface TimeLayoutProps {
muted?: boolean; muted?: boolean;
daySpan?: number; daySpan?: number;
className?: string; className?: string;
testId?: string;
} }
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) { export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
return ( return (
<div className={style.column}> <div className={style.column}>
<span className={style.label}>{label}</span> <span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}> <span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
{value}
</span>
</div> </div>
); );
} }
+1 -2
View File
@@ -300,8 +300,7 @@ export default function Rundown({ data }: RundownProps) {
isNextDay = checkIsNextDay(entry, lastEvent); isNextDay = checkIsNextDay(entry, lastEvent);
if (!isPast) { if (!isPast) {
totalGap += entry.gap; totalGap += entry.gap;
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
} }
if (isNewLatest(entry, lastEvent)) { if (isNewLatest(entry, lastEvent)) {
// populate previous entry // populate previous entry
@@ -1,15 +1,17 @@
import { useCallback } from 'react'; import { CSSProperties, useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventCustom from './composite/EventEditorCustom'; import EventEditorImage from './composite/EventEditorImage';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers'; import EventTextArea from './composite/EventTextArea';
import EventTextInput from './composite/EventTextInput';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
@@ -75,16 +77,52 @@ export default function EventEditor(props: EventEditorProps) {
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
</Editor.Title> </Editor.Title>
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
</div> {Object.keys(customFields).map((fieldKey) => {
<div className={style.column}> const key = `${event.id}-${fieldKey}`;
<Editor.Title> const fieldName = `custom-${fieldKey}`;
Automations const initialValue = event.custom[fieldKey] ?? '';
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>} const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
</Editor.Title> const labelText = customFields[fieldKey].label;
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</div> </div>
</div> </div>
); );
@@ -1,68 +0,0 @@
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,30 +0,0 @@
.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;
}
@@ -1,173 +0,0 @@
import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Tag from '../../../../common/components/tag/Tag';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps {
eventId: string;
triggers?: Trigger[];
}
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
const { triggers, eventId } = props;
const showTriggers = triggers !== undefined && triggers.length > 0;
return (
<>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
<EventTriggerForm triggers={triggers} eventId={eventId} />
</>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
}
function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings();
const { updateEvent } = useEventAction();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEvent({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automationSettings.automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
return (
<div className={style.triggerForm}>
<Select
size='sm'
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
defaultValue={TimerLifeCycle.onStart}
>
<option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => (
<option key={cycle} value={cycle}>
{cycle}
</option>
))}
</Select>
<Select
size='sm'
variant='ontime'
value={automationId}
defaultValue='«invalid»'
onChange={(e) => setAutomationId(e.target.value)}
>
<option disabled value='«invalid»'>
Automation
</option>
{Object.values(automationSettings.automations).map(({ id, title }) => (
<option key={id} value={id}>
{title}
</option>
))}
</Select>
<Button
variant='ontime-subtle'
size='sm'
isDisabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
</Button>
{validationError !== undefined ? (
<Tooltip label={validationError} shouldWrapChildren>
<IoAlertCircle className={style.errorLabel} />
</Tooltip>
) : (
<IoCheckmarkCircle className={style.success} />
)}
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
}
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props;
const { updateEvent } = useEventAction();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEvent({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEvent],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(id)}
/>
</div>
);
})}
</Fragment>
))}
</div>
);
}
@@ -1,10 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
export const eventTriggerOptions: TimerLifeCycle[] = [
TimerLifeCycle.onLoad,
TimerLifeCycle.onStart,
TimerLifeCycle.onPause,
TimerLifeCycle.onFinish,
TimerLifeCycle.onWarning,
TimerLifeCycle.onDanger,
];
@@ -1,7 +1,5 @@
import { Button, ButtonGroup } from '@chakra-ui/react'; import { Button, ButtonGroup } from '@chakra-ui/react';
import { OffsetMode } from 'ontime-types';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -14,8 +12,6 @@ export default function RundownHeader() {
const setRunMode = () => setAppMode(AppMode.Run); const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit); const setEditMode = () => setAppMode(AppMode.Edit);
const { offsetMode } = useOffsetMode();
return ( return (
<div className={style.header}> <div className={style.header}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
@@ -26,22 +22,6 @@ export default function RundownHeader() {
Edit Edit
</Button> </Button>
</ButtonGroup> </ButtonGroup>
<ButtonGroup isAttached>
<Button
size='sm'
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Absolute)}
>
Absolute
</Button>
<Button
size='sm'
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Relative)}
>
Relative
</Button>
</ButtonGroup>
<RundownMenu /> <RundownMenu />
</div> </div>
); );
@@ -9,9 +9,57 @@
top: 75vh; top: 75vh;
line-height: normal; line-height: normal;
animation-fill-mode: both;
animation-timing-function: ease-out;
&--pre {
animation-name: in;
animation-play-state: paused;
.data-top {
animation-play-state: paused;
animation-name: top-in;
}
.data-lower {
animation-play-state: paused;
animation-name: bottom-in;
}
}
&--in {
animation-name: in;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-in;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-in;
}
}
&--out {
animation-name: out;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-out;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-out;
}
}
.line { .line {
width: 100%; width: 100%;
height: var(--lowerThird-line-height-override, 0.5vh); height: var(--bottomThird-line-height-override, 0.5vh);
} }
.clip { .clip {
@@ -22,10 +70,10 @@
.data-top { .data-top {
padding: 0 3vw; padding: 0 3vw;
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif; font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
text-align: var(--lowerThird-text-align-override, right); text-align: var(--lowerThird-text-align-override, left);
white-space: nowrap; white-space: nowrap;
@include ellipsis-text(); @include ellipsis-text();
&::after { &::after {
content: '\200b'; content: '\200b';
} }
@@ -37,24 +85,72 @@
} }
.data-bottom { .data-bottom {
font-weight: var(--lowerThird-bottom-font-weight-override, 540); font-weight: var(--lowerThird-bottom-font-weight-override, normal);
font-style: var(--lowerThird-bottom-font-style-override, normal); font-style: var(--lowerThird-bottom-font-style-override, normal);
} }
}
&--in { }
transition-timing-function: cubic-bezier(0.25, 0.5, 0.5, 1);
} @keyframes in {
0% {
&--out { transform: translateX(-100%);
transition-timing-function: cubic-bezier(0.5, 0, 0.75, 0.5); opacity: 0%;
transform: translateX(-100%); }
opacity: 0; 50%,
.data-top { 100% {
transform: translateY(100%); transform: translateX(0%);
} opacity: 100%;
.data-bottom { }
transform: translateY(-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,123 +1,204 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types'; import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options'; import { getLowerThirdOptions } from './lowerThird.options';
import './LowerThird.scss'; import './LowerThird.scss';
type LowerOptions = {
width: number;
topSrc: string;
bottomSrc: string;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: string;
bottomSize: string;
transition: number;
delay: number;
key: string;
lineColour: string;
lineHeight: string;
};
interface LowerProps { interface LowerProps {
customFields: CustomFields; customFields: CustomFields;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
viewSettings: ViewSettings; viewSettings: ViewSettings;
time: ViewExtendedTimer;
} }
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000',
bottomColour: '000000',
topBg: 'FFF0',
bottomBg: 'FFF0',
topSize: '65px',
bottomSize: '40px',
transition: 3,
delay: 3,
key: 'FFF0',
lineColour: 'FF0000',
lineHeight: '0.4em',
};
export default function LowerThird(props: LowerProps) { export default function LowerThird(props: LowerProps) {
const { customFields, eventNow, viewSettings, time } = props; const { customFields, eventNow, viewSettings } = props;
const [searchParams] = useSearchParams();
const previousId = useRef<string>(); const previousId = useRef<string>();
const animationTimeout = useRef<NodeJS.Timeout>(); const animationTimeout = useRef<NodeJS.Timeout>();
const [playState, setPlayState] = useState<boolean>(false); const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
const [textValue, setTextValue] = useState<{ top: string; bottom: string }>({ top: '', bottom: '' });
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const options = useLowerOptions();
const { playback } = time;
useWindowTitle('Lower Third'); useWindowTitle('Lower Third');
const options = useMemo(() => {
const newOptions = { ...defaultOptions };
const width = searchParams.get('width');
if (width !== null) {
newOptions.width = Number(width);
}
const topSrc = searchParams.get('top-src');
if (topSrc) {
newOptions.topSrc = topSrc;
}
const bottomSrc = searchParams.get('bottom-src');
if (bottomSrc) {
newOptions.bottomSrc = bottomSrc;
}
const topColour = searchParams.get('top-colour');
if (topColour !== null) {
newOptions.topColour = topColour;
}
const bottomColour = searchParams.get('bottom-colour');
if (bottomColour !== null) {
newOptions.bottomColour = bottomColour;
}
const topBg = searchParams.get('top-bg');
if (topBg !== null) {
newOptions.topBg = topBg;
}
const bottomBg = searchParams.get('bottom-bg');
if (bottomBg !== null) {
newOptions.bottomBg = bottomBg;
}
const topSize = searchParams.get('top-size');
if (topSize !== null) {
newOptions.topSize = topSize;
}
const bottomSize = searchParams.get('bottom-size');
if (bottomSize && bottomSize != newOptions.bottomSize) {
newOptions.bottomSize = bottomSize;
}
const transition = searchParams.get('transition');
if (transition !== null) {
newOptions.transition = Number(transition);
}
const delay = searchParams.get('delay');
if (delay !== null) {
newOptions.delay = Number(delay);
}
const key = searchParams.get('key');
if (key !== null) {
newOptions.key = key;
}
const lineColour = searchParams.get('line-colour');
if (lineColour !== null) {
newOptions.lineColour = lineColour;
}
return newOptions;
}, [searchParams]);
// on unmount, cancel any ongoing animations // on unmount, cancel any ongoing animations
useEffect(() => { useEffect(() => {
// if hold is negative then force animate in return () => {
if (options.hold < 0) { // eslint-disable-next-line react-hooks/exhaustive-deps
clearTimeout(animationTimeout.current); clearTimeout(animationTimeout.current);
setTextValue({ };
top: getPropertyValue(eventNow, options.topSrc) ?? '', }, []);
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
});
setPlayState(true);
return;
}
}, [eventNow, options.bottomSrc, options.hold, options.topSrc]);
const animateIn = useCallback(() => {
// if hold skip
if (options.hold < 0) return;
//clear any pending timeouts
clearTimeout(animationTimeout.current);
// set the values
setTextValue({
top: getPropertyValue(eventNow, options.topSrc) ?? '',
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
});
// start animation
setPlayState(true);
// reschedule out animation, should animate out after the in animation time + hold time
setTimeout(() => setPlayState(false), (options.hold + options.transitionIn) * MILLIS_PER_SECOND);
}, [eventNow, options.bottomSrc, options.hold, options.topSrc, options.transitionIn]);
const animateOut = useCallback(() => {
if (options.hold < 0) return; // if hold is negative then we never animate out
//clear any pending timeouts
clearTimeout(animationTimeout.current);
// start animation
setPlayState(false);
}, [options.hold]);
// check if playback has changed and schedule animations
useEffect(() => {
if (isPlaybackActive(playback)) {
animateIn();
} else {
animateOut();
}
}, [animateIn, animateOut, playback]);
// check if data has changed and schedule animations // check if data has changed and schedule animations
useEffect(() => { useEffect(() => {
const hasChanged = eventNow?.id !== previousId.current; const hasChanged = eventNow?.id !== previousId.current;
if (hasChanged) { if (!hasChanged) {
previousId.current = eventNow?.id; return;
if (eventNow?.id) animateIn();
} }
}, [animateIn, eventNow?.id]);
const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`; previousId.current = eventNow?.id;
const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`; const animateOutInMs = options.delay * 1000 + options.transition * 1000;
const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`; const reschedule = (newState: 'pre' | 'in' | 'out') => {
const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s'; 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`;
return ( return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}> <div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} /> <ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div <div
className={`container ${playState ? 'container--in' : 'container--out'}`} className={`container container--${playState}`}
style={{ style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
minWidth: `${options.width}vw`,
transitionDuration: boxDuration,
transitionDelay: boxDelay,
}}
> >
<div className='clip'> <div className='clip'>
<div <div
className='data-top' className='data-top'
style={{ style={{
transitionDuration: textDuration, animationDuration: transition,
transitionDelay: textDelay,
color: `#${options.topColour}`, color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`, backgroundColor: `#${options.topBg}`,
fontSize: `${options.topSize}em`, fontSize: options.topSize,
}} }}
> >
{textValue.top} {topText}
</div> </div>
</div> </div>
<div <div
@@ -130,14 +211,13 @@ export default function LowerThird(props: LowerProps) {
<div <div
className='data-bottom' className='data-bottom'
style={{ style={{
transitionDuration: textDuration, animationDuration: transition,
transitionDelay: textDelay,
color: `#${options.bottomColour}`, color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`, backgroundColor: `#${options.bottomBg}`,
fontSize: `${options.bottomSize}em`, fontSize: options.bottomSize,
}} }}
> >
{textValue.bottom} {bottomText}
</div> </div>
</div> </div>
</div> </div>
@@ -1,10 +1,7 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants'; import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types'; import { ViewOption } from '../../../common/components/view-params-editor/types';
import safeParseNumber from '../../../common/utils/safeParseNumber';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => { export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, { const topSourceOptions = makeOptionsFromCustomFields(customFields, {
@@ -47,32 +44,18 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
collapsible: true, collapsible: true,
options: [ options: [
{ {
id: 'transition-in', id: 'transition',
title: 'Transition In', title: 'Transition',
description: 'Transition in time (default 3 seconds)', description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'transition-out',
title: 'Transition Out',
description: 'Transition out time (default 3 seconds)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'hold',
title: 'Hold',
description: 'Time on screen before transition out. Set to -1 to stop transition (default 3 seconds) ',
type: 'number', type: 'number',
placeholder: '3 (default)', placeholder: '3 (default)',
}, },
{ {
id: 'delay', id: 'delay',
title: 'Delay', title: 'Delay',
description: 'Delay between trigger and transition in (default 0 seconds)', description: 'Delay between transition in and out in seconds (default 3)',
type: 'number', type: 'number',
placeholder: '0 (default)', placeholder: '3 (default)',
}, },
], ],
}, },
@@ -86,14 +69,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
title: 'Top Text Size', title: 'Top Text Size',
description: 'Font size of the top text', description: 'Font size of the top text',
type: 'string', type: 'string',
placeholder: '5em', placeholder: '65px',
}, },
{ {
id: 'bottom-size', id: 'bottom-size',
title: 'Bottom Text Size', title: 'Bottom Text Size',
description: 'Font size of the bottom text', description: 'Font size of the bottom text',
type: 'string', type: 'string',
placeholder: '4em', placeholder: '64px',
}, },
{ {
id: 'width', id: 'width',
@@ -149,73 +132,3 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
}, },
]; ];
}; };
type LowerOptions = {
width: number;
topSrc: string;
bottomSrc: string;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: number;
bottomSize: number;
transitionIn: number;
transitionOut: number;
hold: number;
delay: number;
key: string;
lineColour: string;
};
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000',
bottomColour: '000000',
topBg: 'FFF0',
bottomBg: 'FFF0',
topSize: 5,
bottomSize: 4,
transitionIn: 3,
transitionOut: 3,
hold: 3,
delay: 0,
key: 'FFF0',
lineColour: 'FF0000',
};
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with defaultOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): LowerOptions {
// we manually make an object that matches the key above
return {
width: safeParseNumber(searchParams.get('width'), defaultOptions.width),
topSrc: searchParams.get('top-src') ?? defaultOptions.topSrc,
bottomSrc: searchParams.get('bottom-src') ?? defaultOptions.bottomSrc,
topColour: searchParams.get('top-colour') ?? defaultOptions.topColour,
bottomColour: searchParams.get('bottom-colour') ?? defaultOptions.bottomColour,
topBg: searchParams.get('top-bg') ?? defaultOptions.topBg,
bottomBg: searchParams.get('bottom-bg') ?? defaultOptions.bottomBg,
topSize: safeParseNumber(searchParams.get('top-size'), defaultOptions.topSize),
bottomSize: safeParseNumber(searchParams.get('bottom-size'), defaultOptions.bottomSize),
transitionIn: safeParseNumber(searchParams.get('transition-in'), defaultOptions.transitionIn),
transitionOut: safeParseNumber(searchParams.get('transition-out'), defaultOptions.transitionOut),
hold: safeParseNumber(searchParams.get('hold'), defaultOptions.hold),
delay: safeParseNumber(searchParams.get('hold'), defaultOptions.delay),
key: searchParams.get('key') ?? defaultOptions.key,
lineColour: searchParams.get('line-colour') ?? defaultOptions.lineColour,
};
}
/**
* Hook exposes the timer view options
*/
export function useLowerOptions(): LowerOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -93,7 +93,7 @@ $orange-active: #f60;
} }
.studio-timer { .studio-timer {
font-size: calc(var(--clock-size) / 6); font-size: calc(var(--clock-size) / 5);
line-height: 1em; line-height: 1em;
color: var(--studio-active, $red-active); color: var(--studio-active, $red-active);
@@ -140,7 +140,7 @@ export const ScheduleProvider = ({
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId); let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current // we want to show the event after the current
const viewEvents = events.slice(selectedEventIndex + 1); const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
selectedEventIndex = 0; selectedEventIndex = 0;
return ( return (
@@ -96,12 +96,11 @@ describe('makeTable()', () => {
"Is Public? (x)", "Is Public? (x)",
"Skip?", "Skip?",
"lighting", "lighting",
"Type",
], ],
[ [
"00:00:00", "00:00:00",
"00:00:00", "00:00:00",
"", "...",
"", "",
"", "",
"", "",
@@ -110,7 +109,6 @@ describe('makeTable()', () => {
"x", "x",
"", "",
"", "",
"",
], ],
] ]
`); `);
@@ -104,7 +104,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
<div ref={tableContainerRef} className={style.cuesheetContainer}> <div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet} id='cuesheet' {...listeners}> <table className={style.cuesheet} id='cuesheet' {...listeners}>
<CuesheetHeader headerGroups={headerGroups} /> <CuesheetHeader headerGroups={headerGroups} />
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} /> <CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
</table> </table>
</div> </div>
<CuesheetTableMenu showModal={showModal} /> <CuesheetTableMenu showModal={showModal} />
@@ -19,10 +19,7 @@ function BlockRow(props: BlockRowProps) {
return null; return null;
} }
// guard the use case where user has hidden all columns const paddingRows = new Array(columnCount - 1).fill(null);
const fillColumns = Math.min(columnCount, 1);
const paddingRows = new Array(fillColumns).fill(null);
return ( return (
<tr className={style.blockRow}> <tr className={style.blockRow}>
@@ -16,24 +16,16 @@ interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>; rowModel: RowModel<OntimeRundownEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>; selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>; table: Table<OntimeRundownEntry>;
columnSizing: Record<string, number>;
} }
export default function CuesheetBody(props: CuesheetBodyProps) { export default function CuesheetBody(props: CuesheetBodyProps) {
const { rowModel, selectedRef, table } = props; const { rowModel, selectedRef, table, columnSizing } = props;
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const { hideDelays, hidePast } = useCuesheetOptions(); const { hideDelays, hidePast } = useCuesheetOptions();
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns()); const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length);
const getColumnHash = lazyEvaluate(() => {
let columnHash = '';
const columns = getVisibleColumns();
for (let i = 0; i < columns.length; i++) {
columnHash += `${columns[i].getIndex()}-${columns[i].getSize()} `;
}
return columnHash;
});
let eventIndex = 0; let eventIndex = 0;
// for the first event, it will be past if there is something selected // for the first event, it will be past if there is something selected
@@ -49,7 +41,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
} }
if (isOntimeBlock(entry)) { if (isOntimeBlock(entry)) {
const columnCount = getVisibleColumns().length; const columnCount = getColumnCount();
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />; return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
} }
if (isOntimeDelay(entry)) { if (isOntimeDelay(entry)) {
@@ -66,7 +58,6 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedEventId; const isSelected = key === selectedEventId;
const columnHash = getColumnHash();
if (isPast && hidePast) { if (isPast && hidePast) {
return null; return null;
@@ -96,7 +87,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour} rowBgColour={rowBgColour}
table={table} table={table}
columnHash={columnHash} columnSizing={columnSizing}
/> />
); );
} }
@@ -23,20 +23,20 @@ interface EventRowProps {
rowBgColour?: string; rowBgColour?: string;
table: Table<OntimeRundownEntry>; table: Table<OntimeRundownEntry>;
/** hack to force re-rendering of the row when the column sizes change */ /** hack to force re-rendering of the row when the column sizes change */
columnHash: string; columnSizing: Record<string, number>;
} }
export default memo(EventRow, (prevProps, nextProps) => { export default memo(EventRow, (prevProps, nextProps) => {
return ( return (
prevProps.rowId === nextProps.rowId && prevProps.rowId === nextProps.rowId &&
prevProps.event.id === nextProps.event.id &&
prevProps.event.revision === nextProps.event.revision && prevProps.event.revision === nextProps.event.revision &&
prevProps.eventIndex === nextProps.eventIndex && prevProps.eventIndex === nextProps.eventIndex &&
prevProps.rowIndex === nextProps.rowIndex && prevProps.rowIndex === nextProps.rowIndex &&
prevProps.isPast === nextProps.isPast && prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef && prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour && prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.columnHash === nextProps.columnHash prevProps.table === nextProps.table &&
prevProps.columnSizing === nextProps.columnSizing
); );
}); });
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => { export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') { if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber, { fallback: '' }); return millisToString(data as MaybeNumber);
} }
if (field === 'isPublic' || field === 'skip') { if (field === 'isPublic' || field === 'skip') {
@@ -59,7 +59,6 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
'isPublic', 'isPublic',
'skip', 'skip',
...customFieldKeys, ...customFieldKeys,
'type',
]; ];
const fieldTitles = [ const fieldTitles = [
@@ -74,7 +73,6 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
'Is Public? (x)', 'Is Public? (x)',
'Skip?', 'Skip?',
...customFieldLabels, ...customFieldLabels,
'Type',
]; ];
// add header row to data // add header row to data
@@ -25,9 +25,7 @@
flex: 1; flex: 1;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px); width: min(calc(100vw - 4rem), 800px);
padding-bottom: 10vh;
} }
.info__label { .info__label {
@@ -35,15 +33,11 @@
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
margin-top: $view-element-gap; margin-top: $view-element-gap;
} white-space: pre;
.info__value {
white-space: break-spaces;
} }
a.info__value { a.info__value {
color: $action-text-color; color: $action-text-color;
&:hover { &:hover {
color: $ontime-color; color: $ontime-color;
} }
@@ -8,7 +8,6 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo'; import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo'; import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options'; import { projectInfoOptions } from './projectInfo.options';
@@ -67,7 +66,6 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)} )}
<BackstageInfo general={general} /> <BackstageInfo general={general} />
<PublicInfo general={general} /> <PublicInfo general={general} />
<CustomInfo general={general} />
</div> </div>
</div> </div>
); );
@@ -1,36 +0,0 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,21 +9,14 @@ export const projectInfoOptions: ViewOption[] = [
{ {
id: 'showBackstage', id: 'showBackstage',
title: 'Show backstage Data', title: 'Show backstage Data',
description: 'Whether to show fields related to the backstage views', description: 'Weather to show fields related to the backstage views',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{ {
id: 'showPublic', id: 'showPublic',
title: 'Show Public Data', title: 'Show Public Data',
description: 'Whether to show fields related to the public views', description: 'Weather to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
+3 -3
View File
@@ -70,9 +70,9 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: ` additionalData: `
@use '@/theme/ontimeColours' as *; @use './src/theme/ontimeColours' as *;
@use '@/theme/ontimeStyles' as *; @use './src/theme/ontimeStyles' as *;
@use '@/theme/mixins' as *; @use './src/theme/mixins' as *;
`, `,
}, },
}, },
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "3.16.0", "version": "3.14.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -12,8 +12,8 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "36.3.1", "electron": "^31.2.0",
"electron-builder": "26.0.12", "electron-builder": "^24.13.3",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
@@ -38,7 +38,9 @@
"icon": "icon.icns" "icon": "icon.icns"
}, },
"mac": { "mac": {
"notarize": true, "notarize": {
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "./entitlements.plist", "entitlements": "./entitlements.plist",
+1 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.16.0", "version": "3.14.3",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -52,7 +52,6 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "tsx ./scripts/bundleCss.ts",
"build": "node esbuild.electron.js", "build": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js", "build:local": "node esbuild.dev.js",
-24
View File
@@ -1,24 +0,0 @@
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();
+1 -1
View File
@@ -5,7 +5,7 @@ import * as dgram from 'node:dgram';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { isOntimeCloud } from '../setup/environment.js'; import { isOntimeCloud } from '../externals.js';
import { integrationPayloadFromPath } from './utils/parse.js'; import { integrationPayloadFromPath } from './utils/parse.js';
import type { IAdapter } from './IAdapter.js'; import type { IAdapter } from './IAdapter.js';
@@ -1,41 +0,0 @@
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 });
}
}
@@ -1,10 +0,0 @@
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);
@@ -1,33 +0,0 @@
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' });
}
@@ -1,12 +0,0 @@
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,13 +9,12 @@ import type {
import { deleteAtIndex, generateId } from 'ontime-utils'; import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
/** /**
* Gets a copy of the stored automation settings * Gets a copy of the stored automation settings
*/ */
export function getAutomationSettings(): AutomationSettings { export function getAutomationSettings(): AutomationSettings {
return getDataProvider().getAutomation(); return structuredClone(getDataProvider().getAutomation());
} }
/** /**
@@ -139,25 +138,14 @@ export async function deleteAutomation(id: string): Promise<void> {
if (!Object.hasOwn(automations, id)) { if (!Object.hasOwn(automations, id)) {
return; return;
} }
// prevent deleting a automation that is in use
// prevent deleting a automation that is in use in triggers const triggers = getAutomationTriggers();
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id); for (let i = 0; i < triggers.length; i++) {
if (triggers.length) { const trigger = triggers[i];
throw new Error( if (trigger.automationId === id) {
`Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`, throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
); }
} }
// prevent deleting a automation that is in use in events
const events = getTimedEvents().filter(
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
);
if (events.length) {
throw new Error(
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
);
}
delete automations[id]; delete automations[id];
await saveChanges({ automations }); await saveChanges({ automations });
} }
@@ -3,16 +3,16 @@ import {
isOntimeAction, isOntimeAction,
isOSCOutput, isOSCOutput,
LogOrigin, LogOrigin,
TimerLifeCycle,
type AutomationFilter, type AutomationFilter,
type AutomationOutput, type AutomationOutput,
type FilterRule, type FilterRule,
type TimerLifeCycle,
} from 'ontime-types'; } from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils'; import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js'; import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../setup/environment.js'; import { isOntimeCloud } from '../../externals.js';
import { emitOSC } from './clients/osc.client.js'; import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js'; import { emitHTTP } from './clients/http.client.js';
@@ -23,21 +23,14 @@ import { toOntimeAction } from './clients/ontime.client.js';
/** /**
* Exposes a method for triggering actions based on a TimerLifeCycle event * Exposes a method for triggering actions based on a TimerLifeCycle event
*/ */
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) { export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
if (!getAutomationsEnabled()) { if (!getAutomationsEnabled()) {
return; return;
} }
let triggers = getAutomationTriggers(); const triggers = getAutomationTriggers();
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
// get triggers from event if (triggerAutomations.length === 0) {
if (state.eventNow?.triggers) {
triggers = triggers.concat(state.eventNow.triggers);
}
// note: there are no onStop triggers in event
const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
if (filteredTrigger.length === 0) {
return; return;
} }
@@ -46,7 +39,7 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
return; return;
} }
filteredTrigger.forEach((trigger) => { triggerAutomations.forEach((trigger) => {
const automation = automations[trigger.automationId]; const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) { if (!automation || automation.outputs.length === 0) {
return; return;
+1 -2
View File
@@ -58,7 +58,6 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '', backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '', backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null, projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
}, },
}); });
@@ -204,7 +203,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/** /**
* Loads the demo project * Loads the demo project
*/ */
export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const projectName = await projectService.loadDemoProject(); const projectName = await projectService.loadDemoProject();
@@ -16,7 +16,6 @@ export const validateNewProject = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(), body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
-2
View File
@@ -12,7 +12,6 @@ import { router as excelRouter } from './excel/excel.router.js';
import { router as sessionRouter } from './session/session.router.js'; import { router as sessionRouter } from './session/session.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js'; import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
import { router as reportRouter } from './report/report.router.js'; import { router as reportRouter } from './report/report.router.js';
import { router as assetsRouter } from './assets/assets.router.js';
export const appRouter = express.Router(); export const appRouter = express.Router();
@@ -28,7 +27,6 @@ appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/session', sessionRouter); appRouter.use('/session', sessionRouter);
appRouter.use('/view-settings', viewSettingsRouter); appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter); appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes //we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => { appRouter.all('/*', (_req, res) => {
@@ -5,11 +5,11 @@ import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js'; import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js'; import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
import * as projectDao from './project.dao.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) { export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(projectDao.getProjectData()); res.json(getDataProvider().getProjectData());
} }
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) { export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -27,7 +27,6 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo, backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage, endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo, projectLogo: req.body?.projectLogo,
custom: req.body?.custom,
}); });
const updatedData = await editCurrentProjectData(newData); const updatedData = await editCurrentProjectData(newData);
@@ -1,9 +0,0 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
}
@@ -10,9 +10,6 @@ export const projectSanitiser = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(), body('projectLogo').optional({ nullable: true }).isString().trim(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -10,7 +10,6 @@ import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js'; import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js'; import { password, routerPrefix } from '../../externals.js';
import { hashPassword } from '../../utils/hash.js'; import { hashPassword } from '../../utils/hash.js';
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
const startedAt = new Date(); const startedAt = new Date();
@@ -29,7 +28,6 @@ export async function getSessionStats(): Promise<SessionStats> {
projectName, projectName,
playback, playback,
timezone: getTimezoneLabel(startedAt), timezone: getTimezoneLabel(startedAt),
version: ONTIME_VERSION,
}; };
} }
@@ -3,7 +3,7 @@ import { getErrorMessage, obfuscate } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { isDocker } from '../../setup/environment.js'; import { isDocker } from '../../externals.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js'; import * as appState from '../../services/app-state-service/AppStateService.js';
@@ -1,4 +1,4 @@
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials'; import { DeepPartial } from 'ts-essentials';
@@ -17,7 +17,6 @@ import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js'; import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js'; import { handleLegacyMessageConversion } from './integration.legacy.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20); const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null; let lastRequest: Date | null = null;
@@ -287,11 +286,6 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('No matching method provided'); throw new Error('No matching method provided');
}, },
offsetmode: (payload) => {
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
}; };
/** /**
+1 -2
View File
@@ -9,8 +9,7 @@ import cookieParser from 'cookie-parser';
// import utils // import utils
import { publicDir, srcDir } from './setup/index.js'; import { publicDir, srcDir } from './setup/index.js';
import { environment, isProduction } from './setup/environment.js'; import { environment, isProduction, updateRouterPrefix } from './externals.js';
import { updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
+1 -1
View File
@@ -4,7 +4,7 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js'; import { clock } from '../services/Clock.js';
import { socket } from '../adapters/WebsocketAdapter.js'; import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleError } from '../utils/console.js'; import { consoleSubdued, consoleError } from '../utils/console.js';
import { isProduction } from '../setup/environment.js'; import { isProduction } from '../externals.js';
class Logger { class Logger {
private queue: Log[]; private queue: Log[];
@@ -14,7 +14,7 @@ import { JSONFilePreset } from 'lowdb/node';
import { isPath } from '../../utils/fileManagement.js'; import { isPath } from '../../utils/fileManagement.js';
import { shouldCrashDev } from '../../utils/development.js'; import { shouldCrashDev } from '../../utils/development.js';
import { isTest } from '../../setup/environment.js'; import { isTest } from '../../externals.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
@@ -63,7 +63,7 @@ function getData(): Readonly<DatabaseModel> {
} }
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> { async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...structuredClone(db.data.project), ...structuredClone(newData) }; // Performing deep copy as we're updating / merging data db.data.project = { ...db.data.project, ...newData };
await persist(); await persist();
return db.data.project; return db.data.project;
} }
@@ -4,27 +4,24 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
const { const {
rundown = deepExisting.rundown, rundown = existing.rundown,
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
urlPresets = deepExisting.urlPresets, urlPresets = existing.urlPresets,
customFields = deepExisting.customFields, customFields = existing.customFields,
automation = deepExisting.automation, automation = existing.automation,
} = deepNewData; } = newData;
return { return {
...deepExisting, ...existing,
rundown, rundown,
project: { ...deepExisting.project, ...project }, project: { ...existing.project, ...project },
settings: { ...deepExisting.settings, ...settings }, settings: { ...existing.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings }, viewSettings: { ...existing.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? deepExisting.urlPresets, urlPresets: urlPresets ?? existing.urlPresets,
customFields: customFields ?? deepExisting.customFields, customFields: customFields ?? existing.customFields,
automation: { ...deepExisting.automation, ...automation }, automation: { ...existing.automation, ...automation },
}; };
} }
@@ -12,12 +12,6 @@ describe('safeMerge', () => {
publicInfo: 'existing backstageInfo', publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'existing custom title',
value: 'existing custom value',
},
],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -68,12 +62,6 @@ describe('safeMerge', () => {
project: { project: {
title: 'new title', title: 'new title',
publicInfo: 'new public info', publicInfo: 'new public info',
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}, },
}; };
// @ts-expect-error -- just testing // @ts-expect-error -- just testing
@@ -86,12 +74,6 @@ describe('safeMerge', () => {
backstageUrl: 'existing backstageUrl', backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}); });
}); });
@@ -125,7 +107,6 @@ describe('safeMerge', () => {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+8
View File
@@ -7,6 +7,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { srcFiles } from './setup/index.js'; import { srcFiles } from './setup/index.js';
// ================================================= // =================================================
// resolve running environment
const env = process.env.NODE_ENV || 'production';
export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD; export const password = process.env.SESSION_PASSWORD;
export const routerPrefix = process.env.ROUTER_PREFIX; export const routerPrefix = process.env.ROUTER_PREFIX;
+1 -1
View File
@@ -124,6 +124,6 @@ function setSessionCookie(res: Response, token: string) {
httpOnly: false, // allow websocket to access cookie httpOnly: false, // allow websocket to access cookie
secure: true, secure: true,
path: '/', // allow cookie to be accessed from any path path: '/', // allow cookie to be accessed from any path
sameSite: 'none', // allow cookies to be sent in cross-origin requests (e.g., iframes) sameSite: 'strict',
}); });
} }
-1
View File
@@ -11,7 +11,6 @@ export const dbModel: DatabaseModel = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
-1
View File
@@ -413,7 +413,6 @@ export const demoDb: DatabaseModel = {
backstageUrl: 'www.github.com/cpvalente/ontime', backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal', backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -740,8 +740,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50); expect(offset).toBe(-50);
}); });
it('added time subtracts time offset (positive offset)', () => { it('added time subtracts time offset (positive offset)', () => {
@@ -763,8 +763,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-60); expect(offset).toBe(-60);
}); });
it('considers running overtime (negative offset)', () => { it('considers running overtime (negative offset)', () => {
@@ -787,8 +787,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-10); expect(offset).toBe(-10);
}); });
it('paused time is delayed time (negative offset)', () => { it('paused time is delayed time (negative offset)', () => {
@@ -812,8 +812,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-25); expect(offset).toBe(-25);
}); });
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
@@ -850,8 +850,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0); expect(offset).toBe(0);
}); });
it('handles loaded event', () => { it('handles loaded event', () => {
@@ -891,8 +891,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(81000000 - 79521653); // clock - timestart expect(offset).toBe(81000000 - 79521653); // clock - timestart
}); });
it('with time-to-end, offsets dont exist if we are not in overtime', () => { it('with time-to-end, offsets dont exist if we are not in overtime', () => {
@@ -944,8 +944,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0); expect(offset).toBe(0);
}); });
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset is the overtime', () => {
@@ -997,8 +997,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-400000); // <--- offset is always the overtime expect(offset).toBe(-400000); // <--- offset is always the overtime
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
@@ -1040,84 +1040,9 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent; state.timer.current = updateCurrent;
const { absoluteOffset } = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(millisToString(absoluteOffset)).toBe('-00:16:40'); expect(millisToString(offset)).toBe('-00:16:40');
expect(absoluteOffset).toBe(81000000 - 82000000); // <-- planned end - now expect(offset).toBe(81000000 - 82000000); // <-- planned end - now
});
});
describe('getRelativeOffset()', () => {
it('relative offset is 0 when starting at the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 150,
},
timer: {
startedAt: 150,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 150,
plannedStart: 150,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
expect(relativeOffset).toBe(0);
});
it('relative offset is 0 when starting after the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 100,
},
timer: {
startedAt: 150,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 150,
plannedStart: 100,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50);
expect(relativeOffset).toBe(0);
});
it('relative offset is 0 when starting before the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 150,
},
timer: {
startedAt: 100,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 100,
plannedStart: 150,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(50);
expect(relativeOffset).toBe(0);
}); });
}); });
@@ -1244,10 +1169,8 @@ describe('getTimerPhase()', () => {
}, },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0, totalDelay: 0,
pausedAt: null,
}, },
} as RuntimeState; } as RuntimeState;
@@ -1285,10 +1208,8 @@ describe('getTimerPhase()', () => {
}, },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0, totalDelay: 0,
pausedAt: null,
}, },
} as RuntimeState; } as RuntimeState;
@@ -2,7 +2,7 @@ import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node'; import { JSONFile } from 'lowdb/node';
import { publicFiles } from '../../setup/index.js'; import { publicFiles } from '../../setup/index.js';
import { isTest } from '../../setup/environment.js'; import { isTest } from '../../externals.js';
import { isPath } from '../../utils/fileManagement.js'; import { isPath } from '../../utils/fileManagement.js';
import { shouldCrashDev } from '../../utils/development.js'; import { shouldCrashDev } from '../../utils/development.js';
@@ -314,13 +314,17 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
} }
/** /**
* Patches the current project data * Changes the title of a project
* Handles deleting the local logo if the logo has been removed * it handles invalidating the necessary data
*/ */
export async function editCurrentProjectData(newData: Partial<ProjectData>) { export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData(); const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData); const updatedProjectData = await getDataProvider().setProjectData(newData);
if (currentProjectData.title !== updatedProjectData.title) {
// something
}
// Delete the old logo if the logo has been removed // Delete the old logo if the logo has been removed
if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) { if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) {
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo); const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
@@ -108,7 +108,7 @@ export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove); const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventIds }); const { didMutate } = await scopedMutation({ eventIds });
if (!didMutate) { if (didMutate === false) {
return; return;
} }
@@ -146,7 +146,7 @@ export async function editEvent(patch: PatchWithId) {
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id }); const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
// short circuit if nothing changed // short circuit if nothing changed
if (!didMutate) { if (didMutate === false) {
return newEvent; return newEvent;
} }
@@ -118,7 +118,6 @@ export enum regenerateWhitelist {
'timeWarning', 'timeWarning',
'timeDanger', 'timeDanger',
'custom', 'custom',
'triggers',
} }
/** /**
@@ -4,7 +4,6 @@ import {
isPlayableEvent, isPlayableEvent,
LogOrigin, LogOrigin,
MaybeNumber, MaybeNumber,
OffsetMode,
OntimeEvent, OntimeEvent,
Playback, Playback,
TimerLifeCycle, TimerLifeCycle,
@@ -69,11 +68,6 @@ class RuntimeService {
RuntimeService.previousState = {} as RuntimeState; RuntimeService.previousState = {} as RuntimeState;
} }
@broadcastResult
setOffsetMode(mode: OffsetMode) {
runtimeState.setOffsetMode(mode);
}
/** /**
* Checks result of an update and notifies integrations as needed * Checks result of an update and notifies integrations as needed
* This is the only exception of a private method that has broadcast result * This is the only exception of a private method that has broadcast result
@@ -703,14 +697,11 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// for the very fist run there will be nothing in the previousState so we force an update // for the very fist run there will be nothing in the previousState so we force an update
const justStarted = !RuntimeService.previousState?.timer; const justStarted = !RuntimeService.previousState?.timer;
// offset mode has been changed
const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode;
// if playback changes most things should update // if playback changes most things should update
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback; const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
// combine all big changes // combine all big changes
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged; const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
/** /**
* Timer should be updated if * Timer should be updated if
+8 -19
View File
@@ -116,10 +116,10 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is time ahead * Positive offset is time ahead
* Negative offset is time delayed * Negative offset is time delayed
*/ */
export function getRuntimeOffset(state: RuntimeState): { absoluteOffset: number; relativeOffset: number } { export function getRuntimeOffset(state: RuntimeState): number {
// nothing to calculate if there are no loaded events or if we havent started // nothing to calculate if there are no loaded events or if we havent started
if (state.eventNow === null || state.runtime.actualStart === null) { if (state.eventNow === null || state.runtime.actualStart === null) {
return { absoluteOffset: 0, relativeOffset: 0 }; return 0;
} }
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
@@ -133,16 +133,18 @@ export function getRuntimeOffset(state: RuntimeState): { absoluteOffset: number;
const { clock } = state; const { clock } = state;
const { countToEnd, timeStart } = state.eventNow; const { countToEnd, timeStart } = state.eventNow;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
const { actualStart, plannedStart } = state.runtime;
// if we havent started, but the timer is armed // if we havent started, but the timer is armed
// the offset is the difference to the schedule // the offset is the difference to the schedule
if (startedAt === null) { if (startedAt === null) {
return { absoluteOffset: timeStart - clock, relativeOffset: 0 }; return timeStart - clock;
} }
const overtime = Math.min(current, 0); const overtime = Math.min(current, 0);
// in time-to-end, offset is overtime // in time-to-end, offset is overtime
if (countToEnd) {
return overtime;
}
const startOffset = timeStart - startedAt; const startOffset = timeStart - startedAt;
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
@@ -151,20 +153,7 @@ export function getRuntimeOffset(state: RuntimeState): { absoluteOffset: number;
// addedTime - time added by user (negative offset) // addedTime - time added by user (negative offset)
// pausedTime - time the playback was paused (negative offset) // pausedTime - time the playback was paused (negative offset)
// overtime - how long the timer has been over-running (negative offset) // overtime - how long the timer has been over-running (negative offset)
const offset = startOffset - addedTime - pausedTime + overtime; return startOffset - addedTime - pausedTime + overtime;
// offset between planned rundown start and actual rundown start
const rundownStartOffset = actualStart - plannedStart;
// offset offset relative to the actual rundown start
const relativeOffset = offset + rundownStartOffset;
// in time-to-end, offset is overtime
if (countToEnd) {
return { absoluteOffset: overtime, relativeOffset };
}
return { absoluteOffset: offset, relativeOffset };
} }
/** /**
@@ -175,7 +164,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) { if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
return null; return null;
} }
return state.runtime.plannedEnd - state.runtime.offset + state._rundown.totalDelay; return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
} }
/** /**
-8
View File
@@ -1,8 +0,0 @@
// resolve running environment
const env = process.env.NODE_ENV || 'production';
export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
+1 -1
View File
@@ -10,7 +10,7 @@ import { dirname, join } from 'path';
import { config } from './config.js'; import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js'; import { ensureDirectory } from '../utils/fileManagement.js';
import { isProduction } from './environment.js'; import { isProduction } from '../externals.js';
/** /**
* Returns public path depending on OS * Returns public path depending on OS
@@ -1,4 +1,4 @@
import { TimerPhase, Playback, OffsetMode } from 'ontime-types'; import { TimerPhase, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js'; import type { RuntimeState } from '../runtimeState.js';
@@ -16,12 +16,10 @@ const baseState: RuntimeState = {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 0, numEvents: 0,
offset: 0, offset: 0,
relativeOffset: 0,
plannedStart: 0, plannedStart: 0,
plannedEnd: 0, plannedEnd: 0,
actualStart: null, actualStart: null,
expectedEnd: null, expectedEnd: null,
offsetMode: OffsetMode.Absolute,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -37,12 +35,10 @@ const baseState: RuntimeState = {
}, },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
totalDelay: 0,
pausedAt: null, pausedAt: null,
secondaryTarget: null, secondaryTarget: null,
}, },
_rundown: {
totalDelay: 0,
},
}; };
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState { export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
import { import {
type RuntimeState, type RuntimeState,
addTime, addTime,
clearState, clear,
getState, getState,
load, load,
loadBlock, loadBlock,
@@ -71,7 +71,7 @@ beforeAll(() => {
describe('mutation on runtimeState', () => { describe('mutation on runtimeState', () => {
beforeEach(() => { beforeEach(() => {
clearState(); clear();
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => { vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
const actual = (await importOriginal()) as object; const actual = (await importOriginal()) as object;
@@ -246,7 +246,7 @@ describe('roll mode', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime('jan 1 00:00'); vi.setSystemTime('jan 1 00:00');
clearState(); clear();
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();

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