Compare commits

...

24 Commits

Author SHA1 Message Date
Carlos Valente 943dd19c09 style: wording and UI tweaks (#564)
* style: wording and UI tweaks

* style: prevent note overflow

* style: small wording changes

* soft blink (#569)

* chore: version bump


---------

Co-authored-by: Alex Christoffer Rasmussen <alex.christoffer@gmail.com>
2023-11-07 21:27:33 +01:00
Alex Christoffer Rasmussen 138be478ca Addtime in sec (#576)
* addtime endpoint

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-07 21:24:44 +01:00
Carlos Valente 98bd320cbd style: coloured row background (#571)
* style: coloured row background
2023-11-05 09:47:55 +01:00
Alex Christoffer Rasmussen f57faf1837 remove unnecessary import 2023-11-04 08:46:35 +01:00
arc-alex 44f025b359 update node-osc to v9 2023-11-04 08:46:35 +01:00
Carlos Valente 0555d0f400 feat: expand user options for views with schedule (#565)
* feat: expand user options for views with schedule

* fix: selected public id on load

* fix: load events on roll

* style: remove empty container
2023-11-03 13:47:38 +01:00
Carlos Valente 1e90ce4c1d style: sticky index column (#566)
* style: sticky index column
2023-11-02 20:59:30 +01:00
Carlos Valente 2f09d27813 fix: nullcheck for nextPublicEventId in findRoll (#567)
Co-authored-by: Alex Christoffer Rasmussen <alex.christoffer@gmail.com>
2023-11-02 10:31:05 +01:00
Alex Christoffer Rasmussen a01046b479 Feat/change event api (#553)
* feat: change attribute for given event 

---------

Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-31 19:24:22 +01:00
Enubia 78d5d442cf feat: added export options (#529)
* feat: added export options

* chore: eslint console proposal

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-30 11:41:15 +01:00
Brian Teeman 7c234466f7 typos (#562)
quick fix for some typos/spelling

Small way of saying thanks for the software
2023-10-30 11:36:54 +01:00
Carlos Valente 29e5c96d44 fix: prevent error when port is unchanged (#551)
* fix: prevent error when port is unchanged
2023-10-28 19:01:58 +02:00
Kye Lewis 36b1effaaf Clarify build before E2E steps (#556)
Co-authored-by: Kye <kye@klewis.me>
2023-10-26 14:57:11 +02:00
Alex Christoffer Rasmussen a84c0654e2 Feat resumability (#547)
* add resumability functionality

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2023-10-24 08:33:17 +02:00
Carlos Valente b34fe71995 chore: add line end to prettier (#548) 2023-10-14 19:12:14 +02:00
Carlos Valente 2f74293505 chore: update docs (#537)
- Avoid duplicate instructions on docker and refer to documentation
- Add link to macOS docs on running an unsigned app
2023-10-08 11:47:45 +02:00
Carlos Valente e2aabe3646 chore: version bump (#528) 2023-09-17 15:04:55 +02:00
Carlos Valente 7545f6f7d0 refactor: clarify description import (#526) 2023-09-16 15:24:23 +02:00
Carlos Valente 958c68b6f6 style hover (#525)
* refactor: render if in view

* style: add hover indicator on row
2023-09-16 13:49:12 +02:00
Carlos Valente a0c5375376 refactor: project data (#523) 2023-09-15 17:33:30 +02:00
Carlos Valente 9708f0bfc6 refactor: pass events instead of derived data to titles (#493)
* refactor: pass events instead of derived data to titles
2023-09-14 22:30:23 +02:00
Carlos Valente 13eca98133 refactor: recompose and memoise (#522) 2023-09-14 21:06:48 +02:00
Carlos Valente 126e31403e style tweaks (#524)
* style: allow 2 rows in block title

* fix: accidental change to next and progress bar
2023-09-13 21:51:24 +02:00
Carlos Valente 53beea2768 refactor: prevent calling undefined (#521) 2023-09-13 21:24:49 +02:00
107 changed files with 1774 additions and 991 deletions
+1 -1
View File
@@ -21,6 +21,6 @@
}
],
"rules": {
"no-console": "warn"
"no-console": ["warn", { "allow": ["warn", "error"]}]
}
}
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
+1
View File
@@ -1,4 +1,5 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+2
View File
@@ -56,6 +56,8 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
webserver to test against
These tests also run against a separate version of the DB (test-db)
Before running the E2E, you should first build the project with `pnpm build:local`.
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
+36 -44
View File
@@ -14,10 +14,11 @@
Ontime is an application for creating and managing event running order and timers.
The user inputs a list of events along with scheduling and event information.
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
@@ -28,7 +29,7 @@ This makes for a simple and cheap way to distribute over a venue using a network
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
Any device with a browser in the same network can choose one of the supported views to render the available data.
Any device with a browser in the same network can choose one of the supported views to render the available data.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
@@ -50,8 +51,9 @@ IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
```
For management views
For management views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
@@ -63,14 +65,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Distribute data over network and render it in the browser
- [x] Different screen types
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
@@ -83,17 +85,19 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We want Ontime to be unique by targeting freelancers instead of roles.
We want Ontime to be unique by targeting freelancers instead of roles.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
many environments and workflows.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
work.
## Rich APIs for workflow integrations
@@ -113,7 +117,8 @@ Taking advantage of the integrations, we currently use Ontime with:
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
@@ -121,26 +126,11 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
### Headless run
You can self-host and run Ontime in a docker image. The run command will:
You can self-host and run Ontime in a docker image.
- expose the necessary ports (listed in the Dockerfile)
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
- the image name __getontime/ontime__
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
The docker image is
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
```bash
docker pull getontime/ontime
```
and use the included docker compose to get started
```bash
docker-compose up
```
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
## Roadmap
@@ -148,32 +138,32 @@ Related information available [in the docs](https://ontime.gitbook.io/v2/additio
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or
```Ontime can't be opened because it is from an unidentified developer```
`Ontime can't be opened because it is from an unidentified developer`
or in Linux
```Could Not Display "ontime-linux.AppImage```
`Could Not Display "ontime-linux.AppImage`
You can circumvent this by allowing the execution of the app manually.
- In Windows: click more and select "Run Anyway"
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
Privacy and allow the execution of the app
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
@@ -185,6 +175,7 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
#### Safari
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
@@ -194,7 +185,8 @@ There is no plan for any further work on this.
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
+1
View File
@@ -1,4 +1,5 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.8.2",
"version": "2.13.1",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+2 -2
View File
@@ -1,5 +1,5 @@
// REST stuff
export const EVENT_DATA = ['eventdata'];
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -19,7 +19,7 @@ export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
@@ -1,21 +0,0 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+12 -26
View File
@@ -1,8 +1,9 @@
import axios from 'axios';
import { Alias, EventData, OSCSettings, OscSubscription, Settings, UserFields, ViewSettings } from 'ontime-types';
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
@@ -109,32 +110,17 @@ export async function postOscSubscriptions(data: OscSubscription) {
}
/**
* @description HTTP request to download db
* @return {Promise}
* @description HTTP request to download db in CSV format
*/
export const downloadRundown = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'rundown.json';
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
/**
@@ -178,6 +164,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
export async function postNew(initialData: Partial<EventData>) {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -1,5 +1,3 @@
import Empty from '../state/Empty';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
@@ -13,8 +11,9 @@ interface ScheduleProps {
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return <Empty text='No events to show' />;
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
@@ -1,7 +1,9 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -32,12 +34,25 @@ export const ScheduleProvider = ({
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
const numPages = Math.ceil(events.length / eventsPerPage);
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = events.slice(eventStart, eventEnd);
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
@@ -52,7 +67,9 @@ export const ScheduleProvider = ({
// every SCROLL_TIME go to the next array
useInterval(() => {
if (events.length > eventsPerPage) {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
@@ -132,7 +132,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to supress overtime styles (red borders and red text)',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
},
{
@@ -194,6 +194,37 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
},
];
export const PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
},
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
@@ -1,15 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useEventData() {
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -42,7 +42,7 @@ export default function useFullscreen() {
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen().catch(() => {
element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */
});
}
@@ -55,7 +55,7 @@ export default function useFullscreen() {
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */
});
}
+5 -2
View File
@@ -5,7 +5,9 @@ import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -77,7 +79,8 @@ export const setPlayback = {
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
titles: state.titles,
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
@@ -92,7 +95,7 @@ export const useCuesheet = () => {
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -1,6 +1,6 @@
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
publicUrl: '',
+4 -20
View File
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
nextEventId: null,
nextPublicEventId: null,
},
titles: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
};
export const runtime = createStore<RuntimeStore>(() => ({
+1 -1
View File
@@ -47,7 +47,7 @@ export const getAliasRoute = (location: Location, data: Alias[], searchParams: U
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
@@ -0,0 +1,56 @@
import axios from 'axios';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, userFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
-12
View File
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
runtime.setState(state);
break;
}
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
+3 -3
View File
@@ -10,13 +10,13 @@ type ColourCombination = {
* @param bgColour
* @return {{backgroundColor, color: string}}
*/
export const getAccessibleColour = (bgColour: string): ColourCombination => {
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
} catch (error) {
console.log(`Unable to parse colour: ${bgColour}`);
} catch (_error) {
/* we do not handle errors here */
}
}
return { backgroundColor: '#000', color: '#fffffa' };
@@ -42,7 +42,7 @@ export default function MessageControl() {
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<div className={style.buttonSection}>
<label className={style.label}>Timer messsage blink</label>
<label className={style.label}>Timer message blink</label>
<label className={style.label}>Blackout timer screens</label>
<Button
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
@@ -44,6 +44,11 @@ $table-header-font-size: calc(1rem - 3px);
min-width: 2rem;
text-align: right;
font-weight: 400;
position: sticky;
left: 0;
z-index: 1;
background-color: $gray-1300;
}
}
@@ -61,6 +66,11 @@ $table-header-font-size: calc(1rem - 3px);
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
background-color: $gray-1250;
border-radius: 2px;
+27 -119
View File
@@ -1,34 +1,21 @@
import { useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { SortableCell } from './tableElements/SortableCell';
import { initialColumnOrder } from './cuesheetCols';
import style from './Cuesheet.module.scss';
const pastOpacity = '0.2';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
@@ -67,49 +54,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
getCoreRowModel: getCoreRowModel(),
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
};
@@ -122,6 +66,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -137,37 +83,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<thead className={style.tableHeader}>
{table.getHeaderGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<CuesheetHeader headerGroups={headerGroups} />
<tbody>
{table.getRowModel().rows.map((row) => {
const key = row.original.id;
@@ -177,13 +93,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
if (isOntimeBlock(row.original)) {
const title = row.original.title;
return (
<tr key={key} className={style.blockRow}>
<td>{title}</td>
</tr>
);
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
@@ -192,12 +102,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
const delayTime = millisToDelayString(delayVal);
return (
<tr key={key} className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(row.original)) {
eventIndex++;
@@ -210,25 +115,28 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
const bgFallback = 'transparent';
const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = row.original.skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
if (isSelected) {
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const colour = new Color(row.original.colour).alpha(0.25);
rowBgColour = colour.hsl().string();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<tr
<EventRow
key={key}
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={isSelected ? selectedRef : undefined}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
@@ -236,7 +144,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</td>
);
})}
</tr>
</EventRow>
);
}
@@ -21,4 +21,4 @@
border: 1px solid $white-10;
border-radius: 4px;
}
}
}
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo } from 'react';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
@@ -20,6 +21,8 @@ export default function CuesheetWrapper() {
const { updateEvent } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [headerData, setheaderData] = useState<ProjectData | null>(null);
// Set window title
useEffect(() => {
@@ -69,37 +72,76 @@ export default function CuesheetWrapper() {
);
const exportHandler = useCallback(
(headerData: EventData) => {
(headerData: ProjectData, exportType: ExportType) => {
if (!headerData || !rundown || !userFields) {
return;
}
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
let fileName = '';
let url = '';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
if (exportType === 'json') {
const jsonContent = JSON.stringify({
headerData,
rundown,
userFields,
});
fileName = 'ontime export.json';
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else if (exportType === 'csv') {
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
fileName = 'ontime export.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else {
console.error('Invalid export type: ', exportType);
return;
}
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'ontime export.csv');
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
return;
},
[rundown, userFields],
);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (headerData) {
exportHandler(headerData, exportType);
}
};
const handleOpenModal = (projectData: ProjectData) => {
setheaderData(projectData);
setIsModalOpen(true);
};
if (!rundown || !userFields) {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
</div>
);
}
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Ontime · Schedule Template",
],
[
"Event Name",
"Project Title",
"",
],
[
"Project Description",
"",
],
[
@@ -0,0 +1,18 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -0,0 +1,108 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
export default memo(CuesheetHeader);
@@ -0,0 +1,6 @@
interface CuesheetRowProps {
row: OntimeRundownEntry;
isSelected: boolean;
}
function CuesheetRow() {}
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Cuesheet.module.scss';
interface DelayRowProps {
duration: number;
}
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
return (
<tr className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
export default memo(DelayRow);
@@ -0,0 +1,66 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Cuesheet.module.scss';
const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgColour = colour;
const textColour = getAccessibleColour(bgColour);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
</tr>
);
}
export default memo(EventRow);
@@ -3,21 +3,20 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: EventData) => void;
handleExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
@@ -26,18 +25,17 @@ interface CuesheetTableHeaderProps {
};
}
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const { data: project } = useProjectData();
const exportCsv = () => {
if (event) {
handleCSVExport(event);
const exportProject = () => {
if (project) {
handleExport(project);
}
};
@@ -47,32 +45,17 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
@@ -89,9 +72,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
<span className={style.actionIcon} onClick={exportCsv}>
CSV
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionIcon} onClick={exportProject}>
Export
</span>
</Tooltip>
</div>
@@ -0,0 +1,31 @@
import { formatDisplay } from 'ontime-utils';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const timer = useTimer();
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
</>
);
}
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -19,7 +20,7 @@ interface CuesheetTableSettingsProps {
handleClearToggles: () => void;
}
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
@@ -81,3 +82,5 @@ export default function CuesheetTableSettings(props: CuesheetTableSettingsProps)
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -6,8 +6,8 @@ import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
import style from './Cuesheet.module.scss';
@@ -1,5 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
+8 -8
View File
@@ -12,17 +12,17 @@ export default function Info() {
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = {
title: data.titles.titleNow || '',
subtitle: data.titles.subtitleNow || '',
presenter: data.titles.presenterNow || '',
note: data.titles.noteNow || '',
title: data.eventNow?.title || '',
subtitle: data.eventNow?.subtitle || '',
presenter: data.eventNow?.presenter || '',
note: data.eventNow?.note || '',
};
const titlesNext = {
title: data.titles.titleNext || '',
subtitle: data.titles.subtitleNext || '',
presenter: data.titles.presenterNext || '',
note: data.titles.noteNext || '',
title: data.eventNext?.title || '',
subtitle: data.eventNext?.subtitle || '',
presenter: data.eventNext?.presenter || '',
note: data.eventNext?.note || '',
};
const selected = !data.numEvents
@@ -1,9 +1,9 @@
import useEventData from '../../../common/hooks-query/useEventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData();
const { data } = useProjectData();
return (
<>
+22 -3
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
@@ -10,11 +10,12 @@ import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import style from './MenuBar.module.scss';
@@ -100,6 +101,22 @@ const MenuBar = (props: MenuBarProps) => {
};
}, [handleKeyPress, isElectron]);
const [isModalOpen, setIsModalOpen] = useState(false);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (exportType === 'json') {
downloadRundown();
} else if (exportType === 'csv') {
downloadCSV();
}
};
return (
<VStack>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
@@ -128,12 +145,14 @@ const MenuBar = (props: MenuBarProps) => {
{...buttonStyle}
icon={<IoSaveOutline />}
isDisabled={appMode === AppMode.Run}
clickHandler={downloadRundown}
clickHandler={() => setIsModalOpen(true)}
tooltip='Export project file'
aria-label='Export project file'
size='sm'
/>
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
@@ -0,0 +1,10 @@
.modalHeader {
font-weight: 400;
}
.modalBody {
display: flex;
justify-content: space-between;
margin-top: 1rem;
margin-bottom: 1rem;
}
@@ -0,0 +1,32 @@
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import styles from './ExportModal.module.scss';
export type ExportType = 'csv' | 'json';
interface ExportModalProps {
isOpen: boolean;
onClose: (type?: ExportType) => void;
}
export default function ExportModal(props: ExportModalProps) {
const { isOpen, onClose } = props;
return (
<Modal isOpen={isOpen} onClose={onClose} motionPreset='scale' size='xl' colorScheme='blackAlpha'>
<ModalOverlay />
<ModalContent>
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.modalBody}>
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='48%'>
Rundown as CSV
</Button>
<Button onClick={() => onClose('json')} variant='ontime-filled' width='48%'>
Project file
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -16,12 +16,12 @@ import {
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { EventData } from 'ontime-types';
import type { ProjectData } from 'ontime-types';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
@@ -32,7 +32,7 @@ interface QuickStartProps {
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useEventData();
const { data, status } = useProjectData();
const {
handleSubmit,
register,
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<EventData>) => {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
onClose();
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
}
};
const onReset = () => reset(eventDataPlaceholder);
const onReset = () => reset(projectDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and event data will be reset
On submit, application options will be kept but rundown and project data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event title
Project title
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
Project description
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public Info
Public info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR Code Url
Public QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage Info
Backstage info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR Code Url
Backstage QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { postProjectData } from '../../../common/api/projectDataApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData();
export default function ProjectDataForm() {
const { data, status, isFetching, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<EventData>({
} = useForm<ProjectData>({
defaultValues: data,
values: data,
resetOptions: {
@@ -34,11 +34,11 @@ export default function EventDataForm() {
}
}, [data, reset]);
const onSubmit = async (formData: EventData) => {
const onSubmit = async (formData: ProjectData) => {
try {
await postEventData(formData);
await postProjectData(formData);
} catch (error) {
logAxiosError('Error saving event settings', error);
logAxiosError('Error saving project data', error);
} finally {
await refetch();
}
@@ -55,10 +55,10 @@ export default function EventDataForm() {
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Event title'
title='Project title'
description='Shown in overview screens'
error={errors.title?.message}
>
@@ -73,7 +73,7 @@ export default function EventDataForm() {
</ModalInput>
<ModalInput
field='description'
title='Event description'
title='Project description'
description='Free field, shown in editor'
error={errors.description?.message}
>
@@ -87,7 +87,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -107,7 +107,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -128,7 +128,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<OntimeModalFooter
formId='event-data'
formId='project-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm';
import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App</Tab>
<Tab>Event Data</Tab>
<Tab>Project Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<AppSettingsModal />
</TabPanel>
<TabPanel>
<EventDataForm />
<ProjectDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
@@ -9,7 +9,7 @@ import { getOperatorOptions } from '../../common/components/view-params-editor/c
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useEventData from '../../common/hooks-query/useEventData';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import { isStringBoolean } from '../../common/utils/viewUtils';
@@ -27,7 +27,7 @@ type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useEventData();
const { data: projectData, status: projectDataStatus } = useProjectData();
const featureData = useOperator();
const [searchParams] = useSearchParams();
@@ -128,6 +128,9 @@ $skip-opacity: 0.1;
.eventTitle {
grid-area: title;
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
}
.eventActions {
@@ -172,6 +175,9 @@ $skip-opacity: 0.1;
font-size: calc(1rem - 3px);
color: $block-text-color;
line-height: calc(1rem - 3px);
max-height: 2rem;
overflow-y: hidden;
}
@@ -1,16 +1,14 @@
/* eslint-disable react/display-name */
import { ComponentType, useMemo } from 'react';
import { TitleBlock } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { useStore } from 'zustand';
import useEventData from '../../common/hooks-query/useEventData';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
const withData = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
// persisted app state
@@ -18,56 +16,34 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// HTTP API data
const { data: rundownData } = useRundown();
const { data: eventData } = useEventData();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
return rundownData.filter((e) => e.type === 'event' && e.title && e.isPublic);
return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic);
}
return [];
}, [rundownData]);
// websocket data
const data = useStore(runtime);
const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data;
const publicSelectedId = data.loaded.selectedPublicEventId;
const selectedId = data.loaded.selectedEventId;
const nextId = data.loaded.nextEventId;
/********************************************/
/*** + titleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showNow = true;
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
// is there a next field?
let showNext = true;
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
const titleManager: TitleManager = { ...titles, showNow: showNow, showNext: showNext };
/********************************************/
/*** + publicTitleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showPublicNow = true;
if (!titlesPublic.titleNow && !titlesPublic.subtitleNow && !titlesPublic.presenterNow) showPublicNow = false;
// is there a next field?
let showPublicNext = true;
if (!titlesPublic.titleNext && !titlesPublic.subtitleNext && !titlesPublic.presenterNext) showPublicNext = false;
const publicTitleManager: TitleManager = {
...titlesPublic,
showNow: showPublicNow,
showNext: showPublicNext,
};
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
playback,
onAir,
eventNext,
publicEventNext,
publicEventNow,
eventNow,
loaded,
} = data;
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
/******************************************/
/*** + TimeManagerType ***/
@@ -75,9 +51,6 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
/*** -------------------------------- ***/
/******************************************/
// inject info:
// is timer finished
// get clock string
const TimeManagerType = {
...timer,
playback,
@@ -95,8 +68,10 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
title={titleManager}
publicTitle={publicTitleManager}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
publicEventNext={publicEventNext}
time={TimeManagerType}
events={publicEvents}
backstageEvents={rundownData}
@@ -104,7 +79,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={eventData}
general={project}
onAir={onAir}
/>
);
@@ -25,7 +25,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -89,7 +89,10 @@
border-radius: 8px;
&.blink {
animation: blink 0.5s ease-in-out 3;
animation-name: blink;
animation-timing-function: ease-in-out;
animation-iteration-count: 3;
animation-duration: 1s;
}
}
@@ -99,7 +102,11 @@
margin-top: 2em;
padding-top: 1em;
display: flex;
gap: 7.5em;
}
.timer-gap {
flex: 1;
max-width: 7.5em;
}
.aux-timers {
@@ -159,9 +166,12 @@
@keyframes blink {
0% {
background-color: var(--card-background-color-blink-override, $playback-start);
}
20% {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
}
}
50% {
background-color: var(--card-background-color-blink-override, $playback-start);
}
100% {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
}
}
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, SupportedEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,14 +11,13 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Backstage.scss';
@@ -30,16 +29,17 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
title: TitleManager;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -92,8 +92,8 @@ export default function Backstage(props: BackstageProps) {
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -110,7 +110,7 @@ export default function Backstage(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{title.showNow && (
{eventNow && (
<motion.div
className={`event now ${blinkClass ? 'blink' : ''}`}
key='now'
@@ -121,19 +121,21 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='now'
title={title.titleNow}
subtitle={title.subtitleNow}
presenter={title.presenterNow}
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
/>
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
<div className='aux-timers__value'>{startedAt}</div>
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
<div className='aux-timers__value'>{expectedFinish}</div>
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
<div className='aux-timers__value'>{stageTimer}</div>
@@ -144,7 +146,7 @@ export default function Backstage(props: BackstageProps) {
</AnimatePresence>
<AnimatePresence>
{title.showNext && (
{eventNext && (
<motion.div
className='event next'
key='next'
@@ -155,9 +157,9 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='next'
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
</motion.div>
)}
@@ -5,7 +5,6 @@ import { Message } from 'ontime-types';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { TitleManager } from '../ViewWrapper';
import { LowerOptions } from './LowerWrapper';
@@ -13,12 +12,14 @@ import './LowerLines.scss';
interface LowerLinesProps {
lower: Message;
title: TitleManager;
heading: string;
subheading: string;
options: LowerOptions;
doShow: boolean;
}
export default function LowerLines(props: LowerLinesProps) {
const { lower, title, options } = props;
const { lower, heading, subheading, options, doShow } = props;
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
@@ -36,8 +37,8 @@ export default function LowerLines(props: LowerLinesProps) {
}, [options.fadeOut, options.transitionIn]);
useEffect(() => {
setShowLower(title.showNow);
}, [title.showNow]);
setShowLower(doShow);
}, [doShow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
@@ -146,14 +147,14 @@ export default function LowerLines(props: LowerLinesProps) {
>
<motion.div className='title-container' variants={titleContainerVariants}>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
{heading}
</motion.div>
<div className='title-decor' />
</motion.div>
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
<div className='sub-decor' />
<motion.div className='subtitle' variants={subtitleVariants}>
{title.presenterNow}
{subheading}
</motion.div>
</motion.div>
</motion.div>
@@ -1,11 +1,10 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { Message, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TitleManager } from '../ViewWrapper';
import LowerLines from './LowerLines';
@@ -17,33 +16,26 @@ export type LowerOptions = {
keyColour?: string;
fadeOut: number;
};
interface LowerProps {
title: TitleManager;
eventNow: OntimeEvent | null;
lower: Message;
viewSettings: ViewSettings;
}
// prevent triggering animation without a content change
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props: LowerProps) => {
const { title, lower, viewSettings } = props;
const { eventNow, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [titles, setTitles] = useState<TitleManager>({
titleNow: '',
titleNext: '',
subtitleNow: '',
subtitleNext: '',
presenterNow: '',
presenterNext: '',
noteNow: '',
noteNext: '',
showNow: false,
showNext: false,
});
const [heading, setHeading] = useState('');
const [subheading, setSubheading] = useState('');
const [showLower, setShowLower] = useState(false);
// Set window title
useEffect(() => {
@@ -54,28 +46,32 @@ const Lower = (props: LowerProps) => {
useEffect(() => {
// clear titles if necessary
// will trigger an animation out in the component
let timeout: NodeJS.Timeout | null = null;
if (
title?.titleNow !== titles?.titleNow ||
title?.subtitleNow !== titles?.subtitleNow ||
title?.presenterNow !== titles?.presenterNow
) {
setTitles((t) => ({ ...t, showNow: false }));
let timeout: NodeJS.Timeout;
const transitionTime = 2000;
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
// we have new titles
if (haveTitlesChanged && !areTitlesEmpty) {
// show lower
setHeading(eventNow?.title ?? '');
setSubheading(eventNow?.presenter ?? '');
setShowLower(true);
// schedule transition out
const transitionTime = 5000;
timeout = setTimeout(() => {
setTitles(title);
setShowLower(false);
}, transitionTime);
}
return () => {
if (timeout != null) {
if (timeout) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line -- we do this to keep animations
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
}, [eventNow?.title, eventNow?.presenter]);
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -135,7 +131,7 @@ const Lower = (props: LowerProps) => {
}
}
return <LowerLines lower={lower} title={titles} options={options} />;
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
};
export default memo(Lower, areEqual);
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -19,7 +19,6 @@ interface MinimalTimerProps {
pres: TimerMessage;
time: TimeManagerType;
viewSettings: ViewSettings;
general: EventData;
}
export default function MinimalTimer(props: MinimalTimerProps) {
@@ -24,7 +24,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -9,14 +9,13 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Public.scss';
@@ -28,16 +27,18 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
publicTitle: TitleManager;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: TimeManagerType;
events: OntimeEvent[];
publicSelectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
export default function Public(props: BackstageProps) {
const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -57,8 +58,8 @@ export default function Public(props: BackstageProps) {
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -68,7 +69,7 @@ export default function Public(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{publicTitle.showNow && (
{publicEventNow && (
<motion.div
className='event now'
key='now'
@@ -79,16 +80,16 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='now'
title={publicTitle.titleNow}
subtitle={publicTitle.subtitleNow}
presenter={publicTitle.presenterNow}
title={publicEventNow.title}
subtitle={publicEventNow.subtitle}
presenter={publicEventNow.presenter}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{publicTitle.showNext && (
{publicEventNext && (
<motion.div
className='event next'
key='next'
@@ -99,9 +100,9 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='next'
title={publicTitle.titleNext}
subtitle={publicTitle.subtitleNext}
presenter={publicTitle.presenterNext}
title={publicEventNext.title}
subtitle={publicEventNext.subtitle}
presenter={publicEventNext.presenter}
/>
</motion.div>
)}
@@ -13,7 +13,6 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig';
import { formatTime } from '../../../common/utils/time';
import { TitleManager } from '../ViewWrapper';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
@@ -26,7 +25,7 @@ const formatOptions = {
interface StudioClockProps {
isMirrored: boolean;
title: TitleManager;
eventNext: OntimeEvent | null;
time: TimeManagerType;
backstageEvents: OntimeRundown;
selectedId: string | null;
@@ -36,7 +35,7 @@ interface StudioClockProps {
}
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
@@ -87,7 +86,7 @@ export default function StudioClock(props: StudioClockProps) {
className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
>
{title.titleNext}
{eventNext?.title ?? ''}
</div>
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId !== null && formatDisplay(time.current)}
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
@@ -13,7 +13,6 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import { TitleManager } from '../ViewWrapper';
import './Timer.scss';
@@ -40,15 +39,15 @@ const titleVariants = {
interface TimerProps {
isMirrored: boolean;
general: EventData;
pres: TimerMessage;
title: TitleManager;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { isMirrored, pres, title, time, viewSettings } = props;
const { isMirrored, pres, eventNow, eventNext, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -139,7 +138,7 @@ export default function Timer(props: TimerProps) {
/>
<AnimatePresence>
{title.showNow && !finished && (
{eventNow && !finished && (
<motion.div
className='event now'
key='now'
@@ -148,13 +147,13 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard label='now' title={title.titleNow} subtitle={title.subtitleNow} presenter={title.presenterNow} />
<TitleCard label='now' title={eventNow.title} subtitle={eventNow.subtitle} presenter={eventNow.presenter} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{title.showNext && (
{eventNext && (
<motion.div
className='event next'
key='next'
@@ -165,9 +164,9 @@ export default function Timer(props: TimerProps) {
>
<TitleCard
label='next'
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
</motion.div>
)}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.8.2",
"version": "2.13.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -20,7 +20,7 @@
},
"scripts": {
"postinstall": "",
"dev:electron": "NODE_ENV=development electron .",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux",
+4 -3
View File
@@ -1,7 +1,8 @@
{
"semi": true,
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2
"printWidth": 120
}
+6 -5
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.8.2",
"version": "2.13.1",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
@@ -14,17 +14,18 @@
"express-validator": "^6.14.2",
"lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1",
"node-osc": "^8.0.10",
"node-osc": "^9.0.2",
"node-xlsx": "^0.21.0",
"ontime-utils": "workspace:*",
"passport": "^0.6.0",
"passport-local": "~1.0.0",
"steno": "^3.1.0",
"ws": "^8.13.0"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/node": "^16.11.7",
"@types/node-osc": "^6.0.0",
"@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
@@ -52,8 +53,8 @@
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint .",
"test": "vitest",
"test:pipeline": "vitest run",
"test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run",
"typecheck": "tsc --noEmit",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
}
+11 -4
View File
@@ -12,16 +12,16 @@ export class OscServer implements IAdapter {
constructor(config: OSCSettings) {
this.osc = new Server(config.portIn, '0.0.0.0');
this.osc.on('error', console.error);
this.osc.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
this.osc.on('message', (msg) => {
// message should look like /ontime/{path} {args} where
// message should look like /ontime/{path}/{params?} {args} where
// ontime: fixed message for app
// path: command to be called
// args: extra data, only used on some API entries (delay, goto)
// split message
const [, address, path] = msg[0].split('/');
const [, address, path, ...params] = msg[0].split('/');
const args = msg[1];
// get first part before (ontime)
@@ -37,7 +37,14 @@ export class OscServer implements IAdapter {
}
try {
const reply = dispatchFromAdapter(path, args, 'osc');
const reply = dispatchFromAdapter(
path,
{
payload: args,
params,
},
'osc',
);
if (reply) {
const { topic, payload } = reply;
this.osc.emit(topic, payload);
+7 -1
View File
@@ -123,7 +123,13 @@ export class SocketServer implements IAdapter {
// Protocol specific stuff handled above
try {
const reply = dispatchFromAdapter(type, payload, 'ws');
const reply = dispatchFromAdapter(
type,
{
payload,
},
'ws',
);
if (reply) {
const { topic, payload } = reply;
ws.send(topic, payload);
+28 -7
View File
@@ -1,3 +1,5 @@
import { LogOrigin, OSCSettings } from 'ontime-types';
import 'dotenv/config';
import express from 'express';
import expressStaticGzip from 'express-static-gzip';
@@ -6,14 +8,12 @@ import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { LogOrigin, OSCSettings } from 'ontime-types';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
import { router as eventDataRouter } from './routes/eventDataRouter.js';
import { router as projectRouter } from './routes/projectRouter.js';
import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as playbackRouter } from './routes/playbackRouter.js';
@@ -31,6 +31,8 @@ import { logger } from './classes/Logger.js';
import { oscIntegration } from './services/integration-service/OscIntegration.js';
import { populateStyles } from './modules/loadStyles.js';
import { eventStore, getInitialPayload } from './stores/EventStore.js';
import { PlaybackService } from './services/PlaybackService.js';
import { RestorePoint, restoreService } from './services/RestoreService.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -55,7 +57,7 @@ app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/events', rundownRouter);
app.use('/eventdata', eventDataRouter);
app.use('/project', projectRouter);
app.use('/ontime', ontimeRouter);
app.use('/playback', playbackRouter);
@@ -137,10 +139,21 @@ export const startServer = async () => {
expressServer = http.createServer(app);
socket.init(expressServer);
eventLoader.init();
// load restore point if it exists
const maybeRestorePoint = restoreService.load();
if (maybeRestorePoint) {
logger.info(LogOrigin.Server, 'Found resumable state');
PlaybackService.resume(maybeRestorePoint);
}
eventTimer.setRestoreCallback(async (newState: RestorePoint) => restoreService.save(newState));
// provide initial payload to event store
eventLoader.init();
eventStore.init(getInitialPayload());
const initialPayload = getInitialPayload();
eventStore.init(initialPayload);
expressServer.listen(serverPort, '0.0.0.0');
@@ -187,7 +200,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
}
const { success, message } = oscIntegration.init(osc);
logger.info(LogOrigin.Rx, message);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
@@ -202,6 +215,14 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
export const shutdown = async (exitCode = 0) => {
console.log(`Ontime shutting down with code ${exitCode}`);
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 99 means it was the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
}
expressServer?.close();
oscServer?.shutdown();
eventTimer.shutdown();
@@ -2,7 +2,7 @@
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { EventData, OntimeRundown, ViewSettings } from 'ontime-types';
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
@@ -12,14 +12,14 @@ export class DataProvider {
return data;
}
static async setEventData(newData: Partial<EventData>) {
data.eventData = { ...data.eventData, ...newData };
static async setProjectData(newData: Partial<ProjectData>) {
data.project = { ...data.project, ...newData };
await this.persist();
return data.eventData;
return data.project;
}
static getEventData() {
return data.eventData;
static getProjectData() {
return data.project;
}
static async setRundown(newData: OntimeRundown) {
@@ -97,7 +97,7 @@ export class DataProvider {
static async mergeIntoData(newData) {
const mergedData = safeMerge(data, newData);
data.eventData = mergedData.eventData;
data.project = mergedData.project;
data.settings = mergedData.settings;
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
@@ -4,11 +4,11 @@
* @param {object} newData
*/
export function safeMerge(existing, newData) {
const { rundown, eventData, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
return {
...existing,
rundown: rundown ?? existing.rundown,
eventData: { ...existing.eventData, ...eventData },
project: { ...existing.project, ...project },
settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings },
aliases: aliases ?? existing.aliases,
@@ -3,7 +3,7 @@ import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => {
const existing = {
rundown: [],
eventData: {
project: {
title: 'existing title',
publicUrl: 'existing public URL',
backstageUrl: 'existing backstageUrl',
@@ -62,15 +62,15 @@ describe('safeMerge', () => {
expect(mergedData.rundown).toEqual(newData.rundown);
});
it('merges the event key', () => {
it('merges the project key', () => {
const newData = {
eventData: {
project: {
title: 'new title',
publicInfo: 'new public info',
},
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.eventData).toEqual({
expect(mergedData.project).toEqual({
title: 'new title',
publicUrl: 'existing public URL',
publicInfo: 'new public info',
@@ -1,4 +1,4 @@
import { Loaded, OntimeEvent, SupportedEvent, TitleBlock } from 'ontime-types';
import { Loaded, OntimeEvent, SupportedEvent } from 'ontime-types';
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
@@ -10,10 +10,11 @@ let instance;
* Manages business logic around loading events
*/
export class EventLoader {
loadedEvent: OntimeEvent | null;
loaded: Loaded;
titles: TitleBlock;
titlesPublic: TitleBlock;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
constructor() {
if (instance) {
@@ -22,6 +23,18 @@ export class EventLoader {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.eventNow = null;
this.publicEventNow = null;
this.eventNext = null;
this.publicEventNext = null;
this.loaded = {
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
numEvents: 0,
};
}
// we need to delay init until the store is ready
@@ -56,7 +69,7 @@ export class EventLoader {
}
/**
* returns an event given its index
* returns an event given its index after filtering for OntimeEvents
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/
@@ -65,22 +78,12 @@ export class EventLoader {
return timedEvents?.[eventIndex];
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getPlayableAtIndex(eventIndex) {
const timedEvents = EventLoader.getPlayableEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its id
* @param {string} eventId
* @return {object | undefined}
*/
static getEventWithId(eventId) {
static getEventWithId(eventId): OntimeEvent | undefined {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
@@ -167,16 +170,20 @@ export class EventLoader {
timeNow,
);
this.loadedEvent = currentEvent;
// load events
this.eventNow = currentEvent;
this.publicEventNow = currentPublicEvent;
this.eventNext = nextEvent;
this.publicEventNext = nextPublicEvent;
// loaded data summary
this.loaded.selectedEventIndex = nowIndex;
this.loaded.selectedEventId = currentEvent?.id || null;
this.loaded.numEvents = timedEvents.length;
this.loaded.nextEventId = nextEvent?.id || null;
this.loaded.nextPublicEventId = nextPublicEvent?.id || null;
// titles
this._loadThisTitles(currentEvent, 'now-private');
this._loadThisTitles(currentPublicEvent, 'now-public');
this._loadThisTitles(nextEvent, 'next-private');
this._loadThisTitles(nextPublicEvent, 'next-public');
this._loadEvent();
return { currentEvent, nextEvent, timeToNext };
}
@@ -187,10 +194,11 @@ export class EventLoader {
*/
getLoaded() {
return {
loadedEvent: this.loadedEvent,
loaded: this.loaded,
titles: this.titles,
titlesPublic: this.titlesPublic,
eventNow: this.eventNow,
publicEventNow: this.publicEventNow,
eventNext: this.eventNext,
publicEventNext: this.publicEventNext,
};
}
@@ -206,7 +214,10 @@ export class EventLoader {
* Resets instance state
*/
reset(emit = true) {
this.loadedEvent = null;
this.eventNow = null;
this.publicEventNow = null;
this.eventNext = null;
this.publicEventNext = null;
this.loaded = {
selectedEventIndex: null,
selectedEventId: null,
@@ -215,26 +226,6 @@ export class EventLoader {
nextPublicEventId: null,
numEvents: EventLoader.getPlayableEvents().length,
};
this.titles = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
this.titlesPublic = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
// workaround for socket not being ready in constructor
if (emit) {
@@ -246,7 +237,7 @@ export class EventLoader {
* loads an event given its id
* @param {object} event
*/
loadEvent(event) {
loadEvent(event?: OntimeEvent) {
if (typeof event === 'undefined') {
return null;
}
@@ -255,13 +246,12 @@ export class EventLoader {
const playableEvents = EventLoader.getPlayableEvents();
// we know some stuff now
this.loadedEvent = event;
this.loaded.selectedEventIndex = eventIndex;
this.loaded.selectedEventId = event.id;
this.loaded.numEvents = timedEvents.length;
// this.nextEventId = playableEvents[eventIndex + 1].id;
this._loadTitlesNow(event, playableEvents);
this._loadTitlesNext(playableEvents);
this.eventNow = event;
this._loadEventNow(event, playableEvents);
this._loadEventNext(playableEvents);
this._loadEvent();
@@ -274,29 +264,29 @@ export class EventLoader {
private _loadEvent() {
eventStore.batchSet({
loaded: this.loaded,
titles: this.titles,
titlesPublic: this.titlesPublic,
eventNow: this.eventNow,
publicEventNow: this.publicEventNow,
eventNext: this.eventNext,
publicEventNext: this.publicEventNext,
});
}
/**
* @description loads given title (now)
* @description loads currently running events
* @private
* @param {object} event
* @param {array} rundown
*/
private _loadTitlesNow(event, rundown) {
// private title is always current
private _loadEventNow(event, rundown) {
this.eventNow = event;
// check if current is also public
if (event.isPublic) {
this._loadThisTitles(event, 'now');
this.publicEventNow = event;
this.loaded.selectedPublicEventId = event.id;
} else {
this._loadThisTitles(event, 'now-private');
// assume there is no public event
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.publicEventNow = null;
this.loaded.selectedPublicEventId = null;
// if there is nothing before, return
@@ -305,7 +295,8 @@ export class EventLoader {
// iterate backwards to find it
for (let i = this.loaded.selectedEventIndex; i >= 0; i--) {
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'now-public');
this.publicEventNow = rundown[i];
this.loaded.selectedPublicEventId = rundown[i].id;
break;
}
}
@@ -313,180 +304,44 @@ export class EventLoader {
}
/**
* @description look for next titles to load
* @description look for next events
* @private
*/
private _loadTitlesNext(rundown) {
// maybe there is nothing to load
if (this.loaded.selectedEventIndex === null) return;
// assume there is no next event
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
private _loadEventNext(rundown) {
// assume there are no next events
this.eventNext = null;
this.publicEventNext = null;
this.loaded.nextEventId = null;
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.loaded.nextPublicEventId = null;
if (this.loaded.selectedEventIndex === null) return;
const numEvents = rundown.length;
if (this.loaded.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextPrivate = false;
let nextProduction = false;
for (let i = this.loaded.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextPrivate) {
this._loadThisTitles(rundown[i], 'next-private');
nextPrivate = true;
if (!nextProduction) {
this.eventNext = rundown[i];
this.loaded.nextEventId = rundown[i].id;
nextProduction = true;
}
// if event is public
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'next-public');
this.publicEventNext = rundown[i];
this.loaded.nextPublicEventId = rundown[i].id;
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextPrivate) break;
if (nextPublic && nextProduction) break;
}
}
}
/**
* @description loads given title
* @param event
* @param type
* @private
*/
private _loadThisTitles(event, type) {
if (type === 'now') {
if (event === null) {
// public
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
// private
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
// public
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
// private
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
} else if (type === 'now-public') {
if (event === null) {
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
} else {
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
}
} else if (type === 'now-private') {
if (event === null) {
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
}
// next, load to both public and private
else if (type === 'next') {
if (event === null) {
// public
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNext = null;
this.loaded.nextPublicEventId = null;
// private
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
} else {
// public
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.loaded.nextPublicEventId = event.id;
// private
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id;
}
} else if (type === 'next-public') {
if (event === null) {
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNext = null;
this.loaded.nextPublicEventId = null;
} else {
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.loaded.nextPublicEventId = event.id;
}
} else if (type === 'next-private') {
if (event === null) {
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
} else {
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id;
}
} else {
throw new Error(`Unhandled title type: ${type}`);
}
}
}
export const eventLoader = new EventLoader();
+1 -1
View File
@@ -3,10 +3,10 @@ export const config = {
testdb: 'test-db',
directory: 'db',
filename: 'db.json',
tablename: 'events',
},
styles: {
directory: 'styles',
filename: 'override.css',
},
restoreFile: 'ontime.restore',
};
@@ -0,0 +1,69 @@
import { LogOrigin, OntimeEvent } from 'ontime-types';
import { EventLoader } from '../classes/event-loader/EventLoader.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js';
import { logger } from '../classes/Logger.js';
const whitelistedPayload = {
title: coerceString,
subtitle: coerceString,
presenter: coerceString,
note: coerceString,
cue: coerceString,
duration: coerceNumber,
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceString,
user0: coerceString,
user1: coerceString,
user2: coerceString,
user3: coerceString,
user4: coerceString,
user5: coerceString,
user6: coerceString,
user7: coerceString,
user8: coerceString,
user9: coerceString,
};
export function parse(field: string, value: unknown) {
if (!whitelistedPayload.hasOwnProperty(field)) {
throw new Error(`Field ${field} not permitted`);
}
const parserFn = whitelistedPayload[field];
return parserFn(value);
}
/**
* Updates a property of the event with the given id
* @param {string} eventId
* @param {keyof OntimeEvent} propertyName
* @param {OntimeEvent[typeof propertyName]} newValue
*/
export function updateEvent(
eventId: string,
propertyName: keyof OntimeEvent,
newValue: OntimeEvent[typeof propertyName],
) {
const event = EventLoader.getEventWithId(eventId);
if (event) {
let propertiesToUpdate = { [propertyName]: newValue };
// Handles the special case for duration
// needs to be converted to milliseconds
if (propertyName === 'duration') {
propertiesToUpdate.duration = (newValue as number) * 1000;
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
}
editEvent({ id: eventId, ...propertiesToUpdate }).then(() => {
logger.info(LogOrigin.Playback, `Updated ${propertyName} of event with ID ${eventId} to ${newValue}`);
});
} else {
throw new Error(`Event with ID ${eventId} not found`);
}
}
@@ -1,9 +1,27 @@
import { OntimeEvent } from 'ontime-types';
import { messageService } from '../services/message-service/MessageService.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { parse, updateEvent } from './integrationController.config.js';
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
import { event } from '../models/eventsDefinition.js';
export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') {
switch (type.toLowerCase()) {
//TODO: re-throwing the error does not add any extra information or value
export function dispatchFromAdapter(
type: string,
args: {
payload: unknown;
params?: Array<string>;
},
source?: 'osc' | 'ws',
) {
const payload = args.payload;
const typeComponents = type.toLowerCase().split('/');
const mainType = typeComponents[0];
const params = args.params || [];
switch (mainType) {
case 'test-ontime': {
return { topic: 'hello' };
}
@@ -157,6 +175,19 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
PlaybackService.roll();
break;
}
case 'addtime': {
const time = Number(payload);
if (isNaN(time)) {
throw new Error(`Time not recognised ${payload}`);
}
try {
PlaybackService.addTime(time);
} catch (error) {
throw new Error(`Could not add time: ${error}`);
}
break;
}
//deprecated
case 'delay': {
const delayTime = Number(payload);
if (isNaN(delayTime)) {
@@ -222,6 +253,23 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
return { topic: 'timer', payload: timer };
}
// ontime/change/{eventID}/{propertyName}
case 'change': {
if (params.length < 2) {
throw new Error('Too few parameters, 3 expected');
}
if (payload === undefined) {
throw new Error('No payload found');
}
const eventID = params[0];
const propertyName = params[1] as keyof OntimeEvent;
if (!isKeyOfType(propertyName, event)) {
throw new Error(`Cannot update unknown event property ${propertyName}`);
}
const parsedPayload = parse(propertyName, payload);
return updateEvent(eventID, propertyName, parsedPayload);
}
default: {
throw new Error(`Unhandled message ${type}`);
}
@@ -1,4 +1,4 @@
import { Alias, EventData, LogOrigin } from 'ontime-types';
import { Alias, LogOrigin, ProjectData } from 'ontime-types';
import { RequestHandler } from 'express';
import fs from 'fs';
@@ -31,7 +31,7 @@ export const poll = async (req, res) => {
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (req, res) => {
const { title } = DataProvider.getEventData();
const { title } = DataProvider.getProjectData();
const fileTitle = title || 'ontime data';
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
@@ -207,11 +207,16 @@ export const postSettings = async (req, res) => {
const settings = DataProvider.getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
if (isNaN(serverPort)) {
return res.status(400).send(`Invalid value found for server port: ${req.body?.serverPort}`);
}
if (isDocker && req.body?.serverPort) {
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
const serverPort = parseInt(req.body?.serverPort ?? settings.serverPort, 10);
let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
@@ -290,7 +295,7 @@ export const postOscSubscriptions = async (req, res) => {
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Rx, message);
logger.info(LogOrigin.Tx, message);
res.send(oscSettings).status(200);
} catch (error) {
@@ -311,7 +316,7 @@ export const postOSC = async (req, res) => {
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Rx, message);
logger.info(LogOrigin.Tx, message);
res.send(oscSettings).status(200);
} catch (error) {
@@ -334,7 +339,7 @@ export const dbUpload = async (req, res) => {
// Create controller for POST request to '/ontime/new'
export const postNew: RequestHandler = async (req, res) => {
try {
const newEventData: EventData = {
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
@@ -342,7 +347,7 @@ export const postNew: RequestHandler = async (req, res) => {
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
const newData = await DataProvider.setEventData(newEventData);
const newData = await DataProvider.setProjectData(newProjectData);
await deleteAllEvents();
res.status(201).send(newData);
} catch (error) {
@@ -1,24 +1,24 @@
import { RequestHandler } from 'express';
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
// Create controller for GET request to 'event'
export const getEventData: RequestHandler = async (req, res) => {
res.json(DataProvider.getEventData());
// Create controller for GET request to 'project'
export const getProject: RequestHandler = async (req, res) => {
res.json(DataProvider.getProjectData());
};
// Create controller for POST request to 'event'
export const postEventData: RequestHandler = async (req, res) => {
// Create controller for POST request to 'project'
export const postProject: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<EventData> = removeUndefined({
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
@@ -27,7 +27,7 @@ export const postEventData: RequestHandler = async (req, res) => {
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setEventData(newEvent);
const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
@@ -1,6 +1,6 @@
import { body, validationResult } from 'express-validator';
export const eventDataSanitizer = [
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
+1 -1
View File
@@ -2,7 +2,7 @@ import { DatabaseModel } from 'ontime-types';
export const dbModel: DatabaseModel = {
rundown: [],
eventData: {
project: {
title: '',
description: '',
publicUrl: '',
-11
View File
@@ -1,11 +0,0 @@
import express from 'express';
import { getEventData, postEventData } from '../controllers/eventDataController.js';
import { eventDataSanitizer } from '../controllers/eventDataController.validate.js';
export const router = express.Router();
// create route between controller and 'GET /event' endpoint
router.get('/', getEventData);
// create route between controller and 'POST /event' endpoint
router.post('/', eventDataSanitizer, postEventData);
+2 -2
View File
@@ -27,7 +27,7 @@ import {
validateUserFields,
viewValidator,
} from '../controllers/ontimeController.validate.js';
import { eventDataSanitizer } from '../controllers/eventDataController.validate.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
export const router = express.Router();
@@ -77,4 +77,4 @@ router.post('/osc', validateOSC, postOSC);
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
// create route between controller and '/ontime/new' endpoint
router.post('/new', eventDataSanitizer, postNew);
router.post('/new', projectSanitiser, postNew);
+11
View File
@@ -0,0 +1,11 @@
import express from 'express';
import { getProject, postProject } from '../controllers/projectController.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
export const router = express.Router();
// create route between controller and 'GET /project' endpoint
router.get('/', getProject);
// create route between controller and 'POST /project' endpoint
router.post('/', projectSanitiser, postProject);
+45 -8
View File
@@ -1,4 +1,4 @@
import { LogOrigin, OntimeEvent } from 'ontime-types';
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
@@ -6,6 +6,7 @@ import { eventStore } from '../stores/EventStore.js';
import { eventTimer } from './TimerService.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import { RestorePoint } from './RestoreService.js';
/**
* Service manages playback status of app
@@ -241,17 +242,53 @@ export class PlaybackService {
}
}
/**
* @description resume playback state given a restore point
* @param restorePoint
*/
static resume(restorePoint: RestorePoint) {
const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback');
if (restorePoint.playback === Playback.Roll) {
willResume();
PlaybackService.roll();
}
if (restorePoint.selectedEventId) {
const event = EventLoader.getEventWithId(restorePoint.selectedEventId);
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
if (!event) {
return;
}
eventLoader.loadEvent(event);
eventTimer.resume(event, restorePoint);
eventStore.broadcast();
return;
}
}
/**
* Adds time to current event
* @param {number} time - time to add in seconds
*/
static addTime(time: number) {
if (eventTimer.loadedTimerId) {
const timeInMs = time * 1000;
eventTimer.addTime(timeInMs);
timeInMs > 0
? logger.info(LogOrigin.Playback, `Added ${time} sec`)
: logger.info(LogOrigin.Playback, `Removed ${time} sec`);
}
}
/**
* Adds delay to current event
* @deprecated Use addTime
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime: number) {
if (eventTimer.loadedTimerId) {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
delayInMs > 0
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
}
this.addTime(delayTime * 60);
}
}
+160
View File
@@ -0,0 +1,160 @@
import { Playback } from 'ontime-types';
import { readFileSync } from 'fs';
import { Writer } from 'steno';
import { resolveRestoreFile } from '../setup.js';
export type RestorePoint = {
playback: Playback;
selectedEventId: string | null;
startedAt: number | null;
addedTime: number | null;
pausedAt: number | null;
};
/**
* Utility validates a RestorePoint
* @param obj
* @return boolean
*/
export function isRestorePoint(obj: unknown): obj is RestorePoint {
if (!obj) {
return false;
}
const restorePoint = obj as RestorePoint;
if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) {
return false;
}
if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) {
return false;
}
if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) {
return false;
}
if (typeof restorePoint.addedTime !== 'number' && restorePoint.addedTime !== null) {
return false;
}
if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) {
return false;
}
return true;
}
/**
* Utility interface to allow dependency injection during test
*/
/**
* Service manages saving of application state
* that can then be restored when reopening
*/
export class RestoreService {
private readonly filePath: string | null;
private lastStore: string | null;
private file: Writer | null;
private failedCreateAttempts: number;
constructor(filePath: string) {
this.filePath = filePath;
this.lastStore = null;
this.file = null;
this.failedCreateAttempts = 0;
}
/**
* Utility, creates a restore file
*/
create() {
this.file = new Writer(this.filePath);
}
/**
* Utility, reads from file
* @private
*/
private read() {
return readFileSync(this.filePath, 'utf-8');
}
/**
* Utility writes payload to file
* @throws
* @param stringifiedState
*/
private async write(stringifiedState: string) {
// Create a file if it doesnt exist
if (!this.file) {
this.create();
}
// steno is async, and it uses a queue to avoid unnecessary re-writes
await this.file.write(stringifiedState);
}
/**
* Saves runtime data to restore file
* @param newState RestorePoint
*/
async save(newState: RestorePoint) {
// after three failed attempts, mark the service as unavailable
if (this.failedCreateAttempts > 3) {
return;
}
const stringifiedStore = JSON.stringify(newState);
if (stringifiedStore !== this.lastStore) {
try {
await this.write(stringifiedStore);
this.lastStore = stringifiedStore;
this.failedCreateAttempts = 0;
} catch (_err) {
this.failedCreateAttempts += 1;
}
}
}
/**
* Attempts reading a restore point from a given file path
* Returns null if none found, restore point otherwise
*/
load(): RestorePoint | null {
try {
const data = this.read();
const maybeRestorePoint = JSON.parse(data);
if (!isRestorePoint(maybeRestorePoint)) {
return null;
}
return maybeRestorePoint;
} catch (_error) {
// no need to notify the user
return null;
}
}
/**
* Clears the restore file
*/
async clear() {
if (this.file && this.failedCreateAttempts <= 3) {
try {
await this.file.write('');
} catch (_error) {
// nothing to do
}
}
this.file = undefined;
}
}
export const restoreService = new RestoreService(resolveRestoreFile);
+77 -15
View File
@@ -1,4 +1,4 @@
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { eventStore } from '../stores/EventStore.js';
@@ -8,6 +8,7 @@ import { integrationService } from './integration-service/IntegrationService.js'
import { getCurrent, getExpectedFinish } from './timerUtils.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import type { RestorePoint } from './RestoreService.js';
type initialLoadingData = {
startedAt?: number | null;
@@ -15,6 +16,8 @@ type initialLoadingData = {
current?: number | null;
};
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
export class TimerService {
private readonly _interval: NodeJS.Timer;
private _updateInterval: number;
@@ -31,6 +34,7 @@ export class TimerService {
private pausedAt: number | null;
private secondaryTarget: number | null;
private saveRestorePoint: RestoreCallback;
/**
* @constructor
* @param {object} [timerConfig]
@@ -43,6 +47,14 @@ export class TimerService {
this._updateInterval = timerConfig?.updateInterval ?? 1000;
}
/**
* Provides callback to save restore point
* @param cb
*/
setRestoreCallback(cb: RestoreCallback) {
this.saveRestorePoint = cb;
}
/**
* Clears internal state
* @private
@@ -74,6 +86,44 @@ export class TimerService {
this._lastUpdate = null;
}
/**
* Resumes a given playback state, same as load
* @param {RestorePoint} restorePoint
* @param {OntimeEvent} timer
*/
resume(timer: OntimeEvent, restorePoint: RestorePoint) {
this._clear();
// this is pretty much the same as load, with a few exceptions
this.loadedTimerId = timer.id;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = restorePoint.playback;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.timer.startedAt = restorePoint.startedAt;
this.timer.addedTime = restorePoint.addedTime;
this.pausedTime = 0;
this.pausedAt = restorePoint.pausedAt;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
this._onResume();
}
_onResume() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
}
/**
* Reloads information for currently running timer
* @param timer
@@ -121,17 +171,10 @@ export class TimerService {
/**
* Loads given timer to object
* @param {object} timer
* @param initialData
* @param {number} timer.id
* @param {number} timer.timeStart
* @param {number} timer.timeEnd
* @param {number} timer.duration
* @param {string} timer.timerBehaviour
* @param {string} timer.timerType
* @param {boolean} timer.skip
* @param {OntimeEvent} timer
* @param {initialLoadingData} initialData
*/
load(timer, initialData?: initialLoadingData) {
load(timer: OntimeEvent, initialData?: initialLoadingData) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
@@ -155,7 +198,7 @@ export class TimerService {
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
if (typeof initialData !== 'undefined') {
if (initialData) {
this.timer = { ...this.timer, ...initialData };
}
@@ -172,12 +215,13 @@ export class TimerService {
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onLoad);
this._saveState();
}
start() {
if (!this.loadedTimerId) {
if (this.playback === Playback.Roll) {
logger.error('PLAYBACK', 'Cannot start while waiting for event');
logger.error(LogOrigin.Playback, 'Cannot start while waiting for event');
}
return;
}
@@ -222,6 +266,7 @@ export class TimerService {
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStart);
this._saveState();
}
pause() {
@@ -237,6 +282,7 @@ export class TimerService {
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onPause);
this._saveState();
}
stop() {
@@ -254,13 +300,14 @@ export class TimerService {
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStop);
this._saveState();
}
/**
* Delays running timer by given amount
* Adds time to running timer by given amount
* @param {number} amount
*/
delay(amount: number) {
addTime(amount: number) {
if (!this.loadedTimerId) {
return;
}
@@ -280,6 +327,7 @@ export class TimerService {
// force an update
this.update(true);
this._saveState();
}
private updateRoll() {
@@ -394,6 +442,7 @@ export class TimerService {
PlaybackService.startNext();
}
}
this._saveState();
}
/**
@@ -435,6 +484,19 @@ export class TimerService {
_onRoll() {
eventStore.set('playback', this.playback);
this._saveState();
}
async _saveState() {
if (this.saveRestorePoint) {
await this.saveRestorePoint({
playback: this.playback,
selectedEventId: this.loadedTimerId,
startedAt: this.timer.startedAt,
addedTime: this.timer.addedTime,
pausedAt: this.pausedAt,
});
}
}
shutdown() {
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import { Playback } from 'ontime-types';
import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js';
describe('isRestorePoint()', () => {
it('validates a well defined object', () => {
let restorePoint = {
playback: 'play',
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: 3,
};
expect(isRestorePoint(restorePoint)).toBe(true);
restorePoint = {
playback: 'roll',
selectedEventId: '123',
startedAt: null,
addedTime: null,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => {
const restorePoint = {
playback: 'unknown',
selectedEventId: '123',
startedAt: null,
addedTime: null,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with missing playback value', () => {
const restorePoint = {
selectedEventId: '123',
startedAt: null,
addedTime: null,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with incorrect value', () => {
const restorePoint = {
playback: 'roll',
selectedEventId: '123',
startedAt: 'testing',
addedTime: null,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
});
});
describe('RestoreService()', () => {
describe('load()', () => {
it('loads working file with times', () => {
const expected = {
playback: Playback.Play,
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 5678,
pausedAt: 9087,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
const testLoad = restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('loads working file without times', () => {
const expected = {
playback: Playback.Stop,
selectedEventId: null,
startedAt: null,
addedTime: null,
pausedAt: null,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
const testLoad = restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('does not load wrong play state', () => {
const expected = {
playback: 'does-not-exist',
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
const testLoad = restoreService.load();
expect(testLoad).toBe(null);
});
});
describe('save()', () => {
it('saves data to file', async () => {
const testData: RestorePoint = {
playback: Playback.Play,
selectedEventId: '1234',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
await restoreService.save(testData);
expect(writeSpy).toHaveBeenCalledWith(JSON.stringify(testData));
});
});
});
@@ -116,8 +116,8 @@ export function updateTimer(affectedIds?: string[]) {
if (safeOption) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
}
@@ -133,9 +133,9 @@ export function updateTimer(affectedIds?: string[]) {
eventTimer.roll(currentEvent, nextEvent);
}
} else {
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
if (loadedEvent) {
eventTimer.hotReload(loadedEvent);
const { eventNow } = eventLoader.loadById(runningEventId) || {};
if (eventNow) {
eventTimer.hotReload(eventNow);
} else {
eventTimer.stop();
}
@@ -144,8 +144,8 @@ export function updateTimer(affectedIds?: string[]) {
}
if (isNext) {
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
}
return false;
+3
View File
@@ -85,3 +85,6 @@ export const resolveStylesDirectory = join(externalsStartDirectory, config.style
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
// path to restore file
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
+8 -4
View File
@@ -56,8 +56,10 @@ export const eventStore = {
* - Message Service lowerMessage
* - Message Service onAir
* - Event Loader loaded
* - Event Loader titles
* - Event Loader titlesPublic
* - Event Loader eventNow
* - Event Loader publicEventNow
* - Event Loader eventNext
* - Event Loader publicEventNext
*/
export const getInitialPayload = () => ({
@@ -68,6 +70,8 @@ export const getInitialPayload = () => ({
lowerMessage: messageService.lowerMessage,
onAir: messageService.onAir,
loaded: eventLoader.loaded,
titles: eventLoader.titles,
titlesPublic: eventLoader.titlesPublic,
eventNow: eventLoader.eventNow,
publicEventNow: eventLoader.publicEventNow,
eventNext: eventLoader.eventNext,
publicEventNext: eventLoader.publicEventNext,
});
+13 -11
View File
@@ -1,6 +1,6 @@
import { vi } from 'vitest';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { EndAction, TimerType } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js';
@@ -192,7 +192,7 @@ describe('test json parser with valid def', () => {
user9: '',
},
],
eventData: {
project: {
title: 'This is a test definition',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
@@ -246,7 +246,7 @@ describe('test json parser with valid def', () => {
});
it('loaded event settings', () => {
const eventTitle = parseResponse?.eventData?.title;
const eventTitle = parseResponse?.project?.title;
expect(eventTitle).toBe('This is a test definition');
});
@@ -420,10 +420,10 @@ describe('test corrupt data', () => {
expect(parsedDef.rundown.length).toBe(0);
});
it('handles missing event data', async () => {
const emptyEventData = {
it('handles missing project data', async () => {
const emptyProjectData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
eventData: {},
project: {},
settings: {
app: 'ontime',
version: 2,
@@ -433,8 +433,8 @@ describe('test corrupt data', () => {
},
};
const parsedDef = await parseJson(emptyEventData);
expect(parsedDef.eventData).toStrictEqual(dbModel.eventData);
const parsedDef = await parseJson(emptyProjectData);
expect(parsedDef.project).toStrictEqual(dbModel.project);
});
it('handles missing settings', async () => {
@@ -568,7 +568,8 @@ describe('test parseExcel function', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
['Event Name', 'Test Event'],
['Project Name', 'Test Event'],
['Project Description', 'test description'],
['Public URL', 'www.public.com'],
['Backstage URL', 'www.backstage.com'],
['Public Info', 'test public info'],
@@ -650,8 +651,9 @@ describe('test parseExcel function', () => {
[],
];
const expectedParsedEvent = {
const expectedParsedProjectData = {
title: 'Test Event',
description: 'test description',
publicUrl: 'www.public.com',
backstageUrl: 'www.backstage.com',
publicInfo: 'test public info',
@@ -705,7 +707,7 @@ describe('test parseExcel function', () => {
];
const parsedData = await parseExcel(testdata);
expect(parsedData.eventData).toStrictEqual(expectedParsedEvent);
expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
+42
View File
@@ -0,0 +1,42 @@
/**
* @description Converts a value to a number if possible, throws otherwise
* @param {unknown} value - Value to be converted to a string.
* @returns {string} - The converted value as a string.
* @throws {Error} Throws an error if the value is null or undefined.
*/
export function coerceString(value: unknown): string {
if (value == null) {
throw new Error('Invalid value received');
}
return String(value);
}
/**
* @description Converts a value to a number if possible, throws otherwise
* @param {unknown} value - Value to be converted to a boolean.
* @returns {boolean} - The converted value as a boolean.
* @throws {Error} Throws an error if the value is null or undefined.
*/
export function coerceBoolean(value: unknown): boolean {
if (value == null) {
throw new Error('Invalid value received');
}
return Boolean(value);
}
/**
* @description Converts a value to a number if possible, throws otherwise
* @param {unknown} value - Value to be converted to a number.
* @returns {number} - The converted value as a number.
* @throws {Error} Throws an error if the value is null, undefined or not a valid number.
*/
export function coerceNumber(value: unknown): number {
if (value == null) {
throw new Error('Invalid value received');
}
const parsedValue = Number(value);
if (isNaN(parsedValue)) {
throw new Error('Invalid value received');
}
return parsedValue;
}
+24 -16
View File
@@ -7,7 +7,6 @@ import { generateId, calculateDuration } from 'ontime-utils';
import {
DatabaseModel,
EndAction,
EventData,
OntimeEvent,
OntimeRundown,
SupportedEvent,
@@ -19,7 +18,7 @@ import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js';
import {
parseAliases,
parseEventData,
parseProject,
parseOsc,
parseRundown,
parseSettings,
@@ -37,8 +36,9 @@ export const JSON_MIME = 'application/json';
* @returns {object} - parsed object
*/
export const parseExcel = async (excelData) => {
const eventData: Partial<EventData> = {
const projectData: Partial<ProjectData> = {
title: '',
description: '',
publicUrl: '',
backstageUrl: '',
};
@@ -70,7 +70,9 @@ export const parseExcel = async (excelData) => {
excelData
.filter((e) => e.length > 0)
.forEach((row) => {
let eventTitleNext = false;
// project data imports are on the column to the right
let projectTitleNext = false;
let projectDescriptionNext = false;
let publicUrlNext = false;
let publicInfoNext = false;
let backstageUrlNext = false;
@@ -80,20 +82,23 @@ export const parseExcel = async (excelData) => {
row.forEach((column, j) => {
// check flags
if (eventTitleNext) {
eventData.title = column;
eventTitleNext = false;
if (projectTitleNext) {
projectData.title = column;
projectTitleNext = false;
} else if (projectDescriptionNext) {
projectData.description = column;
projectDescriptionNext = false;
} else if (publicUrlNext) {
eventData.publicUrl = column;
projectData.publicUrl = column;
publicUrlNext = false;
} else if (publicInfoNext) {
eventData.publicInfo = column;
projectData.publicInfo = column;
publicInfoNext = false;
} else if (backstageUrlNext) {
eventData.backstageUrl = column;
projectData.backstageUrl = column;
backstageUrlNext = false;
} else if (backstageInfoNext) {
eventData.backstageInfo = column;
projectData.backstageInfo = column;
backstageInfoNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
@@ -154,8 +159,11 @@ export const parseExcel = async (excelData) => {
// look for keywords
// need to make sure it is a string first
switch (col) {
case 'event name':
eventTitleNext = true;
case 'project name':
projectTitleNext = true;
break;
case 'project description':
projectDescriptionNext = true;
break;
case 'public url':
publicUrlNext = true;
@@ -273,7 +281,7 @@ export const parseExcel = async (excelData) => {
});
return {
rundown,
eventData,
project: projectData,
settings: {
app: 'ontime',
version: 2,
@@ -299,7 +307,7 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
// parse Events
returnData.rundown = parseRundown(jsonData);
// parse Event
returnData.eventData = parseEventData(jsonData, enforce);
returnData.project = parseProject(jsonData, enforce);
// Settings handled partially
returnData.settings = parseSettings(jsonData, enforce);
// View settings handled partially
@@ -395,7 +403,7 @@ export const fileHandler = async (file): Promise<ResponseOK | ResponseError> =>
const dataFromExcel = await parseExcel(excelData.data);
res.data = {};
res.data.rundown = parseRundown(dataFromExcel);
res.data.eventData = parseEventData(dataFromExcel, true);
res.data.project = parseProject(dataFromExcel, true);
res.data.userFields = parseUserFields(dataFromExcel);
res.message = 'success';
} else {
+20 -17
View File
@@ -2,11 +2,11 @@ import { generateId } from 'ontime-utils';
import {
Alias,
EndAction,
EventData,
OntimeRundown,
OSCSettings,
OscSubscription,
OscSubscriptionOptions,
ProjectData,
Settings,
TimerLifeCycle,
TimerType,
@@ -91,26 +91,29 @@ export const parseRundown = (data): OntimeRundown => {
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseEventData = (data, enforce): EventData => {
let newEventData: Partial<EventData> = {};
if ('eventData' in data) {
console.log('Found event data, importing...');
const e = data.eventData;
export const parseProject = (data, enforce): ProjectData => {
let newProjectData: Partial<ProjectData> = {};
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
// TODO: Remove eventually
if ('project' in data || 'eventData' in data) {
console.log('Found project data, importing...');
const project = data.project ?? data.eventData;
// filter known properties and write to db
newEventData = {
...dbModel.eventData,
title: e.title || dbModel.eventData.title,
description: e.description || dbModel.eventData.description,
publicUrl: e.publicUrl || dbModel.eventData.publicUrl,
publicInfo: e.publicInfo || dbModel.eventData.publicInfo,
backstageUrl: e.backstageUrl || dbModel.eventData.backstageUrl,
backstageInfo: e.backstageInfo || dbModel.eventData.backstageInfo,
newProjectData = {
...dbModel.project,
title: project.title || dbModel.project.title,
description: project.description || dbModel.project.description,
publicUrl: project.publicUrl || dbModel.project.publicUrl,
publicInfo: project.publicInfo || dbModel.project.publicInfo,
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
};
} else if (enforce) {
newEventData = { ...dbModel.eventData };
console.log('Created event object in db');
newProjectData = { ...dbModel.project };
console.log('Created project object in db');
}
return newEventData as EventData;
return newProjectData as ProjectData;
};
/**
+1 -1
View File
@@ -227,7 +227,7 @@
"id": "1358"
}
],
"eventData": {
"project": {
"title": "All about Carlos demo event",
"description": "Demo event for Ontime",
"publicUrl": "www.getontime.no",
+62 -66
View File
@@ -1,72 +1,67 @@
{
"rundown": [
{
"title": "First test event",
"subtitle": "",
"presenter": "",
"note": "",
"endAction": "none",
"timerType": "count-down",
"timeStart": 32400000,
"timeEnd": 36000000,
"duration": 3600000,
"isPublic": true,
"skip": false,
"colour": "",
"user0": "",
"user1": "",
"user2": "",
"user3": "",
"user4": "",
"user5": "",
"user6": "",
"user7": "",
"user8": "",
"user9": "",
"type": "event",
"revision": 0,
"id": "aa42f"
},
{
"duration": 600000,
"type": "delay",
"revision": 0,
"id": "b1d5a"
},
{
"title": "Second test event",
"subtitle": "",
"presenter": "",
"note": "",
"endAction": "none",
"timerType": "count-down",
"timeStart": 36000000,
"timeEnd": 39600000,
"duration": 3600000,
"isPublic": true,
"skip": false,
"colour": "",
"user0": "",
"user1": "",
"user2": "",
"user3": "",
"user4": "",
"user5": "",
"user6": "",
"user7": "",
"user8": "",
"user9": "",
"type": "event",
"revision": 0,
"id": "d71bc"
},
{
"title": "Lunch",
"type": "block",
"id": "91682"
"id": "18797"
},
{
"title": "Third test event",
"title": "title 1",
"subtitle": "",
"presenter": "",
"note": "",
"endAction": "none",
"timerType": "count-down",
"timeStart": 60000,
"timeEnd": 120000,
"duration": 60000,
"isPublic": false,
"skip": false,
"colour": "",
"user0": "",
"user1": "",
"user2": "",
"user3": "",
"user4": "",
"user5": "",
"user6": "",
"user7": "",
"user8": "",
"user9": "",
"type": "event",
"revision": 0,
"id": "aa42f",
"cue": "1"
},
{
"title": "title 2",
"subtitle": "",
"presenter": "",
"note": "",
"endAction": "none",
"timerType": "count-down",
"timeStart": 120000,
"timeEnd": 180000,
"duration": 60000,
"isPublic": false,
"skip": false,
"colour": "",
"user0": "",
"user1": "",
"user2": "",
"user3": "",
"user4": "",
"user5": "",
"user6": "",
"user7": "",
"user8": "",
"user9": "",
"type": "event",
"revision": 0,
"id": "d71bc",
"cue": "2"
},
{
"title": "title 3",
"subtitle": "",
"presenter": "",
"note": "",
@@ -74,7 +69,7 @@
"timerType": "count-down",
"timeStart": 39600000,
"timeEnd": 720000,
"duration": 0,
"duration": 47520000,
"isPublic": true,
"skip": false,
"colour": "",
@@ -90,10 +85,11 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "da5b4"
"id": "da5b4",
"cue": "3"
}
],
"eventData": {
"project": {
"title": "All about Carlos demo event",
"description": "Demo event for Ontime",
"publicUrl": "www.getontime.no",
+1 -1
View File
@@ -403,7 +403,7 @@
"cue": "SF1.14"
}
],
"eventData": {
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
+2 -1
View File
@@ -19,7 +19,8 @@ test('cuesheet displays events and exports csv', async ({ page }) => {
await page.getByRole('cell', { name: '+10 min' }).click();
await page.getByRole('cell', { name: 'Lunch' }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByTestId('cuesheet').getByText('CSV').click();
await page.getByTestId('cuesheet').getByText('Export').click();
await page.getByText('CSV').click();
// From here we test the CSV download feature
+1 -1
View File
@@ -93,7 +93,7 @@
"id": "da5b4"
}
],
"eventData": {
"project": {
"title": "All about Carlos demo event",
"description": "Demo event for Ontime",
"publicUrl": "www.getontime.no",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.8.2",
"version": "2.13.1",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
@@ -1,5 +1,5 @@
import { Alias } from './core/Alias.type.js';
import { EventData } from './core/EventData.type.js';
import { ProjectData } from './core/ProjectData.type.js';
import { OntimeRundown } from './core/Rundown.type.js';
import { OSCSettings } from './core/OscSettings.type.js';
import { Settings } from './core/Settings.type.js';
@@ -8,7 +8,7 @@ import { ViewSettings } from './core/Views.type.js';
export type DatabaseModel = {
rundown: OntimeRundown;
eventData: EventData;
project: ProjectData;
settings: Settings;
viewSettings: ViewSettings;
aliases: Alias[];

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