mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 10:23:54 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 943dd19c09 | |||
| 138be478ca | |||
| 98bd320cbd | |||
| f57faf1837 | |||
| 44f025b359 | |||
| 0555d0f400 | |||
| 1e90ce4c1d | |||
| 2f09d27813 | |||
| a01046b479 | |||
| 78d5d442cf | |||
| 7c234466f7 | |||
| 29e5c96d44 | |||
| 36b1effaaf | |||
| a84c0654e2 | |||
| b34fe71995 | |||
| 2f74293505 | |||
| e2aabe3646 | |||
| 7545f6f7d0 | |||
| 958c68b6f6 | |||
| a0c5375376 | |||
| 9708f0bfc6 | |||
| 13eca98133 | |||
| 126e31403e | |||
| 53beea2768 | |||
| 0bb09dd039 | |||
| 93fb48ea1c | |||
| 1de3e01216 |
@@ -21,6 +21,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-console": "warn"
|
"no-console": ["warn", { "allow": ["warn", "error"]}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
build
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
**/*.toml
|
||||||
|
**/*.yml
|
||||||
|
**/*.json
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
|
|||||||
webserver to test against
|
webserver to test against
|
||||||
These tests also run against a separate version of the DB (test-db)
|
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`
|
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
|
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ Ontime is an application for creating and managing event running order and timer
|
|||||||
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 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ IP.ADDRESS:4001/public > Public / Foyer view
|
|||||||
IP.ADDRESS:4001/lower > Lower Thirds
|
IP.ADDRESS:4001/lower > Lower Thirds
|
||||||
IP.ADDRESS:4001/studio > Studio Clock
|
IP.ADDRESS:4001/studio > Studio Clock
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
For management views
|
For management views
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
@@ -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] Distribute data over network and render it in the browser
|
||||||
- [x] Different screen types
|
- [x] Different screen types
|
||||||
- Stage Timer
|
- Stage Timer
|
||||||
- Minimal Timer
|
- Minimal Timer
|
||||||
- Clock
|
- Clock
|
||||||
- Backstage Info
|
- Backstage Info
|
||||||
- Public Info
|
- Public Info
|
||||||
- Studio Clock
|
- Studio Clock
|
||||||
- Countdown
|
- Countdown
|
||||||
- [Make your own?](#make-your-own-viewer)
|
- [Make your own?](#make-your-own-viewer)
|
||||||
- [x] Configurable Lower Thirds
|
- [x] Configurable Lower Thirds
|
||||||
- [x] Collaborative editing with the cuesheet view
|
- [x] Collaborative editing with the cuesheet view
|
||||||
- [x] Send live messages to different screen types
|
- [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] 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] [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
|
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
|
||||||
a countdown to any scheduled event
|
a countdown to any scheduled event
|
||||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||||
|
|
||||||
## Unopinionated
|
## 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
|
## 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.
|
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 />
|
<br />
|
||||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
|
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
|
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️
|
### 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)
|
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||||
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
|
|
||||||
- the image name __getontime/ontime__
|
|
||||||
|
|
||||||
The docker image is
|
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)
|
||||||
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)
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -148,7 +138,8 @@ 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.
|
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||||
<br />
|
<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
|
### Issues
|
||||||
|
|
||||||
@@ -159,21 +150,20 @@ Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
|||||||
|
|
||||||
When installing the app you would see warning screens from the Operating System like:
|
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
|
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
|
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.
|
You can circumvent this by allowing the execution of the app manually.
|
||||||
|
|
||||||
- In Windows: click more and select "Run Anyway"
|
- In Windows: click more and select "Run Anyway"
|
||||||
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
|
- 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)
|
||||||
Privacy and allow the execution of the app
|
|
||||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||||
File as a Program
|
File as a Program
|
||||||
|
|
||||||
@@ -185,6 +175,7 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
|||||||
#### Safari
|
#### Safari
|
||||||
|
|
||||||
There are known issues with Safari versions lower than 13:
|
There are known issues with Safari versions lower than 13:
|
||||||
|
|
||||||
- Spacing and text styles might have small inconsistencies
|
- Spacing and text styles might have small inconsistencies
|
||||||
- Table view does not work
|
- 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.
|
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||||
<br /><br />
|
<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 />
|
<br />
|
||||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "2.7.2",
|
"version": "2.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import withData from './features/viewers/ViewWrapper';
|
|||||||
|
|
||||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||||
|
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||||
|
|
||||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
@@ -58,6 +59,9 @@ export default function AppRouter() {
|
|||||||
{/*/!* Lower cannot have fallback *!/*/}
|
{/*/!* Lower cannot have fallback *!/*/}
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
|
|
||||||
|
<Route path='/op' element={<Operator />} />
|
||||||
|
<Route path='/operator' element={<Operator />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// REST stuff
|
// REST stuff
|
||||||
export const EVENT_DATA = ['eventdata'];
|
export const PROJECT_DATA = ['project'];
|
||||||
export const ALIASES = ['aliases'];
|
export const ALIASES = ['aliases'];
|
||||||
export const USERFIELDS = ['userFields'];
|
export const USERFIELDS = ['userFields'];
|
||||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
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 serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
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 rundownURL = `${serverURL}/events`;
|
||||||
export const ontimeURL = `${serverURL}/ontime`;
|
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);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import axios from 'axios';
|
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 { apiRepoLatest } from '../../externals';
|
||||||
import { InfoType } from '../models/Info';
|
import { InfoType } from '../models/Info';
|
||||||
|
import fileDownload from '../utils/fileDownload';
|
||||||
|
|
||||||
import { ontimeURL } from './apiConstants';
|
import { ontimeURL } from './apiConstants';
|
||||||
|
|
||||||
@@ -109,32 +110,17 @@ export async function postOscSubscriptions(data: OscSubscription) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to download db
|
* @description HTTP request to download db in CSV format
|
||||||
* @return {Promise}
|
|
||||||
*/
|
*/
|
||||||
export const downloadRundown = async () => {
|
export const downloadCSV = () => {
|
||||||
await axios({
|
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||||
url: `${ontimeURL}/db`,
|
};
|
||||||
method: 'GET',
|
|
||||||
responseType: 'blob', // important
|
|
||||||
}).then((response) => {
|
|
||||||
const headerLine = response.headers['Content-Disposition'];
|
|
||||||
let filename = 'rundown.json';
|
|
||||||
|
|
||||||
// try and get the filename from the response
|
/**
|
||||||
if (headerLine != null) {
|
* @description HTTP request to download db in JSON format
|
||||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
*/
|
||||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
export const downloadRundown = () => {
|
||||||
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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);
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@use '../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.delaySymbol {
|
||||||
|
svg {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $ontime-delay;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||||
|
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||||
|
|
||||||
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
|
import { millisToDelayString } from '../../utils/dateConfig';
|
||||||
|
|
||||||
|
import style from './DelayIndicator.module.scss';
|
||||||
|
|
||||||
|
interface DelayIndicatorProps {
|
||||||
|
delayValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||||
|
const { delayValue } = props;
|
||||||
|
|
||||||
|
if (typeof delayValue === 'number') {
|
||||||
|
if (delayValue < 0) {
|
||||||
|
return (
|
||||||
|
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||||
|
<span className={style.delaySymbol}>
|
||||||
|
<IoChevronDown />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayValue > 0) {
|
||||||
|
return (
|
||||||
|
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||||
|
<span className={style.delaySymbol}>
|
||||||
|
<IoChevronUp />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ function NavigationMenu() {
|
|||||||
const handleMirror = () => toggleMirror();
|
const handleMirror = () => toggleMirror();
|
||||||
|
|
||||||
const showEditFormDrawer = () => {
|
const showEditFormDrawer = () => {
|
||||||
searchParams.append('edit', 'true');
|
searchParams.set('edit', 'true');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +116,10 @@ function NavigationMenu() {
|
|||||||
Cuesheet
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to='/op' className={style.link} tabIndex={0}>
|
||||||
|
Operator
|
||||||
|
<IoArrowUp className={style.linkIcon} />
|
||||||
|
</Link>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+8
-2
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
|
|||||||
|
|
||||||
interface PlaybackIconProps {
|
interface PlaybackIconProps {
|
||||||
state: Playback;
|
state: Playback;
|
||||||
|
skipTooltip?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||||
const { state } = props;
|
const { state, skipTooltip, className } = props;
|
||||||
|
|
||||||
// if timer is Pause or Armed
|
// if timer is Pause or Armed
|
||||||
let label = 'Timer Paused';
|
let label = 'Timer Paused';
|
||||||
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
|
|||||||
Icon = IoStop;
|
Icon = IoStop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipTooltip) {
|
||||||
|
return <Icon className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||||
<Icon />
|
<Icon className={className} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import Empty from '../state/Empty';
|
|
||||||
|
|
||||||
import { useSchedule } from './ScheduleContext';
|
import { useSchedule } from './ScheduleContext';
|
||||||
import ScheduleItem from './ScheduleItem';
|
import ScheduleItem from './ScheduleItem';
|
||||||
|
|
||||||
@@ -13,8 +11,9 @@ interface ScheduleProps {
|
|||||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||||
|
|
||||||
|
// TODO: design a nice placeholder for empty schedules
|
||||||
if (paginatedEvents?.length < 1) {
|
if (paginatedEvents?.length < 1) {
|
||||||
return <Empty text='No events to show' />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useInterval } from '../../hooks/useInterval';
|
import { useInterval } from '../../hooks/useInterval';
|
||||||
|
import { isStringBoolean } from '../../utils/viewUtils';
|
||||||
|
|
||||||
interface ScheduleContextState {
|
interface ScheduleContextState {
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
@@ -32,12 +34,25 @@ export const ScheduleProvider = ({
|
|||||||
time = 10,
|
time = 10,
|
||||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||||
const [visiblePage, setVisiblePage] = useState(0);
|
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 eventStart = eventsPerPage * visiblePage;
|
||||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
|
||||||
|
|
||||||
const resolveScheduleType = () => {
|
const resolveScheduleType = () => {
|
||||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||||
@@ -52,7 +67,9 @@ export const ScheduleProvider = ({
|
|||||||
|
|
||||||
// every SCROLL_TIME go to the next array
|
// every SCROLL_TIME go to the next array
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
if (events.length > eventsPerPage) {
|
if (stopCycle) {
|
||||||
|
setVisiblePage(0);
|
||||||
|
} else if (events.length > eventsPerPage) {
|
||||||
const next = (visiblePage + 1) % numPages;
|
const next = (visiblePage + 1) % numPages;
|
||||||
setVisiblePage(next);
|
setVisiblePage(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
import style from './Empty.module.scss';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@use '../../../theme/ontimeColours' as *;
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
.drawerContent {
|
.drawerContent {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerHeader {
|
.drawerHeader {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UserFields } from 'ontime-types';
|
||||||
|
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
export const TIME_FORMAT_OPTION: ParamField = {
|
export const TIME_FORMAT_OPTION: ParamField = {
|
||||||
@@ -130,7 +132,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
{
|
{
|
||||||
id: 'hideovertime',
|
id: 'hideovertime',
|
||||||
title: 'Hide Overtime',
|
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',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -192,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[] = [
|
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||||
TIME_FORMAT_OPTION,
|
TIME_FORMAT_OPTION,
|
||||||
{
|
{
|
||||||
@@ -201,3 +234,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
||||||
|
return [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'showseconds',
|
||||||
|
title: 'Show seconds',
|
||||||
|
description: 'Schedule shows hh:mm:ss',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidepast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
title: 'Main data field',
|
||||||
|
description: 'Field to be shown in the first line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
title: 'Title',
|
||||||
|
subtitle: 'Subtitle',
|
||||||
|
presenter: 'Presenter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary',
|
||||||
|
title: 'Secondary data field',
|
||||||
|
description: 'Field to be shown in the second line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
title: 'Title',
|
||||||
|
subtitle: 'Subtitle',
|
||||||
|
presenter: 'Presenter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subscribe',
|
||||||
|
title: 'Highlight Field',
|
||||||
|
description: 'Choose a field to highlight',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
user0: userFields.user0 || 'user0',
|
||||||
|
user1: userFields.user1 || 'user1',
|
||||||
|
user2: userFields.user2 || 'user2',
|
||||||
|
user3: userFields.user3 || 'user3',
|
||||||
|
user4: userFields.user4 || 'user4',
|
||||||
|
user5: userFields.user5 || 'user5',
|
||||||
|
user6: userFields.user6 || 'user6',
|
||||||
|
user7: userFields.user7 || 'user7',
|
||||||
|
user8: userFields.user8 || 'user8',
|
||||||
|
user9: userFields.user9 || 'user9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function getMatches(query: string): boolean {
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: debounce handleChange
|
||||||
|
export default function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState<boolean>(getMatches(query));
|
||||||
|
|
||||||
|
const handleChange = useCallback(() => {
|
||||||
|
setMatches(getMatches(query));
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const matchMedia = window.matchMedia(query);
|
||||||
|
|
||||||
|
// Triggered at the first client-side load and if query changes
|
||||||
|
handleChange();
|
||||||
|
|
||||||
|
// Listen matchMedia
|
||||||
|
matchMedia.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
matchMedia.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [handleChange, query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
+7
-7
@@ -1,15 +1,15 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { EVENT_DATA } from '../api/apiConstants';
|
import { PROJECT_DATA } from '../api/apiConstants';
|
||||||
import { fetchEventData } from '../api/eventDataApi';
|
import { getProjectData } from '../api/projectDataApi';
|
||||||
import { eventDataPlaceholder } from '../models/EventData';
|
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||||
|
|
||||||
export default function useEventData() {
|
export default function useProjectData() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: EVENT_DATA,
|
queryKey: PROJECT_DATA,
|
||||||
queryFn: fetchEventData,
|
queryFn: getProjectData,
|
||||||
placeholderData: eventDataPlaceholder,
|
placeholderData: projectDataPlaceholder,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MutableRefObject, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
|
componentRef: MutableRefObject<ComponentRef>,
|
||||||
|
scrollRef: MutableRefObject<ScrollRef>,
|
||||||
|
topOffset: number,
|
||||||
|
) {
|
||||||
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentRect = componentRef.current.getBoundingClientRect();
|
||||||
|
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
||||||
|
|
||||||
|
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFollowComponentProps {
|
||||||
|
followRef: MutableRefObject<HTMLElement | null>;
|
||||||
|
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||||
|
doFollow: boolean;
|
||||||
|
topOffset?: number;
|
||||||
|
setScrollFlag?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||||
|
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
||||||
|
|
||||||
|
// when cursor moves, view should follow
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doFollow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followRef.current && scrollRef.current) {
|
||||||
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setScrollFlag?.();
|
||||||
|
scrollToComponent(
|
||||||
|
followRef as MutableRefObject<HTMLElement>,
|
||||||
|
scrollRef as MutableRefObject<HTMLElement>,
|
||||||
|
topOffset,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line -- the prompt seems incorrect
|
||||||
|
}, [followRef?.current, scrollRef?.current]);
|
||||||
|
|
||||||
|
const scrollToRefComponent = useCallback(
|
||||||
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
|
if (componentRef.current && containerRef.current) {
|
||||||
|
// @ts-expect-error -- we know this are not null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
scrollToComponent(componentRef!, scrollRef!, offset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[followRef, scrollRef, topOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
return scrollToRefComponent;
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function useFullscreen() {
|
|||||||
});
|
});
|
||||||
} else if (element.webkitRequestFullscreen) {
|
} else if (element.webkitRequestFullscreen) {
|
||||||
// iOS Safari fullscreen API is supported
|
// iOS Safari fullscreen API is supported
|
||||||
element.webkitRequestFullscreen().catch(() => {
|
element.webkitRequestFullscreen?.().catch(() => {
|
||||||
/* nothing to do */
|
/* nothing to do */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function useFullscreen() {
|
|||||||
});
|
});
|
||||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||||
// iOS Safari fullscreen API is supported
|
// iOS Safari fullscreen API is supported
|
||||||
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
|
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||||
/* nothing to do */
|
/* nothing to do */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export const useRundownEditor = () => {
|
|||||||
return useRuntimeStore(featureSelector, deepCompare);
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useOperator = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
playback: state.playback,
|
||||||
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
|
};
|
||||||
|
|
||||||
export const useMessageControl = () => {
|
export const useMessageControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
timerMessage: state.timerMessage,
|
timerMessage: state.timerMessage,
|
||||||
@@ -70,7 +79,8 @@ export const setPlayback = {
|
|||||||
|
|
||||||
export const useInfoPanel = () => {
|
export const useInfoPanel = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
titles: state.titles,
|
eventNow: state.eventNow,
|
||||||
|
eventNext: state.eventNext,
|
||||||
playback: state.playback,
|
playback: state.playback,
|
||||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||||
numEvents: state.loaded.numEvents,
|
numEvents: state.loaded.numEvents,
|
||||||
@@ -85,7 +95,7 @@ export const useCuesheet = () => {
|
|||||||
selectedEventId: state.loaded.selectedEventId,
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||||
numEvents: state.loaded.numEvents,
|
numEvents: state.loaded.numEvents,
|
||||||
titleNow: state.titles.titleNow,
|
titleNow: state.eventNow?.title || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector, deepCompare);
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { EventData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
export const eventDataPlaceholder: EventData = {
|
export const projectDataPlaceholder: ProjectData = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
publicUrl: '',
|
publicUrl: '',
|
||||||
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
|
|||||||
nextEventId: null,
|
nextEventId: null,
|
||||||
nextPublicEventId: null,
|
nextPublicEventId: null,
|
||||||
},
|
},
|
||||||
titles: {
|
eventNow: null,
|
||||||
titleNow: null,
|
eventNext: null,
|
||||||
subtitleNow: null,
|
publicEventNow: null,
|
||||||
presenterNow: null,
|
publicEventNext: 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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runtime = createStore<RuntimeStore>(() => ({
|
export const runtime = createStore<RuntimeStore>(() => ({
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const getAliasRoute = (location: Location, data: Alias[], searchParams: U
|
|||||||
const aliasOnPage = searchParams.get('alias');
|
const aliasOnPage = searchParams.get('alias');
|
||||||
for (const d of data) {
|
for (const d of data) {
|
||||||
if (aliasOnPage) {
|
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 we have the same alias and its enabled and its not empty
|
||||||
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
||||||
const newAliasPath = resolvePath(d.pathAndParams);
|
const newAliasPath = resolvePath(d.pathAndParams);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function debounce(callback: () => void, wait: number) {
|
||||||
|
let timeout: NodeJS.Timeout | null;
|
||||||
|
return () => {
|
||||||
|
if (timeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
callback();
|
||||||
|
}, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isMacOS() {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
runtime.setState(state);
|
runtime.setState(state);
|
||||||
break;
|
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': {
|
case 'ontime-timerMessage': {
|
||||||
const state = runtime.getState();
|
const state = runtime.getState();
|
||||||
state.timerMessage = payload;
|
state.timerMessage = payload;
|
||||||
|
|||||||
@@ -3,27 +3,27 @@ import Color from 'color';
|
|||||||
type ColourCombination = {
|
type ColourCombination = {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Selects text colour to maintain accessible contrast
|
* @description Selects text colour to maintain accessible contrast
|
||||||
* @param bgColour
|
* @param bgColour
|
||||||
* @return {{backgroundColor, color: string}}
|
* @return {{backgroundColor, color: string}}
|
||||||
*/
|
*/
|
||||||
export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
||||||
if (bgColour) {
|
if (bgColour) {
|
||||||
try {
|
try {
|
||||||
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
||||||
return { backgroundColor: bgColour, color: textColor };
|
return { backgroundColor: bgColour, color: textColor };
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.log(`Unable to parse colour: ${bgColour}`);
|
/* we do not handle errors here */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { backgroundColor: '#000', color: "#fffffa" };
|
return { backgroundColor: '#000', color: '#fffffa' };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Creates a list of classnames from array of css module conditions
|
* @description Creates a list of classnames from array of css module conditions
|
||||||
* @param classNames - css modules objects
|
* @param classNames - css modules objects
|
||||||
*/
|
*/
|
||||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
|
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
|
|||||||
return '...';
|
return '...';
|
||||||
}
|
}
|
||||||
const timeFormat = resolver();
|
const timeFormat = resolver();
|
||||||
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
|
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
|
||||||
|
const { showSeconds = false, format: formatString = fallback } = options || {};
|
||||||
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function MessageControl() {
|
|||||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||||
/>
|
/>
|
||||||
<div className={style.buttonSection}>
|
<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>
|
<label className={style.label}>Blackout timer screens</label>
|
||||||
<Button
|
<Button
|
||||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
min-width: 2rem;
|
min-width: 2rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 400;
|
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 {
|
.eventRow {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
outline: 1px solid $blue-700;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -113,14 +123,6 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.delaySymbol {
|
|
||||||
svg {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: $ontime-delay;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.delayedTime {
|
.delayedTime {
|
||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
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 { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import Color from 'color';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
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 CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||||
import { SortableCell } from './tableElements/SortableCell';
|
|
||||||
import { initialColumnOrder } from './cuesheetCols';
|
import { initialColumnOrder } from './cuesheetCols';
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
const pastOpacity = '0.2';
|
|
||||||
|
|
||||||
interface CuesheetProps {
|
interface CuesheetProps {
|
||||||
data: OntimeRundown;
|
data: OntimeRundown;
|
||||||
columns: ColumnDef<OntimeRundownEntry>[];
|
columns: ColumnDef<OntimeRundownEntry>[];
|
||||||
@@ -46,6 +34,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -63,78 +53,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when selection moves, view should follow
|
|
||||||
useEffect(() => {
|
|
||||||
function scrollToComponent(
|
|
||||||
componentRef: MutableRefObject<HTMLTableRowElement>,
|
|
||||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
|
||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!followSelected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedRef.current && tableContainerRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
scrollToComponent(
|
|
||||||
selectedRef as MutableRefObject<HTMLTableRowElement>,
|
|
||||||
tableContainerRef as MutableRefObject<HTMLDivElement>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
|
|
||||||
}, [selectedRef.current, tableContainerRef.current, followSelected]);
|
|
||||||
|
|
||||||
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 = () => {
|
const resetColumnOrder = () => {
|
||||||
saveColumnOrder(initialColumnOrder);
|
saveColumnOrder(initialColumnOrder);
|
||||||
@@ -148,6 +66,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerGroups = table.getHeaderGroups;
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
let isPast = Boolean(selectedId);
|
let isPast = Boolean(selectedId);
|
||||||
|
|
||||||
@@ -163,37 +83,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
)}
|
)}
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
<table className={style.cuesheet}>
|
<table className={style.cuesheet}>
|
||||||
<thead className={style.tableHeader}>
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
{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>
|
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
const key = row.original.id;
|
const key = row.original.id;
|
||||||
@@ -203,13 +93,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeBlock(row.original)) {
|
if (isOntimeBlock(row.original)) {
|
||||||
const title = row.original.title;
|
return <BlockRow key={key} title={row.original.title} />;
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={key} className={style.blockRow}>
|
|
||||||
<td>{title}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (isOntimeDelay(row.original)) {
|
if (isOntimeDelay(row.original)) {
|
||||||
const delayVal = row.original.duration;
|
const delayVal = row.original.duration;
|
||||||
@@ -218,12 +102,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayTime = millisToDelayString(delayVal);
|
return <DelayRow key={key} duration={delayVal} />;
|
||||||
return (
|
|
||||||
<tr key={key} className={style.delayRow}>
|
|
||||||
<td>{delayTime}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (isOntimeEvent(row.original)) {
|
if (isOntimeEvent(row.original)) {
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
@@ -236,25 +115,28 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
return null;
|
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;
|
let rowBgColour: string | undefined;
|
||||||
if (row.original.id === selectedId) {
|
if (isSelected) {
|
||||||
rowBgColour = '#D20300'; // $red-700
|
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 (
|
return (
|
||||||
<tr
|
<EventRow
|
||||||
key={key}
|
key={key}
|
||||||
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
|
eventIndex={eventIndex}
|
||||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
isPast={isPast}
|
||||||
ref={isSelected ? selectedRef : undefined}
|
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) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
return (
|
return (
|
||||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||||
@@ -262,7 +144,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</EventRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { EventData, OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||||
|
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||||
|
|
||||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||||
import Cuesheet from './Cuesheet';
|
import Cuesheet from './Cuesheet';
|
||||||
@@ -20,6 +21,8 @@ export default function CuesheetWrapper() {
|
|||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
const featureData = useCuesheet();
|
const featureData = useCuesheet();
|
||||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [headerData, setheaderData] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,37 +72,76 @@ export default function CuesheetWrapper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportHandler = useCallback(
|
const exportHandler = useCallback(
|
||||||
(headerData: EventData) => {
|
(headerData: ProjectData, exportType: ExportType) => {
|
||||||
if (!headerData || !rundown || !userFields) {
|
if (!headerData || !rundown || !userFields) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetData = makeTable(headerData, rundown, userFields);
|
let fileName = '';
|
||||||
const csvContent = makeCSV(sheetData);
|
let url = '';
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
if (exportType === 'json') {
|
||||||
const url = URL.createObjectURL(blob);
|
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');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', 'ontime export.csv');
|
link.setAttribute('download', fileName);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// Clean up the URL.createObjectURL to release resources
|
// Clean up the URL.createObjectURL to release resources
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
[rundown, userFields],
|
[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) {
|
if (!rundown || !userFields) {
|
||||||
return <Empty text='Loading...' />;
|
return <Empty text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<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} />
|
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||||
|
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
|||||||
"Ontime · Schedule Template",
|
"Ontime · Schedule Template",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Event Name",
|
"Project Title",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Project Description",
|
||||||
"",
|
"",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('parseField()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty string on undefined fields', () => {
|
it('returns an empty string on undefined fields', () => {
|
||||||
expect(parseField('presenter', undefined)).toBe('');
|
expect(parseField('presenter')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('simply returns any other value in any other field', () => {
|
describe('simply returns any other value in any other field', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
+4
-4
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
|
|||||||
.actionIcon {
|
.actionIcon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $active-colour;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.enabled {
|
&.enabled {
|
||||||
color: $active-indicator;
|
color: $active-indicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $active-colour;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,20 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
|||||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
import { EventData, Playback } from 'ontime-types';
|
import { Playback, ProjectData } from 'ontime-types';
|
||||||
import { formatDisplay } from 'ontime-utils';
|
|
||||||
|
|
||||||
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||||
import { useTimer } from '../../../common/hooks/useSocket';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
|
||||||
import { formatTime } from '../../../common/utils/time';
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||||
import PlaybackIcon from '../tableElements/PlaybackIcon';
|
|
||||||
|
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||||
|
|
||||||
import style from './CuesheetTableHeader.module.scss';
|
import style from './CuesheetTableHeader.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableHeaderProps {
|
interface CuesheetTableHeaderProps {
|
||||||
handleCSVExport: (headerData: EventData) => void;
|
handleExport: (headerData: ProjectData) => void;
|
||||||
featureData: {
|
featureData: {
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
selectedEventIndex: number | null;
|
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 followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||||
const timer = useTimer();
|
|
||||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||||
const { data: event } = useEventData();
|
const { data: project } = useProjectData();
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportProject = () => {
|
||||||
if (event) {
|
if (project) {
|
||||||
handleCSVExport(event);
|
handleExport(project);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,32 +45,17 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
featureData.numEvents ? featureData.numEvents : '-'
|
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 (
|
return (
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.event}>
|
<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 className={style.eventNow}>{featureData?.titleNow || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.playback}>
|
<div className={style.playback}>
|
||||||
<div className={style.playbackLabel}>{selected}</div>
|
<div className={style.playbackLabel}>{selected}</div>
|
||||||
<PlaybackIcon state={featureData.playback} />
|
<PlaybackIcon state={featureData.playback} />
|
||||||
</div>
|
</div>
|
||||||
<div className={style.timer}>
|
<CuesheetTableHeaderTimers />
|
||||||
<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>
|
|
||||||
<div className={style.headerActions}>
|
<div className={style.headerActions}>
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
||||||
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
||||||
@@ -89,9 +72,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
|
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||||
<span className={style.actionIcon} onClick={exportCsv}>
|
<span className={style.actionIcon} onClick={exportProject}>
|
||||||
CSV
|
Export
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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 { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||||
import { Column } from '@tanstack/react-table';
|
import { Column } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
@@ -19,7 +20,7 @@ interface CuesheetTableSettingsProps {
|
|||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||||
@@ -81,3 +82,5 @@ export default function CuesheetTableSettings(props: CuesheetTableSettingsProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CuesheetTableSettings);
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
|
||||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
|
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||||
import EditableCell from './tableElements/EditableCell';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|||||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayIndicator(props: { delayValue: number }) {
|
|
||||||
const { delayValue } = props;
|
|
||||||
if (delayValue < 0) {
|
|
||||||
return (
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
|
||||||
<span className={style.delaySymbol}>
|
|
||||||
<IoChevronDown />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delayValue > 0) {
|
|
||||||
return (
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
|
||||||
<span className={style.delaySymbol}>
|
|
||||||
<IoChevronUp />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
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';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
|
|||||||
* @param {object} userFields
|
* @param {object} userFields
|
||||||
* @return {(string[])[]}
|
* @return {(string[])[]}
|
||||||
*/
|
*/
|
||||||
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
||||||
const data = [
|
const data = [
|
||||||
['Ontime · Schedule Template'],
|
['Ontime · Schedule Template'],
|
||||||
['Event Name', headerData?.title || ''],
|
['Project Title', headerData?.title || ''],
|
||||||
|
['Project Description', headerData?.description || ''],
|
||||||
['Public URL', headerData?.publicUrl || ''],
|
['Public URL', headerData?.publicUrl || ''],
|
||||||
['Backstage URL', headerData?.backstageUrl || ''],
|
['Backstage URL', headerData?.backstageUrl || ''],
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
|||||||
.eventEditor {
|
.eventEditor {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
box-shadow: $large-bottom-drawer-shadow;
|
||||||
border-top: 1px solid $white-20;
|
border-top: 1px solid $white-20;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-left: 1px solid $white-10;
|
border-left: 1px solid $white-10;
|
||||||
border-radius: 0 8px 0 0;
|
border-radius: 0 8px 0 0;
|
||||||
|
|||||||
@@ -12,17 +12,17 @@ export default function Info() {
|
|||||||
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
|
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
|
||||||
|
|
||||||
const titlesNow = {
|
const titlesNow = {
|
||||||
title: data.titles.titleNow || '',
|
title: data.eventNow?.title || '',
|
||||||
subtitle: data.titles.subtitleNow || '',
|
subtitle: data.eventNow?.subtitle || '',
|
||||||
presenter: data.titles.presenterNow || '',
|
presenter: data.eventNow?.presenter || '',
|
||||||
note: data.titles.noteNow || '',
|
note: data.eventNow?.note || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const titlesNext = {
|
const titlesNext = {
|
||||||
title: data.titles.titleNext || '',
|
title: data.eventNext?.title || '',
|
||||||
subtitle: data.titles.subtitleNext || '',
|
subtitle: data.eventNext?.subtitle || '',
|
||||||
presenter: data.titles.presenterNext || '',
|
presenter: data.eventNext?.presenter || '',
|
||||||
note: data.titles.noteNext || '',
|
note: data.eventNext?.note || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const selected = !data.numEvents
|
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';
|
import style from '../Info.module.scss';
|
||||||
|
|
||||||
export default function InfoHeader({ selected }: { selected: string }) {
|
export default function InfoHeader({ selected }: { selected: string }) {
|
||||||
const { data } = useEventData();
|
const { data } = useProjectData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { VStack } from '@chakra-ui/react';
|
import { VStack } from '@chakra-ui/react';
|
||||||
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
||||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
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 { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
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 QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
||||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||||
|
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||||
|
|
||||||
import style from './MenuBar.module.scss';
|
import style from './MenuBar.module.scss';
|
||||||
|
|
||||||
@@ -100,6 +101,22 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
};
|
};
|
||||||
}, [handleKeyPress, isElectron]);
|
}, [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 (
|
return (
|
||||||
<VStack>
|
<VStack>
|
||||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
|
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
|
||||||
@@ -128,12 +145,14 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
icon={<IoSaveOutline />}
|
icon={<IoSaveOutline />}
|
||||||
isDisabled={appMode === AppMode.Run}
|
isDisabled={appMode === AppMode.Run}
|
||||||
clickHandler={downloadRundown}
|
clickHandler={() => setIsModalOpen(true)}
|
||||||
tooltip='Export project file'
|
tooltip='Export project file'
|
||||||
aria-label='Export project file'
|
aria-label='Export project file'
|
||||||
size='sm'
|
size='sm'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
|
||||||
|
|
||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...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,
|
ModalOverlay,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@chakra-ui/react';
|
} 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 { postNew } from '../../../common/api/ontimeApi';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import { eventDataPlaceholder } from '../../../common/models/EventData';
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
import { ontimeQueryClient } from '../../../common/queryClient';
|
import { ontimeQueryClient } from '../../../common/queryClient';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../Modal.module.scss';
|
||||||
@@ -32,7 +32,7 @@ interface QuickStartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
||||||
const { data, status } = useEventData();
|
const { data, status } = useProjectData();
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
if (data) reset(data);
|
if (data) reset(data);
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data: Partial<EventData>) => {
|
const onSubmit = async (data: Partial<ProjectData>) => {
|
||||||
try {
|
try {
|
||||||
await postNew(data);
|
await postNew(data);
|
||||||
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
|
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
|
||||||
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||||
|
|
||||||
onClose();
|
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;
|
const disableButtons = status !== 'success' || isSubmitting;
|
||||||
return (
|
return (
|
||||||
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<AlertTitle>Note</AlertTitle>
|
<AlertTitle>Note</AlertTitle>
|
||||||
<AlertDescription>
|
<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>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Event title
|
Project title
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Event description
|
Project description
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Public Info
|
Public info
|
||||||
<Textarea
|
<Textarea
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Public QR Code Url
|
Public QR code Url
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Backstage Info
|
Backstage info
|
||||||
<Textarea
|
<Textarea
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Backstage QR Code Url
|
Backstage QR code Url
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
+15
-15
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Input, Textarea } from '@chakra-ui/react';
|
import { Input, Textarea } from '@chakra-ui/react';
|
||||||
import { EventData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { logAxiosError } from '../../../common/api/apiUtils';
|
import { logAxiosError } from '../../../common/api/apiUtils';
|
||||||
import { postEventData } from '../../../common/api/eventDataApi';
|
import { postProjectData } from '../../../common/api/projectDataApi';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
import ModalLoader from '../modal-loader/ModalLoader';
|
||||||
import { inputProps } from '../modalHelper';
|
import { inputProps } from '../modalHelper';
|
||||||
import ModalInput from '../ModalInput';
|
import ModalInput from '../ModalInput';
|
||||||
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
|
|||||||
|
|
||||||
import style from './SettingsModal.module.scss';
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
export default function EventDataForm() {
|
export default function ProjectDataForm() {
|
||||||
const { data, status, isFetching, refetch } = useEventData();
|
const { data, status, isFetching, refetch } = useProjectData();
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors, isSubmitting, isDirty, isValid },
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
} = useForm<EventData>({
|
} = useForm<ProjectData>({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -34,11 +34,11 @@ export default function EventDataForm() {
|
|||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: EventData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postEventData(formData);
|
await postProjectData(formData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error saving event settings', error);
|
logAxiosError('Error saving project data', error);
|
||||||
} finally {
|
} finally {
|
||||||
await refetch();
|
await refetch();
|
||||||
}
|
}
|
||||||
@@ -55,10 +55,10 @@ export default function EventDataForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
|
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
|
||||||
<ModalInput
|
<ModalInput
|
||||||
field='title'
|
field='title'
|
||||||
title='Event title'
|
title='Project title'
|
||||||
description='Shown in overview screens'
|
description='Shown in overview screens'
|
||||||
error={errors.title?.message}
|
error={errors.title?.message}
|
||||||
>
|
>
|
||||||
@@ -73,7 +73,7 @@ export default function EventDataForm() {
|
|||||||
</ModalInput>
|
</ModalInput>
|
||||||
<ModalInput
|
<ModalInput
|
||||||
field='description'
|
field='description'
|
||||||
title='Event description'
|
title='Project description'
|
||||||
description='Free field, shown in editor'
|
description='Free field, shown in editor'
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
>
|
>
|
||||||
@@ -87,7 +87,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<div style={{ height: '16px' }} />
|
<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
|
<Textarea
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
@@ -107,7 +107,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<div style={{ height: '16px' }} />
|
<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
|
<Textarea
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
@@ -128,7 +128,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<OntimeModalFooter
|
<OntimeModalFooter
|
||||||
formId='event-data'
|
formId='project-data'
|
||||||
handleRevert={onReset}
|
handleRevert={onReset}
|
||||||
isDirty={isDirty}
|
isDirty={isDirty}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
|
|||||||
import AppSettingsModal from './AppSettings';
|
import AppSettingsModal from './AppSettings';
|
||||||
import CuesheetSettingsForm from './CuesheetSettingsForm';
|
import CuesheetSettingsForm from './CuesheetSettingsForm';
|
||||||
import EditorSettings from './EditorSettings';
|
import EditorSettings from './EditorSettings';
|
||||||
import EventDataForm from './EventDataForm';
|
import ProjectDataForm from './ProjectDataForm';
|
||||||
import ViewSettingsForm from './ViewSettingsForm';
|
import ViewSettingsForm from './ViewSettingsForm';
|
||||||
|
|
||||||
interface ModalManagerProps {
|
interface ModalManagerProps {
|
||||||
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
|
|||||||
<Tabs variant='ontime' size='sm' isLazy>
|
<Tabs variant='ontime' size='sm' isLazy>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>App</Tab>
|
<Tab>App</Tab>
|
||||||
<Tab>Event Data</Tab>
|
<Tab>Project Data</Tab>
|
||||||
<Tab>Editor</Tab>
|
<Tab>Editor</Tab>
|
||||||
<Tab>Cuesheet</Tab>
|
<Tab>Cuesheet</Tab>
|
||||||
<Tab>Views</Tab>
|
<Tab>Views</Tab>
|
||||||
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
|
|||||||
<AppSettingsModal />
|
<AppSettingsModal />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<EventDataForm />
|
<ProjectDataForm />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<EditorSettings />
|
<EditorSettings />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@use '../../../src/theme/v2Styles' as *;
|
||||||
|
@use '../../../src/theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.operatorContainer {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operatorEvents {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
min-height: 95vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
||||||
|
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||||
|
|
||||||
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
|
import Empty from '../../common/components/state/Empty';
|
||||||
|
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
|
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';
|
||||||
|
|
||||||
|
import FollowButton from './follow-button/FollowButton';
|
||||||
|
import OperatorBlock from './operator-block/OperatorBlock';
|
||||||
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
|
import StatusBar from './status-bar/StatusBar';
|
||||||
|
|
||||||
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
|
const selectedOffset = 50;
|
||||||
|
|
||||||
|
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 } = useProjectData();
|
||||||
|
|
||||||
|
const featureData = useOperator();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const isAutomatedScroll = useRef(false);
|
||||||
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollToComponent = useFollowComponent({
|
||||||
|
followRef: selectedRef,
|
||||||
|
scrollRef: scrollRef,
|
||||||
|
doFollow: !lockAutoScroll,
|
||||||
|
topOffset: selectedOffset,
|
||||||
|
setScrollFlag: () => (isAutomatedScroll.current = true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set window title
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'ontime - Operator';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// reset scroll if nothing is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!featureData?.selectedEventId) {
|
||||||
|
if (!lockAutoScroll) {
|
||||||
|
scrollRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
|
||||||
|
|
||||||
|
const handleOffset = () => {
|
||||||
|
if (featureData.selectedEventId) {
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
setLockAutoScroll(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
// prevent considering automated scrolls as user scrolls
|
||||||
|
if (isAutomatedScroll.current) {
|
||||||
|
isAutomatedScroll.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRef?.current && scrollRef?.current) {
|
||||||
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
if (selectedRect && scrollerRect) {
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||||
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingData = !data || !userFields || !projectData;
|
||||||
|
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
|
||||||
|
|
||||||
|
if (missingData || isLoading) {
|
||||||
|
return <Empty text='Loading...' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fields which the user subscribed to
|
||||||
|
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
|
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||||
|
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
|
||||||
|
const subscribedAlias = subscribe ? userFields[subscribe] : '';
|
||||||
|
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||||
|
|
||||||
|
const operatorOptions = getOperatorOptions(userFields);
|
||||||
|
let isPast = Boolean(featureData.selectedEventId);
|
||||||
|
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
||||||
|
|
||||||
|
const firstEvent = getFirstEvent(data);
|
||||||
|
const lastEvent = getLastEvent(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.operatorContainer}>
|
||||||
|
<NavigationMenu />
|
||||||
|
<ViewParamsEditor paramFields={operatorOptions} />
|
||||||
|
|
||||||
|
<StatusBar
|
||||||
|
projectTitle={projectData.title}
|
||||||
|
playback={featureData.playback}
|
||||||
|
selectedEventId={featureData.selectedEventId}
|
||||||
|
firstStart={firstEvent?.timeStart}
|
||||||
|
firstId={firstEvent?.id}
|
||||||
|
lastEnd={lastEvent?.timeEnd}
|
||||||
|
lastId={lastEvent?.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
|
||||||
|
{data.map((entry) => {
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
const isSelected = featureData.selectedEventId === entry.id;
|
||||||
|
if (isSelected) {
|
||||||
|
isPast = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide past events (if setting) and skipped events
|
||||||
|
if ((hidePast && isPast) || entry.skip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||||
|
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
|
||||||
|
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OperatorEvent
|
||||||
|
key={entry.id}
|
||||||
|
colour={entry.colour}
|
||||||
|
cue={entry.cue}
|
||||||
|
main={mainField}
|
||||||
|
secondary={secondaryField}
|
||||||
|
timeStart={entry.timeStart}
|
||||||
|
timeEnd={entry.timeEnd}
|
||||||
|
duration={entry.duration}
|
||||||
|
delay={entry.delay}
|
||||||
|
isSelected={isSelected}
|
||||||
|
subscribed={subscribedData}
|
||||||
|
subscribedAlias={subscribedAlias}
|
||||||
|
showSeconds={showSeconds}
|
||||||
|
isPast={isPast}
|
||||||
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === SupportedEvent.Block) {
|
||||||
|
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
<div className={style.spacer} />
|
||||||
|
</div>
|
||||||
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.followButton {
|
||||||
|
position: relative;
|
||||||
|
bottom: 12rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
background-color: $blue-700;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: bottom 1s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transition: background-color $transition-time-action;
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
transition: bottom 1s;
|
||||||
|
bottom: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.followButton {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||||
|
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './FollowButton.module.scss';
|
||||||
|
|
||||||
|
interface FollowButtonProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onClickHandler: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FollowButton(props: FollowButtonProps) {
|
||||||
|
const { isVisible, onClickHandler } = props;
|
||||||
|
|
||||||
|
const classes = cx([style.followButton, !isVisible && style.hidden]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes} onClick={onClickHandler} type='button'>
|
||||||
|
<IoLocate />
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.block {
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import style from './OperatorBlock.module.scss';
|
||||||
|
|
||||||
|
interface OperatorBlockProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorBlock({ title }: OperatorBlockProps) {
|
||||||
|
return <div className={style.block}>{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(OperatorBlock);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@import '../Operator.module.scss';
|
||||||
|
|
||||||
|
@mixin clock-size {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
opacity: 1;
|
||||||
|
border-top: 1px solid $white-1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
color: $white-90;
|
||||||
|
background-color: $gray-1300;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1.25rem 1fr auto;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
grid-template-areas:
|
||||||
|
"binder main schedule"
|
||||||
|
"binder secondary running"
|
||||||
|
"binder fields fields";
|
||||||
|
|
||||||
|
&.subscribed {
|
||||||
|
background-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.running {
|
||||||
|
border-top: 1px solid $gray-1300;
|
||||||
|
background-color: var(--operator-running-bg-override, $red-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.past {
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.binder {
|
||||||
|
grid-area: binder;
|
||||||
|
color: $section-white;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
position: relative;
|
||||||
|
background-color: $gray-1050; // to override inline
|
||||||
|
|
||||||
|
.cue {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
width: 6em;
|
||||||
|
|
||||||
|
rotate: -90deg;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainField {
|
||||||
|
grid-area: main;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryField {
|
||||||
|
grid-area: secondary;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule {
|
||||||
|
@include clock-size;
|
||||||
|
grid-area: schedule;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running {
|
||||||
|
@include clock-size;
|
||||||
|
grid-area: running;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
grid-area: fields;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $ui-black;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
background-color: var(--operator-highlight-override, $orange-600);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: $orange-500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { memo, RefObject } from 'react';
|
||||||
|
|
||||||
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import style from './OperatorEvent.module.scss';
|
||||||
|
|
||||||
|
interface OperatorEventProps {
|
||||||
|
colour: string;
|
||||||
|
cue: string;
|
||||||
|
main: string;
|
||||||
|
secondary: string;
|
||||||
|
timeStart: number;
|
||||||
|
timeEnd: number;
|
||||||
|
duration: number;
|
||||||
|
delay?: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
subscribed?: string;
|
||||||
|
subscribedAlias: string;
|
||||||
|
showSeconds: boolean;
|
||||||
|
isPast: boolean;
|
||||||
|
selectedRef?: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract this to contain re-renders
|
||||||
|
function RollingTime() {
|
||||||
|
const timer = useTimer();
|
||||||
|
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorEvent(props: OperatorEventProps) {
|
||||||
|
const {
|
||||||
|
colour,
|
||||||
|
cue,
|
||||||
|
main,
|
||||||
|
secondary,
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
isSelected,
|
||||||
|
subscribed,
|
||||||
|
subscribedAlias,
|
||||||
|
showSeconds,
|
||||||
|
isPast,
|
||||||
|
selectedRef,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const start = formatTime(timeStart, { showSeconds });
|
||||||
|
const end = formatTime(timeEnd, { showSeconds });
|
||||||
|
|
||||||
|
const cueColours = colour && getAccessibleColour(colour);
|
||||||
|
|
||||||
|
const operatorClasses = cx([
|
||||||
|
style.event,
|
||||||
|
isSelected ? style.running : null,
|
||||||
|
subscribed ? style.subscribed : null,
|
||||||
|
isPast ? style.past : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={operatorClasses} ref={selectedRef}>
|
||||||
|
<div className={style.binder} style={{ ...cueColours }}>
|
||||||
|
<span className={style.cue}>{cue}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={style.mainField}>{main}</span>
|
||||||
|
<span className={style.schedule}>
|
||||||
|
{start} - {end}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={style.secondaryField}>{secondary}</span>
|
||||||
|
<span className={style.running}>
|
||||||
|
<DelayIndicator delayValue={delay} />
|
||||||
|
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={style.fields}>
|
||||||
|
{subscribed && (
|
||||||
|
<>
|
||||||
|
<span className={style.field}>{subscribedAlias}</span>
|
||||||
|
<span className={style.value}>{subscribed}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(OperatorEvent);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
@mixin column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
box-shadow: $large-top-drawer-shadow;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
"playback timer1B timer2B timer3B";
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
column-gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playbackIcon {
|
||||||
|
grid-area: playback;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeNow {
|
||||||
|
grid-area: timer1B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elapsedTime {
|
||||||
|
grid-area: timer2B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runningTime {
|
||||||
|
grid-area: timer3B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
display: none;
|
||||||
|
line-height: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startTime {
|
||||||
|
grid-area: timer2A;
|
||||||
|
@include column;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endTime {
|
||||||
|
grid-area: timer3A;
|
||||||
|
@include column;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.statusBar {
|
||||||
|
grid-template-areas:
|
||||||
|
"playback timer1B timer2A timer3A"
|
||||||
|
"title title timer2B timer3B";
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
column-gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startTime {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endTime {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import styles from './StatusBar.module.scss';
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
projectTitle: string;
|
||||||
|
playback: Playback;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
firstStart?: number;
|
||||||
|
firstId?: string;
|
||||||
|
lastEnd?: number;
|
||||||
|
lastId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBar(props: StatusBarProps) {
|
||||||
|
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||||
|
|
||||||
|
const timer = useTimer();
|
||||||
|
|
||||||
|
const getTimeStart = () => {
|
||||||
|
if (firstStart === undefined) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEventId) {
|
||||||
|
if (firstId === selectedEventId) {
|
||||||
|
return millisToString(timer.expectedFinish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return millisToString(firstStart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeEnd = () => {
|
||||||
|
if (lastEnd === undefined) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEventId) {
|
||||||
|
if (lastId === selectedEventId) {
|
||||||
|
return millisToString(timer.expectedFinish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return millisToString(lastEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
// use user defined format
|
||||||
|
const timeNow = formatTime(timer.clock, {
|
||||||
|
showSeconds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningTime = millisToString(timer.current);
|
||||||
|
const elapsedTime = millisToString(timer.elapsed);
|
||||||
|
|
||||||
|
const PlaybackIconComponent = useMemo(() => {
|
||||||
|
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||||
|
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||||
|
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||||
|
}, [playback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
{PlaybackIconComponent}
|
||||||
|
<div className={styles.timeNow}>
|
||||||
|
<span className={styles.label}>Time now</span>
|
||||||
|
<span className={styles.timer}>{timeNow}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.elapsedTime}>
|
||||||
|
<span className={styles.label}>Elapsed time</span>
|
||||||
|
<span className={styles.timer}>{elapsedTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.runningTime}>
|
||||||
|
<span className={styles.label}>Running timer</span>
|
||||||
|
<span className={styles.timer}>{runningTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.title}>{projectTitle}</span>
|
||||||
|
<div className={styles.startTime}>
|
||||||
|
<span className={styles.label}>Scheduled start</span>
|
||||||
|
<span className={styles.timer}>{getTimeStart()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.endTime}>
|
||||||
|
<span className={styles.label}>Scheduled end</span>
|
||||||
|
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
||||||
import { getFirst, getNext, getPrevious } from 'ontime-utils';
|
import { getFirst, getNext, getPrevious } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||||
@@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
||||||
|
|
||||||
// DND KIT
|
// DND KIT
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
|
|||||||
};
|
};
|
||||||
}, [handleKeyPress]);
|
}, [handleKeyPress]);
|
||||||
|
|
||||||
// when cursor moves, view should follow
|
|
||||||
useEffect(() => {
|
|
||||||
function scrollToComponent(
|
|
||||||
componentRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
|
||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursorRef.current && scrollRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect
|
|
||||||
}, [cursorRef?.current, scrollRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// in run mode, we follow selection
|
// in run mode, we follow selection
|
||||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
|
|||||||
$block-clearance: 0.5rem;
|
$block-clearance: 0.5rem;
|
||||||
$block-border-radius: 0.5rem;
|
$block-border-radius: 0.5rem;
|
||||||
$block-text-color: $gray-50;
|
$block-text-color: $gray-50;
|
||||||
$block-bg: $gray-1200;
|
$block-bg: $gray-1250;
|
||||||
$block-bg2: $gray-1050; // for delay and blocks
|
$block-bg2: $gray-1050; // for delay and blocks
|
||||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||||
$secondary-block-height: 2.5rem;
|
$secondary-block-height: 2.5rem;
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ $skip-opacity: 0.1;
|
|||||||
|
|
||||||
.eventTitle {
|
.eventTitle {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: calc(2.5em + 2px);
|
||||||
|
line-height: 1.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventActions {
|
.eventActions {
|
||||||
@@ -172,6 +175,9 @@ $skip-opacity: 0.1;
|
|||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
color: $block-text-color;
|
color: $block-text-color;
|
||||||
line-height: calc(1rem - 3px);
|
line-height: calc(1rem - 3px);
|
||||||
|
|
||||||
|
max-height: 2rem;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
<span>
|
<span>
|
||||||
<IoPeople
|
<IoPeople
|
||||||
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
|
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
|
||||||
data-isPublic={isPublic}
|
data-ispublic={isPublic}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SupportedEvent } from 'ontime-types';
|
|||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import style from './QuickAddBlock.module.scss';
|
import style from './QuickAddBlock.module.scss';
|
||||||
@@ -82,7 +83,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
|||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
data-testid='quick-add-event'
|
data-testid='quick-add-event'
|
||||||
>
|
>
|
||||||
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
|
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
|
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
|
||||||
@@ -94,7 +95,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
|||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
data-testid='quick-add-delay'
|
data-testid='quick-add-delay'
|
||||||
>
|
>
|
||||||
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
|
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
|
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
|
||||||
@@ -106,7 +107,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
|||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
data-testid='quick-add-block'
|
data-testid='quick-add-block'
|
||||||
>
|
>
|
||||||
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
|
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
import { ComponentType, useMemo } from 'react';
|
import { ComponentType, useMemo } from 'react';
|
||||||
import { TitleBlock } from 'ontime-types';
|
import { SupportedEvent } from 'ontime-types';
|
||||||
import { useStore } from 'zustand';
|
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 useRundown from '../../common/hooks-query/useRundown';
|
||||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||||
import { runtime } from '../../common/stores/runtime';
|
import { runtime } from '../../common/stores/runtime';
|
||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
|
|
||||||
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
|
|
||||||
|
|
||||||
const withData = <P extends object>(Component: ComponentType<P>) => {
|
const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||||
return (props: Partial<P>) => {
|
return (props: Partial<P>) => {
|
||||||
// persisted app state
|
// persisted app state
|
||||||
@@ -18,56 +16,34 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
|||||||
|
|
||||||
// HTTP API data
|
// HTTP API data
|
||||||
const { data: rundownData } = useRundown();
|
const { data: rundownData } = useRundown();
|
||||||
const { data: eventData } = useEventData();
|
const { data: project } = useProjectData();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
|
||||||
const publicEvents = useMemo(() => {
|
const publicEvents = useMemo(() => {
|
||||||
if (Array.isArray(rundownData)) {
|
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 [];
|
return [];
|
||||||
}, [rundownData]);
|
}, [rundownData]);
|
||||||
|
|
||||||
// websocket data
|
// websocket data
|
||||||
const data = useStore(runtime);
|
const data = useStore(runtime);
|
||||||
const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data;
|
const {
|
||||||
const publicSelectedId = data.loaded.selectedPublicEventId;
|
timer,
|
||||||
const selectedId = data.loaded.selectedEventId;
|
publicMessage,
|
||||||
const nextId = data.loaded.nextEventId;
|
timerMessage,
|
||||||
|
lowerMessage,
|
||||||
/********************************************/
|
playback,
|
||||||
/*** + titleManager ***/
|
onAir,
|
||||||
/*** WRAP INFORMATION RELATED TO TITLES ***/
|
eventNext,
|
||||||
/*** ---------------------------------- ***/
|
publicEventNext,
|
||||||
/********************************************/
|
publicEventNow,
|
||||||
// is there a now field?
|
eventNow,
|
||||||
let showNow = true;
|
loaded,
|
||||||
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
|
} = data;
|
||||||
|
const publicSelectedId = loaded.selectedPublicEventId;
|
||||||
// is there a next field?
|
const selectedId = loaded.selectedEventId;
|
||||||
let showNext = true;
|
const nextId = loaded.nextEventId;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
/******************************************/
|
/******************************************/
|
||||||
/*** + TimeManagerType ***/
|
/*** + TimeManagerType ***/
|
||||||
@@ -75,9 +51,6 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
|||||||
/*** -------------------------------- ***/
|
/*** -------------------------------- ***/
|
||||||
/******************************************/
|
/******************************************/
|
||||||
|
|
||||||
// inject info:
|
|
||||||
// is timer finished
|
|
||||||
// get clock string
|
|
||||||
const TimeManagerType = {
|
const TimeManagerType = {
|
||||||
...timer,
|
...timer,
|
||||||
playback,
|
playback,
|
||||||
@@ -95,8 +68,10 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
|||||||
pres={timerMessage}
|
pres={timerMessage}
|
||||||
publ={publicMessage}
|
publ={publicMessage}
|
||||||
lower={lowerMessage}
|
lower={lowerMessage}
|
||||||
title={titleManager}
|
eventNow={eventNow}
|
||||||
publicTitle={publicTitleManager}
|
publicEventNow={publicEventNow}
|
||||||
|
eventNext={eventNext}
|
||||||
|
publicEventNext={publicEventNext}
|
||||||
time={TimeManagerType}
|
time={TimeManagerType}
|
||||||
events={publicEvents}
|
events={publicEvents}
|
||||||
backstageEvents={rundownData}
|
backstageEvents={rundownData}
|
||||||
@@ -104,7 +79,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
|||||||
publicSelectedId={publicSelectedId}
|
publicSelectedId={publicSelectedId}
|
||||||
viewSettings={viewSettings}
|
viewSettings={viewSettings}
|
||||||
nextId={nextId}
|
nextId={nextId}
|
||||||
general={eventData}
|
general={project}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
|
|
||||||
.event-header {
|
.project-header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -89,7 +89,10 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
&.blink {
|
&.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;
|
margin-top: 2em;
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 7.5em;
|
}
|
||||||
|
|
||||||
|
.timer-gap {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 7.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aux-timers {
|
.aux-timers {
|
||||||
@@ -159,9 +166,12 @@
|
|||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0% {
|
0% {
|
||||||
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
background-color: var(--card-background-color-blink-override, $playback-start);
|
background-color: var(--card-background-color-blink-override, $playback-start);
|
||||||
}
|
}
|
||||||
20% {
|
100% {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
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 { formatDisplay } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
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 { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
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 ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
import { titleVariants } from '../common/animation';
|
import { titleVariants } from '../common/animation';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import './Backstage.scss';
|
import './Backstage.scss';
|
||||||
|
|
||||||
@@ -30,16 +29,17 @@ const formatOptions = {
|
|||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
publ: Message;
|
publ: Message;
|
||||||
title: TitleManager;
|
eventNow: OntimeEvent | null;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
general: EventData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Backstage(props: BackstageProps) {
|
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 { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
@@ -92,8 +92,8 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
|
||||||
<div className='event-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
@@ -110,7 +110,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
|
|
||||||
<div className='now-container'>
|
<div className='now-container'>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{title.showNow && (
|
{eventNow && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`event now ${blinkClass ? 'blink' : ''}`}
|
className={`event now ${blinkClass ? 'blink' : ''}`}
|
||||||
key='now'
|
key='now'
|
||||||
@@ -121,19 +121,21 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
>
|
>
|
||||||
<TitleCard
|
<TitleCard
|
||||||
label='now'
|
label='now'
|
||||||
title={title.titleNow}
|
title={eventNow.title}
|
||||||
subtitle={title.subtitleNow}
|
subtitle={eventNow.subtitle}
|
||||||
presenter={title.presenterNow}
|
presenter={eventNow.presenter}
|
||||||
/>
|
/>
|
||||||
<div className='timer-group'>
|
<div className='timer-group'>
|
||||||
<div className='aux-timers'>
|
<div className='aux-timers'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||||
<div className='aux-timers__value'>{startedAt}</div>
|
<div className='aux-timers__value'>{startedAt}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='timer-gap' />
|
||||||
<div className='aux-timers'>
|
<div className='aux-timers'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='timer-gap' />
|
||||||
<div className='aux-timers'>
|
<div className='aux-timers'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||||
<div className='aux-timers__value'>{stageTimer}</div>
|
<div className='aux-timers__value'>{stageTimer}</div>
|
||||||
@@ -144,7 +146,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{title.showNext && (
|
{eventNext && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event next'
|
className='event next'
|
||||||
key='next'
|
key='next'
|
||||||
@@ -155,9 +157,9 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
>
|
>
|
||||||
<TitleCard
|
<TitleCard
|
||||||
label='next'
|
label='next'
|
||||||
title={title.titleNext}
|
title={eventNext.title}
|
||||||
subtitle={title.subtitleNext}
|
subtitle={eventNext.subtitle}
|
||||||
presenter={title.presenterNext}
|
presenter={eventNext.presenter}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Message } from 'ontime-types';
|
|||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import { LowerOptions } from './LowerWrapper';
|
import { LowerOptions } from './LowerWrapper';
|
||||||
|
|
||||||
@@ -13,12 +12,14 @@ import './LowerLines.scss';
|
|||||||
|
|
||||||
interface LowerLinesProps {
|
interface LowerLinesProps {
|
||||||
lower: Message;
|
lower: Message;
|
||||||
title: TitleManager;
|
heading: string;
|
||||||
|
subheading: string;
|
||||||
options: LowerOptions;
|
options: LowerOptions;
|
||||||
|
doShow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LowerLines(props: LowerLinesProps) {
|
export default function LowerLines(props: LowerLinesProps) {
|
||||||
const { lower, title, options } = props;
|
const { lower, heading, subheading, options, doShow } = props;
|
||||||
const [showLower, setShowLower] = useState(true);
|
const [showLower, setShowLower] = useState(true);
|
||||||
|
|
||||||
// Unmount if fadeOut
|
// Unmount if fadeOut
|
||||||
@@ -36,8 +37,8 @@ export default function LowerLines(props: LowerLinesProps) {
|
|||||||
}, [options.fadeOut, options.transitionIn]);
|
}, [options.fadeOut, options.transitionIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowLower(title.showNow);
|
setShowLower(doShow);
|
||||||
}, [title.showNow]);
|
}, [doShow]);
|
||||||
|
|
||||||
// Format messages
|
// Format messages
|
||||||
const showLowerMessage = lower.text !== '' && lower.visible;
|
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-container' variants={titleContainerVariants}>
|
||||||
<motion.div className='title' variants={titleVariants}>
|
<motion.div className='title' variants={titleVariants}>
|
||||||
{title.titleNow}
|
{heading}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div className='title-decor' />
|
<div className='title-decor' />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
|
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
|
||||||
<div className='sub-decor' />
|
<div className='sub-decor' />
|
||||||
<motion.div className='subtitle' variants={subtitleVariants}>
|
<motion.div className='subtitle' variants={subtitleVariants}>
|
||||||
{title.presenterNow}
|
{subheading}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import LowerLines from './LowerLines';
|
import LowerLines from './LowerLines';
|
||||||
|
|
||||||
@@ -17,33 +16,26 @@ export type LowerOptions = {
|
|||||||
keyColour?: string;
|
keyColour?: string;
|
||||||
fadeOut: number;
|
fadeOut: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LowerProps {
|
interface LowerProps {
|
||||||
title: TitleManager;
|
eventNow: OntimeEvent | null;
|
||||||
lower: Message;
|
lower: Message;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent triggering animation without a content change
|
// prevent triggering animation without a content change
|
||||||
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
|
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 Lower = (props: LowerProps) => {
|
||||||
const { title, lower, viewSettings } = props;
|
const { eventNow, lower, viewSettings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [titles, setTitles] = useState<TitleManager>({
|
|
||||||
titleNow: '',
|
const [heading, setHeading] = useState('');
|
||||||
titleNext: '',
|
const [subheading, setSubheading] = useState('');
|
||||||
subtitleNow: '',
|
const [showLower, setShowLower] = useState(false);
|
||||||
subtitleNext: '',
|
|
||||||
presenterNow: '',
|
|
||||||
presenterNext: '',
|
|
||||||
noteNow: '',
|
|
||||||
noteNext: '',
|
|
||||||
showNow: false,
|
|
||||||
showNext: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,28 +46,32 @@ const Lower = (props: LowerProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// clear titles if necessary
|
// clear titles if necessary
|
||||||
// will trigger an animation out in the component
|
// will trigger an animation out in the component
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
let timeout: NodeJS.Timeout;
|
||||||
if (
|
|
||||||
title?.titleNow !== titles?.titleNow ||
|
|
||||||
title?.subtitleNow !== titles?.subtitleNow ||
|
|
||||||
title?.presenterNow !== titles?.presenterNow
|
|
||||||
) {
|
|
||||||
setTitles((t) => ({ ...t, showNow: false }));
|
|
||||||
|
|
||||||
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(() => {
|
timeout = setTimeout(() => {
|
||||||
setTitles(title);
|
setShowLower(false);
|
||||||
}, transitionTime);
|
}, transitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeout != null) {
|
if (timeout) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line -- we do this to keep animations
|
// 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
|
// defer rendering until we load stylesheets
|
||||||
if (!shouldRender) {
|
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);
|
export default memo(Lower, areEqual);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
@@ -19,7 +19,6 @@ interface MinimalTimerProps {
|
|||||||
pres: TimerMessage;
|
pres: TimerMessage;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
general: EventData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
|
|
||||||
.event-header {
|
.project-header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
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 { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
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 { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
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 ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
import { titleVariants } from '../common/animation';
|
import { titleVariants } from '../common/animation';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import './Public.scss';
|
import './Public.scss';
|
||||||
|
|
||||||
@@ -28,16 +27,18 @@ const formatOptions = {
|
|||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
publ: Message;
|
publ: Message;
|
||||||
publicTitle: TitleManager;
|
publicEventNow: OntimeEvent | null;
|
||||||
|
publicEventNext: OntimeEvent | null;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
general: EventData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Public(props: BackstageProps) {
|
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 { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -57,8 +58,8 @@ export default function Public(props: BackstageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
|
||||||
<div className='event-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
@@ -68,7 +69,7 @@ export default function Public(props: BackstageProps) {
|
|||||||
|
|
||||||
<div className='now-container'>
|
<div className='now-container'>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{publicTitle.showNow && (
|
{publicEventNow && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event now'
|
className='event now'
|
||||||
key='now'
|
key='now'
|
||||||
@@ -79,16 +80,16 @@ export default function Public(props: BackstageProps) {
|
|||||||
>
|
>
|
||||||
<TitleCard
|
<TitleCard
|
||||||
label='now'
|
label='now'
|
||||||
title={publicTitle.titleNow}
|
title={publicEventNow.title}
|
||||||
subtitle={publicTitle.subtitleNow}
|
subtitle={publicEventNow.subtitle}
|
||||||
presenter={publicTitle.presenterNow}
|
presenter={publicEventNow.presenter}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{publicTitle.showNext && (
|
{publicEventNext && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event next'
|
className='event next'
|
||||||
key='next'
|
key='next'
|
||||||
@@ -99,9 +100,9 @@ export default function Public(props: BackstageProps) {
|
|||||||
>
|
>
|
||||||
<TitleCard
|
<TitleCard
|
||||||
label='next'
|
label='next'
|
||||||
title={publicTitle.titleNext}
|
title={publicEventNext.title}
|
||||||
subtitle={publicTitle.subtitleNext}
|
subtitle={publicEventNext.subtitle}
|
||||||
presenter={publicTitle.presenterNext}
|
presenter={publicEventNext.presenter}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
|
|||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
import { secondsInMillis } from '../../../common/utils/dateConfig';
|
import { secondsInMillis } from '../../../common/utils/dateConfig';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
|
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ const formatOptions = {
|
|||||||
|
|
||||||
interface StudioClockProps {
|
interface StudioClockProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
title: TitleManager;
|
eventNext: OntimeEvent | null;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
backstageEvents: OntimeRundown;
|
backstageEvents: OntimeRundown;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@@ -36,7 +35,7 @@ interface StudioClockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock(props: 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)
|
// deferring rendering seems to affect styling (font and useFitText)
|
||||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
@@ -87,7 +86,7 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
className='next-title'
|
className='next-title'
|
||||||
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
|
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
|
||||||
>
|
>
|
||||||
{title.titleNext}
|
{eventNext?.title ?? ''}
|
||||||
</div>
|
</div>
|
||||||
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||||
{selectedId !== null && formatDisplay(time.current)}
|
{selectedId !== null && formatDisplay(time.current)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
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 { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
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 { formatTime } from '../../../common/utils/time';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
|
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
|
||||||
import { TitleManager } from '../ViewWrapper';
|
|
||||||
|
|
||||||
import './Timer.scss';
|
import './Timer.scss';
|
||||||
|
|
||||||
@@ -40,15 +39,15 @@ const titleVariants = {
|
|||||||
|
|
||||||
interface TimerProps {
|
interface TimerProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
general: EventData;
|
|
||||||
pres: TimerMessage;
|
pres: TimerMessage;
|
||||||
title: TitleManager;
|
eventNow: OntimeEvent | null;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
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 { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{title.showNow && !finished && (
|
{eventNow && !finished && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event now'
|
className='event now'
|
||||||
key='now'
|
key='now'
|
||||||
@@ -148,13 +147,13 @@ export default function Timer(props: TimerProps) {
|
|||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{title.showNext && (
|
{eventNext && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event next'
|
className='event next'
|
||||||
key='next'
|
key='next'
|
||||||
@@ -165,9 +164,9 @@ export default function Timer(props: TimerProps) {
|
|||||||
>
|
>
|
||||||
<TitleCard
|
<TitleCard
|
||||||
label='next'
|
label='next'
|
||||||
title={title.titleNext}
|
title={eventNext.title}
|
||||||
subtitle={title.subtitleNext}
|
subtitle={eventNext.subtitle}
|
||||||
presenter={title.presenterNext}
|
presenter={eventNext.presenter}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ $white-10: rgba(255, 255, 255, 0.10);
|
|||||||
$white-13: rgba(255, 255, 255, 0.13);
|
$white-13: rgba(255, 255, 255, 0.13);
|
||||||
$white-20: rgba(255, 255, 255, 0.20);
|
$white-20: rgba(255, 255, 255, 0.20);
|
||||||
$white-60: rgba(255, 255, 255, 0.60);
|
$white-60: rgba(255, 255, 255, 0.60);
|
||||||
|
$white-90: rgba(255, 255, 255, 0.90);
|
||||||
|
|
||||||
$black-10: rgba(0, 0, 0, 0.10);
|
$black-10: rgba(0, 0, 0, 0.10);
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ $gray-900: #4c4c4c;
|
|||||||
$gray-1000: #404040;
|
$gray-1000: #404040;
|
||||||
$gray-1050: #303030;
|
$gray-1050: #303030;
|
||||||
$gray-1100: #2d2d2d;
|
$gray-1100: #2d2d2d;
|
||||||
$gray-1250: #262626;
|
$gray-1200: #262626;
|
||||||
$gray-1200: #202020;
|
$gray-1250: #202020;
|
||||||
$gray-1300: #1a1a1a;
|
$gray-1300: #1a1a1a;
|
||||||
$gray-1350: #101010;
|
$gray-1350: #101010;
|
||||||
$pure-white: #fff;
|
$pure-white: #fff;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ $bg-container-onlight: $gray-100;
|
|||||||
|
|
||||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
|
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||||
|
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
||||||
|
|
||||||
$modal-note-color: $gray-700;
|
$modal-note-color: $gray-700;
|
||||||
|
|
||||||
@@ -54,6 +56,9 @@ $section-white: $ui-white;
|
|||||||
$inner-section-text-size: calc(1rem - 2px);
|
$inner-section-text-size: calc(1rem - 2px);
|
||||||
$text-body-size: calc(1rem - 1px);
|
$text-body-size: calc(1rem - 1px);
|
||||||
|
|
||||||
|
// media queries
|
||||||
|
$min-tablet: 500px;
|
||||||
|
|
||||||
.blink {
|
.blink {
|
||||||
animation: blink $blinking-time linear infinite;
|
animation: blink $blinking-time linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ontimeButtonOutlined = {
|
|||||||
},
|
},
|
||||||
_active: {
|
_active: {
|
||||||
backgroundColor: '#2d2d2d', // $gray-1100
|
backgroundColor: '#2d2d2d', // $gray-1100
|
||||||
borderColor: '#202020', // $gray-12000
|
borderColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const ontimeButtonSubtle = {
|
|||||||
},
|
},
|
||||||
_active: {
|
_active: {
|
||||||
backgroundColor: '#2d2d2d', // $gray-1100
|
backgroundColor: '#2d2d2d', // $gray-1100
|
||||||
borderColor: '#202020', // $gray-12000
|
borderColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const ontimeBlockRadio = {
|
export const ontimeBlockRadio = {
|
||||||
control: {
|
control: {
|
||||||
borderColor: '#262626', // $gray-1250
|
borderColor: '#262626', // $gray-1200
|
||||||
backgroundColor: '#262626', // $gray-1250
|
backgroundColor: '#262626', // $gray-1200
|
||||||
_checked: {
|
_checked: {
|
||||||
borderColor: '#262626', // $gray-1250
|
borderColor: '#262626', // $gray-1200
|
||||||
color: '#3182ce', // $action-blue
|
color: '#3182ce', // $action-blue
|
||||||
backgroundColor: '#3182ce', // $action-blue
|
backgroundColor: '#3182ce', // $action-blue
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const commonStyles = {
|
const commonStyles = {
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
backgroundColor: '#262626', // $gray-1250
|
backgroundColor: '#262626', // $gray-1200
|
||||||
color: '#e2e2e2', // $gray-200
|
color: '#e2e2e2', // $gray-200
|
||||||
border: '1px solid transparent',
|
border: '1px solid transparent',
|
||||||
_hover: {
|
_hover: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "2.7.2",
|
"version": "2.13.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "",
|
"postinstall": "",
|
||||||
"dev:electron": "NODE_ENV=development electron .",
|
"dev:electron": "cross-env NODE_ENV=development electron .",
|
||||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||||
"dist-mac": "electron-builder --publish=never --mac",
|
"dist-mac": "electron-builder --publish=never --mac",
|
||||||
"dist-linux": "electron-builder --publish=never --x64 --linux",
|
"dist-linux": "electron-builder --publish=never --x64 --linux",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"semi": true,
|
"endOfLine": "lf",
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 120,
|
"printWidth": 120
|
||||||
"tabWidth": 2
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "2.7.2",
|
"version": "2.13.1",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^1.20.0",
|
"body-parser": "^1.20.0",
|
||||||
@@ -14,17 +14,18 @@
|
|||||||
"express-validator": "^6.14.2",
|
"express-validator": "^6.14.2",
|
||||||
"lowdb": "^5.0.5",
|
"lowdb": "^5.0.5",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-osc": "^8.0.10",
|
"node-osc": "^9.0.2",
|
||||||
"node-xlsx": "^0.21.0",
|
"node-xlsx": "^0.21.0",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-local": "~1.0.0",
|
"passport-local": "~1.0.0",
|
||||||
|
"steno": "^3.1.0",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/node": "^16.11.7",
|
"@types/node": "^16.11.7",
|
||||||
"@types/node-osc": "^6.0.0",
|
"@types/node-osc": "^6.0.2",
|
||||||
"@types/websocket": "^1.0.5",
|
"@types/websocket": "^1.0.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||||
"@typescript-eslint/parser": "^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: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",
|
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"test": "cross-env IS_TEST=true vitest",
|
||||||
"test:pipeline": "vitest run",
|
"test:pipeline": "cross-env IS_TEST=true vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ export class OscServer implements IAdapter {
|
|||||||
constructor(config: OSCSettings) {
|
constructor(config: OSCSettings) {
|
||||||
this.osc = new Server(config.portIn, '0.0.0.0');
|
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) => {
|
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
|
// ontime: fixed message for app
|
||||||
// path: command to be called
|
// path: command to be called
|
||||||
// args: extra data, only used on some API entries (delay, goto)
|
// args: extra data, only used on some API entries (delay, goto)
|
||||||
|
|
||||||
// split message
|
// split message
|
||||||
const [, address, path] = msg[0].split('/');
|
const [, address, path, ...params] = msg[0].split('/');
|
||||||
const args = msg[1];
|
const args = msg[1];
|
||||||
|
|
||||||
// get first part before (ontime)
|
// get first part before (ontime)
|
||||||
@@ -37,7 +37,14 @@ export class OscServer implements IAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reply = dispatchFromAdapter(path, args, 'osc');
|
const reply = dispatchFromAdapter(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
payload: args,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
'osc',
|
||||||
|
);
|
||||||
if (reply) {
|
if (reply) {
|
||||||
const { topic, payload } = reply;
|
const { topic, payload } = reply;
|
||||||
this.osc.emit(topic, payload);
|
this.osc.emit(topic, payload);
|
||||||
|
|||||||
@@ -123,7 +123,13 @@ export class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
// Protocol specific stuff handled above
|
// Protocol specific stuff handled above
|
||||||
try {
|
try {
|
||||||
const reply = dispatchFromAdapter(type, payload, 'ws');
|
const reply = dispatchFromAdapter(
|
||||||
|
type,
|
||||||
|
{
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
'ws',
|
||||||
|
);
|
||||||
if (reply) {
|
if (reply) {
|
||||||
const { topic, payload } = reply;
|
const { topic, payload } = reply;
|
||||||
ws.send(topic, payload);
|
ws.send(topic, payload);
|
||||||
|
|||||||
+28
-7
@@ -1,3 +1,5 @@
|
|||||||
|
import { LogOrigin, OSCSettings } from 'ontime-types';
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import expressStaticGzip from 'express-static-gzip';
|
import expressStaticGzip from 'express-static-gzip';
|
||||||
@@ -6,14 +8,12 @@ import cors from 'cors';
|
|||||||
|
|
||||||
// import utils
|
// import utils
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
|
|
||||||
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
|
||||||
|
|
||||||
// Import Routes
|
// Import Routes
|
||||||
import { router as rundownRouter } from './routes/rundownRouter.js';
|
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 ontimeRouter } from './routes/ontimeRouter.js';
|
||||||
import { router as playbackRouter } from './routes/playbackRouter.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 { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||||
import { populateStyles } from './modules/loadStyles.js';
|
import { populateStyles } from './modules/loadStyles.js';
|
||||||
import { eventStore, getInitialPayload } from './stores/EventStore.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}`);
|
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ app.use(express.json({ limit: '1mb' }));
|
|||||||
|
|
||||||
// Implement route endpoints
|
// Implement route endpoints
|
||||||
app.use('/events', rundownRouter);
|
app.use('/events', rundownRouter);
|
||||||
app.use('/eventdata', eventDataRouter);
|
app.use('/project', projectRouter);
|
||||||
app.use('/ontime', ontimeRouter);
|
app.use('/ontime', ontimeRouter);
|
||||||
app.use('/playback', playbackRouter);
|
app.use('/playback', playbackRouter);
|
||||||
|
|
||||||
@@ -137,10 +139,21 @@ export const startServer = async () => {
|
|||||||
expressServer = http.createServer(app);
|
expressServer = http.createServer(app);
|
||||||
|
|
||||||
socket.init(expressServer);
|
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
|
// provide initial payload to event store
|
||||||
eventLoader.init();
|
const initialPayload = getInitialPayload();
|
||||||
eventStore.init(getInitialPayload());
|
eventStore.init(initialPayload);
|
||||||
|
|
||||||
expressServer.listen(serverPort, '0.0.0.0');
|
expressServer.listen(serverPort, '0.0.0.0');
|
||||||
|
|
||||||
@@ -187,7 +200,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { success, message } = oscIntegration.init(osc);
|
const { success, message } = oscIntegration.init(osc);
|
||||||
logger.info(LogOrigin.Rx, message);
|
logger.info(LogOrigin.Tx, message);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
integrationService.register(oscIntegration);
|
integrationService.register(oscIntegration);
|
||||||
@@ -202,6 +215,14 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
|||||||
export const shutdown = async (exitCode = 0) => {
|
export const shutdown = async (exitCode = 0) => {
|
||||||
console.log(`Ontime shutting down with code ${exitCode}`);
|
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();
|
expressServer?.close();
|
||||||
oscServer?.shutdown();
|
oscServer?.shutdown();
|
||||||
eventTimer.shutdown();
|
eventTimer.shutdown();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user