mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 22:47:59 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b0e03e980 | |||
| 0966659596 | |||
| 1123e29499 | |||
| 3d2248cceb | |||
| 043ba38916 | |||
| 5b6d4c5b28 | |||
| 07e8bedff4 | |||
| 6e61a7ecf9 | |||
| accdf2c396 | |||
| 5e39c9b51f | |||
| b52fb0f9ee | |||
| 54f30e69a1 | |||
| 189cee0104 | |||
| 9e04a1dcfa | |||
| 0c3c08ac98 | |||
| 95b437e18c | |||
| 75b5118b0f | |||
| dcbb92c470 | |||
| 8da8a08464 | |||
| 8b5034efbf | |||
| 74418ce17e | |||
| d8a8f0ad92 | |||
| 817ebcdd26 | |||
| c4f51192de | |||
| 5cf5b2fae3 | |||
| 8f249b9d51 | |||
| bef69e7d0f | |||
| dd81b92584 | |||
| b14ac07568 | |||
| c4136a0ec7 | |||
| cf14b0b427 | |||
| 16a169b22c | |||
| 8af5feb9af | |||
| 88b45fd952 | |||
| a8e9962eed | |||
| 70c93a69fe | |||
| 0ef1b44d9e | |||
| 84839824fb | |||
| b63e3ed8f3 |
@@ -0,0 +1,2 @@
|
||||
github: cpvalente
|
||||
buy_me_a_coffee: cpvalente
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 660 KiB |
@@ -1,9 +1,8 @@
|
||||
name: Ontime build v3
|
||||
name: Ontime build
|
||||
|
||||
on:
|
||||
# Only trigger manually for now for testing
|
||||
# push:
|
||||
# tags: [ "*" ]
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker Image CI Ontime V2
|
||||
name: Docker Image CI Ontime
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,14 +13,14 @@ jobs:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v3.6.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
version: 16.16.0
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Ontime build v2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
runs-on: macOS-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
run: pnpm dist-mac
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
./apps/electron/dist/ontime-macOS-x64.dmg
|
||||
./apps/electron/dist/ontime-macOS-arm64.dmg
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
run: pnpm dist-win
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: './apps/electron/dist/ontime-win64.exe'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_ubuntu:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
run: pnpm dist-linux
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: './apps/electron/dist/ontime-linux.AppImage'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,9 +1,8 @@
|
||||
name: Ontime test v3
|
||||
name: Ontime test
|
||||
|
||||
on:
|
||||
# Only trigger manually for now for testing
|
||||
# pull_request:
|
||||
# branches: '*'
|
||||
pull_request:
|
||||
branches: '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Ontime test v2
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run code quality per package
|
||||
- name: React - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/client
|
||||
|
||||
- name: Server - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/server
|
||||
|
||||
- name: Utils - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./packages/utils
|
||||
|
||||
- name: Types - Run linter
|
||||
if: always()
|
||||
run: pnpm lint
|
||||
working-directory: ./packages/types
|
||||
|
||||
# We choose to run tests separately
|
||||
- name: React - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./apps/client
|
||||
|
||||
- name: Server - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./apps/server
|
||||
|
||||
- name: Utils - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./packages/utils
|
||||
|
||||
e2e-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build client
|
||||
run: pnpm build:local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm e2e
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: automated-screenshots
|
||||
path: automated-screenshots/
|
||||
retention-days: 14
|
||||
@@ -1,5 +1,8 @@
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
## Download the latest releases here
|
||||
|
||||
@@ -10,146 +13,115 @@
|
||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
||||
|
||||
# Ontime
|
||||
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
|
||||
|
||||
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.
|
||||
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
|
||||
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Conference organisers
|
||||
- Touring shows and receiving venues
|
||||
- Broadcasters and streamers
|
||||
- Theatres and opera houses
|
||||
- Houses of worship
|
||||
|
||||

|
||||
### For live environments
|
||||
|
||||

|
||||
Ontime is designed for use in live environments. \
|
||||
This guides the application into being flexible and efficiently integrating into different workflows.
|
||||
|
||||
### For teams
|
||||
|
||||
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
|
||||
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
|
||||
signage.
|
||||
|
||||
### Simple infrastructure
|
||||
|
||||
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
|
||||
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
|
||||
your team and clients.
|
||||
|
||||
Ontime is made by video engineers and entertainment technicians.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
[Read the docs to learn more](https://docs.getontime.no)
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
||||
From the app, you can add / edit your running order and control the timer playback.
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
||||
or `192.168.1.3:4001`
|
||||
<br />
|
||||
You can then use the menu in the top left corner to select the desired view.
|
||||
The menu will be initially hidden until there is mouse interaction.
|
||||
Once installed and running, any device in the network has access to Ontime.
|
||||
|
||||
In the case of unattended machines or automation, it is possible to use different URL to recall
|
||||
individual views and extend view settings using the URL presets feature
|
||||
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
||||
views. \
|
||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||
|
||||
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
||||
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
|
||||
|
||||
```
|
||||
For the presentation views
|
||||
For the backstage views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001 > Web server default to presenter timer view
|
||||
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
||||
IP.ADDRESS:4001/minimal > Simple timer view
|
||||
IP.ADDRESS:4001/clock > Simple clock view
|
||||
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
```
|
||||
|
||||
```
|
||||
For management views
|
||||
For the public views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
```
|
||||
|
||||
```
|
||||
For production views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Feature List (in no specific order)
|
||||
## Main features
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Different screen types
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- [x] Configurable Lower Thirds
|
||||
- [x] Collaborative editing with the cuesheet view
|
||||
- [x] Send live messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- OSC (Open Sound Control)
|
||||
- HTTP
|
||||
- WebSockets
|
||||
- [x] Roll mode: run standalone using the system clock
|
||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
||||
- [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Unopinionated
|
||||
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
|
||||
We 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.
|
||||
|
||||
## Rich APIs for workflow integrations
|
||||
|
||||
The app is currently being developed for a broad user base, from broadcast to entertainment and
|
||||
conference halls.
|
||||
|
||||
Taking advantage of the integrations, we currently use Ontime with:
|
||||
|
||||
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
|
||||
webmodule
|
||||
- `OBS`: **render views** using the Browser Module
|
||||
- `QLab`: trigger Ontime using **OSC API**
|
||||
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
|
||||
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
|
||||
|
||||
### Make your own viewer
|
||||
|
||||
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).
|
||||
<br />
|
||||
We have prepared a few resources to help here:
|
||||
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
|
||||
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
|
||||
how to get you started
|
||||
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
|
||||
<br />
|
||||
More information [in the docs](https://docs.getontime.no/features/custom-views/)
|
||||
|
||||
|
||||
### Headless run️
|
||||
|
||||
You can self-host and run Ontime in a docker image.
|
||||
|
||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
|
||||
* [x] Distribute data over network and render it in the browser
|
||||
* [x] Collaborative
|
||||
* [x] Extendable
|
||||
* [x] Send messages to different screen types
|
||||
* [x] Differentiate between backstage and public data
|
||||
* [x] Workflow for managing delays
|
||||
* [x] Rich protocol integrations for Control and Feedback
|
||||
* [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||
* [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
* [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||
<br />
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
||||
We use Github's issue tracking for bug reporting and feature requests. \
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
@@ -169,13 +141,15 @@ or in Linux
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click more and select "Run Anyway"
|
||||
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- in macOS: the solution in macOS is different across versions, please refer to
|
||||
the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
|
||||
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
|
||||
would mean a recurrent expense and is not a priority. This is unlikely to change in future. If you
|
||||
have tips on how to improve this or would like to sponsor the code signing,
|
||||
Long story short: Ontime app is unsigned. \
|
||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense and is not a priority. \
|
||||
This is unlikely to change in future. \
|
||||
If you have tips on how to improve this or would like to sponsor the code signing,
|
||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
#### Safari
|
||||
@@ -187,19 +161,29 @@ There are known issues with Safari versions lower than 13:
|
||||
|
||||
There is no plan for any further work on this.
|
||||
|
||||
# Contributing
|
||||
## Contributing
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
<br /><br />
|
||||
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
||||
Pull Request.
|
||||
<br />
|
||||
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
# Help
|
||||
## Help
|
||||
|
||||
Help is underway! ... and can be found [here](https://docs.getontime.no)
|
||||
|
||||
# License
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the GNU GPL v3
|
||||
|
||||
## SPONSOR
|
||||
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
|
||||
|
||||
<p align="center">
|
||||
<br>
|
||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
|
||||
</p>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Terms of sponsorship
|
||||
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
|
||||
|
||||
## Why sponsor
|
||||
Ontime is and will remain free, regardless of your decision to contribute.
|
||||
Regardless, you may choose to provide a monetary contribution:
|
||||
|
||||
### Improve user experience
|
||||
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
|
||||
|
||||
### Cool stuff
|
||||
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
|
||||
|
||||
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
|
||||
|
||||
### Priorities
|
||||
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
|
||||
|
||||
Additional funding will be considered according to the needs at the time.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.0.0-beta.1",
|
||||
"version": "3.0.0-beta.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.92.0",
|
||||
@@ -28,7 +29,6 @@
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"typeface-open-sans": "^1.1.13",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -7,7 +6,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { ContextMenu } from './common/components/context-menu/ContextMenu';
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { socketClientName } from './common/stores/connectionName';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
@@ -15,38 +13,10 @@ import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
// Load Open Sans typeface
|
||||
// @ts-expect-error no types from font import
|
||||
import('typeface-open-sans');
|
||||
|
||||
const preferredClientName = socketClientName.getState().name;
|
||||
connectSocket(preferredClientName);
|
||||
|
||||
function App() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
// check if the alt key is pressed
|
||||
if (event.altKey) {
|
||||
if (event.code === 'KeyT') {
|
||||
// ask to see debug
|
||||
sendToElectron('set-window', 'show-dev');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [isElectron, sendToElectron]);
|
||||
return (
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
|
||||
|
||||
import { useEmitLog } from '../../stores/logger';
|
||||
|
||||
interface QuitIconBtnProps {
|
||||
clickHandler: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const quitBtnStyle = {
|
||||
color: '#D20300', // $red-700
|
||||
borderColor: '#D20300', // $red-700
|
||||
_focus: { boxShadow: 'none' },
|
||||
_hover: {
|
||||
background: '#D20300', // $red-700
|
||||
color: 'white',
|
||||
_disabled: {
|
||||
color: '#D20300', // $red-700
|
||||
background: 'none',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
background: '#9A0000', // $red-1000
|
||||
color: 'white',
|
||||
},
|
||||
variant: 'outline',
|
||||
isRound: true,
|
||||
};
|
||||
|
||||
export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
const { clickHandler, disabled, ...rest } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { emitInfo } = useEmitLog();
|
||||
const onClose = () => setIsOpen(false);
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.on('user-request-shutdown', () => {
|
||||
emitInfo('Shutdown request');
|
||||
setIsOpen(true);
|
||||
});
|
||||
}
|
||||
}, [emitInfo]);
|
||||
|
||||
const handleShutdown = useCallback(() => {
|
||||
onClose();
|
||||
clickHandler();
|
||||
}, [clickHandler]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label='Quit Application'>
|
||||
<IconButton
|
||||
aria-label='Quit Application'
|
||||
size='lg'
|
||||
icon={<IoPowerOutline />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={disabled}
|
||||
{...quitBtnStyle}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Ontime Shutdown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const { value } = props;
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
|
||||
const { value, inputref } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
autosize(ref.current);
|
||||
const node = inputref.current;
|
||||
autosize(inputref.current);
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, [value]);
|
||||
}, [inputref, value]);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
w='100%'
|
||||
ref={inputref}
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Input, Textarea } from '@chakra-ui/react';
|
||||
|
||||
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
|
||||
import { Size } from '../../../models/Util.type';
|
||||
|
||||
import useReactiveTextInput from './useReactiveTextInput';
|
||||
|
||||
interface BaseProps {
|
||||
isTextArea?: boolean;
|
||||
isFullHeight?: boolean;
|
||||
size?: Size;
|
||||
field: EventEditorSubmitActions;
|
||||
initialText?: string;
|
||||
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
|
||||
}
|
||||
|
||||
interface TextInputProps extends BaseProps {
|
||||
isTextArea?: false;
|
||||
}
|
||||
|
||||
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
|
||||
|
||||
interface TextAreaProps extends BaseProps {
|
||||
isTextArea: true;
|
||||
resize?: ResizeOptions;
|
||||
}
|
||||
|
||||
type InputProps = TextInputProps | TextAreaProps;
|
||||
|
||||
export default function TextInput(props: InputProps) {
|
||||
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
||||
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
||||
|
||||
let resize: ResizeOptions = 'none';
|
||||
if (isTextArea) {
|
||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
||||
}
|
||||
|
||||
return isTextArea ? (
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
size={size}
|
||||
resize={resize}
|
||||
variant='ontime-filled'
|
||||
{...textAreaProps}
|
||||
style={{ height: isFullHeight ? '100%' : undefined }}
|
||||
data-testid='input-textarea'
|
||||
/>
|
||||
) : (
|
||||
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent) => void;
|
||||
onBlur: (event: ChangeEvent) => void;
|
||||
onKeyDown: (event: KeyboardEvent) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export default function useReactiveTextInput(
|
||||
initialText: string,
|
||||
submitCallback: (newValue: string) => void,
|
||||
ref: RefObject<HTMLInputElement>,
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -44,43 +47,45 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit === initialText) {
|
||||
return;
|
||||
}
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
if (valueToSubmit !== initialText) {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
},
|
||||
[initialText, submitCallback],
|
||||
[initialText, ref, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {string} key
|
||||
* @description Handles escape events
|
||||
* @param {string} valueToSubmit
|
||||
*/
|
||||
const keyHandler = useCallback(
|
||||
(key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
setText(initialText);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (options?.submitOnEnter) {
|
||||
handleSubmit(text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[initialText, options?.submitOnEnter, handleSubmit, text],
|
||||
);
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
return getHotkeyHandler(hotKeys);
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
|
||||
return {
|
||||
value: text,
|
||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
||||
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
|
||||
onKeyDown: keyHandler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ interface ScheduleProviderProps {
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
@@ -37,7 +39,7 @@ export const ScheduleProvider = ({
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
|
||||
@@ -249,12 +249,11 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
none: 'None',
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -267,7 +266,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
event: 'Event Load',
|
||||
manual: 'Manual',
|
||||
},
|
||||
defaultValue: 'event',
|
||||
defaultValue: 'manual',
|
||||
},
|
||||
{
|
||||
id: 'top-src',
|
||||
@@ -283,7 +282,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
description: 'Select the data source for the bottom element',
|
||||
type: 'option',
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'lowerMsg',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
@@ -394,9 +393,9 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
placeholder: '8 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
@@ -431,9 +430,9 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
placeholder: '8 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
|
||||
@@ -21,11 +21,11 @@ import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events
|
||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
|
||||
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
@@ -48,6 +48,8 @@ export const useEventAction = () => {
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
defaultWarnTime: number;
|
||||
defaultDangerTime: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,8 @@ export const useEventAction = () => {
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
@@ -85,6 +89,14 @@ export const useEventAction = () => {
|
||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||
newEvent.duration = forgivingStringToMillis(defaultDuration);
|
||||
}
|
||||
|
||||
if (newEvent.timeDanger === undefined) {
|
||||
newEvent.timeDanger = forgivingStringToMillis(defaultDangerTime);
|
||||
}
|
||||
|
||||
if (newEvent.timeWarning === undefined) {
|
||||
newEvent.timeWarning = forgivingStringToMillis(defaultWarnTime);
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
@@ -98,7 +110,7 @@ export const useEventAction = () => {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
|
||||
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -155,7 +167,7 @@ export const useEventAction = () => {
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (eventId: string, field: string, value: string) => {
|
||||
updateEvent({ id: eventId, custom: { [field]: { value } } });
|
||||
updateEvent({ id: eventId, custom: { [field]: value } });
|
||||
},
|
||||
[updateEvent],
|
||||
);
|
||||
|
||||
@@ -25,8 +25,6 @@ export const useOperator = () => {
|
||||
export const useMessageControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
timer: state.message.timer,
|
||||
public: state.message.public,
|
||||
lower: state.message.lower,
|
||||
external: state.message.external,
|
||||
onAir: state.onAir,
|
||||
});
|
||||
@@ -37,10 +35,6 @@ export const useMessageControl = () => {
|
||||
export const setMessage = {
|
||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
||||
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
|
||||
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
|
||||
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
|
||||
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
|
||||
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
||||
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
||||
};
|
||||
@@ -89,13 +83,13 @@ export const useInfoPanel = () => {
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useExtraTimerTime = () => {
|
||||
export const useAuxTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useExtraTimerControl = () => {
|
||||
export const useAuxTimerControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
@@ -104,7 +98,7 @@ export const useExtraTimerControl = () => {
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const setExtraTimer = {
|
||||
export const setAuxTimer = {
|
||||
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
|
||||
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
|
||||
|
||||
@@ -33,5 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
return { mode };
|
||||
});
|
||||
},
|
||||
setCursor: (cursor: string | null) => set(() => ({ cursor })),
|
||||
setCursor: (cursor: string | null) => set({ cursor }),
|
||||
}));
|
||||
|
||||
@@ -2,55 +2,68 @@ import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettings = {
|
||||
linkPrevious: boolean;
|
||||
defaultPublic: boolean;
|
||||
type EditorSettingsStore = {
|
||||
defaultDuration: string;
|
||||
linkPrevious: boolean;
|
||||
defaultWarnTime: string;
|
||||
defaultDangerTime: string;
|
||||
defaultPublic: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
};
|
||||
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
export const editorSettingsDefaults = {
|
||||
duration: '00:10:00',
|
||||
linkPrevious: true,
|
||||
warnTime: '00:02:00', // 120000 same as backend
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultWarnTime = 'ontime-default-warn-time',
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
|
||||
},
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
return {
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||
return { defaultDuration };
|
||||
}),
|
||||
|
||||
setLinkPrevious: (linkPrevious) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { eventSettings: { ...state.eventSettings, linkPrevious } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||
return { eventSettings: { ...state.eventSettings, defaultDuration } };
|
||||
}),
|
||||
}));
|
||||
setLinkPrevious: (linkPrevious) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { linkPrevious };
|
||||
}),
|
||||
setWarnTime: (defaultWarnTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||
return { defaultWarnTime };
|
||||
}),
|
||||
setDangerTime: (defaultDangerTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
|
||||
return { defaultDangerTime };
|
||||
}),
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { defaultPublic };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type EntryCopyStore = {
|
||||
entryCopyId: string | null;
|
||||
setEntryCopyId: (eventId: string | null) => void;
|
||||
};
|
||||
|
||||
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||
entryCopyId: null,
|
||||
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
|
||||
}));
|
||||
@@ -23,14 +23,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
blink: false,
|
||||
blackout: false,
|
||||
},
|
||||
public: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
lower: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: false,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('cloneEvent()', () => {
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {
|
||||
lighting: { value: '3' },
|
||||
lighting: '3',
|
||||
} as EventCustomFields,
|
||||
} as OntimeEvent;
|
||||
|
||||
|
||||
@@ -4,3 +4,5 @@ export function isMacOS() {
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
|
||||
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
gap: 0.25rem;
|
||||
|
||||
overflow: hidden;
|
||||
background-color: black;
|
||||
background-color: $ui-black;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Freeze timer on end'
|
||||
description='Timer in views will stop from going negative after reaching'
|
||||
description='Timer in views will stop from going negative and instead stop at 00:00:00'
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
+56
-13
@@ -1,17 +1,23 @@
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
|
||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
|
||||
const eventSettings = useEditorSettings((state) => state);
|
||||
|
||||
const setDefaultDuration = eventSettings.setDefaultDuration;
|
||||
const setLinkPrevious = eventSettings.setLinkPrevious;
|
||||
const setWarnTime = eventSettings.setWarnTime;
|
||||
const setDangerTime = eventSettings.setDangerTime;
|
||||
const setDefaultPublic = eventSettings.setDefaultPublic;
|
||||
|
||||
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
|
||||
const warnTimeInMs = forgivingStringToMillis(eventSettings.defaultWarnTime);
|
||||
const dangerTimeInMs = forgivingStringToMillis(eventSettings.defaultDangerTime);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
@@ -19,24 +25,21 @@ export default function EditorSettingsForm() {
|
||||
<Panel.SubHeader>Editor settings</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Rundown options</Panel.Title>
|
||||
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Default duration'
|
||||
description='When creating a new event, what is the default duration'
|
||||
/>
|
||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder='00:10:00'
|
||||
placeholder={editorSettingsDefaults.duration}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='New events start time will be linked to the previous event'
|
||||
description='Whether the start time of new events should be linked to the previous event end time'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
@@ -45,6 +48,46 @@ export default function EditorSettingsForm() {
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='End Action' description='Default end action for new events' />
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||
<option value={EndAction.None}>None</option>
|
||||
<option value={EndAction.Stop}>Stop</option>
|
||||
<option value={EndAction.LoadNext}>Load next</option>
|
||||
<option value={EndAction.PlayNext}>Play next</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
|
||||
<TimeInput<'warnTime'>
|
||||
name='warnTime'
|
||||
submitHandler={(_field, value) => setWarnTime(value)}
|
||||
time={warnTimeInMs}
|
||||
placeholder={editorSettingsDefaults.warnTime}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
|
||||
<TimeInput<'dangerTime'>
|
||||
name='dangerTime'
|
||||
submitHandler={(_field, value) => setDangerTime(value)}
|
||||
time={dangerTimeInMs}
|
||||
placeholder={editorSettingsDefaults.dangerTime}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default public' description='New events will be public' />
|
||||
<Switch
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function ShutdownPanel() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const sendShutdown = () => {
|
||||
sendToElectron('shutdown', 'now');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -18,9 +31,29 @@ export default function ShutdownPanel() {
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
|
||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Ontime Shutdown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>
|
||||
This will shutdown the Ontime server. <br /> Are you sure?
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme='red' onClick={sendShutdown} ml={4}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
import { ImportCustom } from 'ontime-utils';
|
||||
|
||||
import { convertToImportMap } from '../importMapUtils';
|
||||
import { convertToImportMap, NamedImportMap } from '../importMapUtils';
|
||||
|
||||
describe('convertToImportMap', () => {
|
||||
it('converts a namedImportMap to a importMap', () => {
|
||||
const defaultNamedImporMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
'Link start': 'link start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
@@ -26,7 +27,7 @@ describe('convertToImportMap', () => {
|
||||
{ ontimeName: 'EmptyImportName', importName: '' },
|
||||
{ ontimeName: '', importName: 'EmptyOntimeName' },
|
||||
] as ImportCustom[],
|
||||
};
|
||||
} as NamedImportMap;
|
||||
|
||||
const importMap = convertToImportMap(defaultNamedImporMap);
|
||||
expect(importMap.custom).toStrictEqual({
|
||||
|
||||
+25
-2
@@ -6,6 +6,7 @@ export type NamedImportMap = typeof namedImportMap;
|
||||
export const namedImportMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
'Link start': 'link start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
@@ -21,6 +22,15 @@ export const namedImportMap = {
|
||||
custom: [] as ImportCustom[],
|
||||
};
|
||||
|
||||
function isNamedImportMap(obj: unknown): obj is NamedImportMap {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = Object.keys(namedImportMap);
|
||||
return keys.every((key) => Object.hasOwn(obj, key));
|
||||
}
|
||||
|
||||
export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => {
|
||||
if (ontimeName && importName) {
|
||||
@@ -32,6 +42,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
return {
|
||||
worksheet: namedImportMap.Worksheet,
|
||||
timeStart: namedImportMap.Start,
|
||||
linkStart: namedImportMap['Link start'],
|
||||
timeEnd: namedImportMap.End,
|
||||
duration: namedImportMap.Duration,
|
||||
cue: namedImportMap.Cue,
|
||||
@@ -52,10 +63,22 @@ export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
||||
}
|
||||
|
||||
export function getPersistedOptions(): NamedImportMap {
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('ontime-import-options');
|
||||
if (!options) {
|
||||
return namedImportMap;
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
return JSON.parse(options);
|
||||
}
|
||||
|
||||
export function getPersistedOptions(): NamedImportMap {
|
||||
try {
|
||||
const options = getPersistImportMap();
|
||||
if (!isNamedImportMap(options)) {
|
||||
return namedImportMap;
|
||||
}
|
||||
return options;
|
||||
} catch {
|
||||
return namedImportMap;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-10
@@ -1,12 +1,27 @@
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr .secondaryRow {
|
||||
background-color: $white-7;
|
||||
padding-left: 2em;
|
||||
}
|
||||
tr .secondaryRow {
|
||||
background-color: $white-7;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.linkStartActive {
|
||||
color: $active-indicator;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from 'react';
|
||||
import { IoLink } from '@react-icons/all-files/io5/IoLink';
|
||||
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
@@ -83,7 +84,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
</td>
|
||||
<td className={style.nowrap}>{event.cue}</td>
|
||||
<td>{event.title}</td>
|
||||
<td>{millisToString(event.timeStart)}</td>
|
||||
<td className={style.flex}>
|
||||
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span>
|
||||
{event.linkStart && <IoLink className={style.linkStartActive} />}
|
||||
</td>
|
||||
<td>{millisToString(event.timeEnd)}</td>
|
||||
<td>{millisToString(event.duration)}</td>
|
||||
<td>{millisToString(event.timeWarning)}</td>
|
||||
@@ -101,7 +105,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
fieldHeaders.map((field) => {
|
||||
let value = '';
|
||||
if (field in event.custom) {
|
||||
value = event.custom[field].value;
|
||||
value = event.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "./MessageControl.module.scss";
|
||||
|
||||
.inputItems {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
@@ -6,10 +8,5 @@
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
&.active {
|
||||
color: $action-text-color;
|
||||
}
|
||||
@extend .label;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,19 @@
|
||||
gap: $element-spacing;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.singleAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
margin-top: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
&.active {
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,29 +17,14 @@ export default function MessageControl() {
|
||||
const message = useMessageControl();
|
||||
const blink = message.timer.blink;
|
||||
const blackout = message.timer.blackout;
|
||||
|
||||
return (
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Public / Backstage screen message'
|
||||
placeholder='Shown in public and backstage screens'
|
||||
text={message.public.text || ''}
|
||||
visible={message.public.visible || false}
|
||||
changeHandler={(newValue) => setMessage.publicText(newValue)}
|
||||
actionHandler={() => setMessage.publicVisible(!message.public.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Lower third message'
|
||||
placeholder='Shown in lower third'
|
||||
text={message.lower.text || ''}
|
||||
visible={message.lower.visible || false}
|
||||
changeHandler={(newValue) => setMessage.lowerText(newValue)}
|
||||
actionHandler={() => setMessage.lowerVisible(!message.lower.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Timer'
|
||||
placeholder='Message shown in stage timer'
|
||||
text={message.timer.text || ''}
|
||||
visible={message.timer.visible || false}
|
||||
text={message.timer.text}
|
||||
visible={message.timer.visible}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
|
||||
/>
|
||||
@@ -69,8 +54,8 @@ export default function MessageControl() {
|
||||
label='External Message (read only)'
|
||||
placeholder={enDash}
|
||||
readonly
|
||||
text={message.external.text || ''}
|
||||
visible={message.external.visible || false}
|
||||
text={message.external.text}
|
||||
visible={message.external.visible}
|
||||
changeHandler={noop}
|
||||
actionHandler={noop}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Playback } from 'ontime-types';
|
||||
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
|
||||
import { ExtraTimer } from './extra-timer/ExtraTimer';
|
||||
import AddTime from './add-time/AddTime';
|
||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
||||
|
||||
@@ -13,13 +14,15 @@ export default function PlaybackControl() {
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer playback={data.playback as Playback} />
|
||||
<PlaybackTimer playback={data.playback as Playback}>
|
||||
<AddTime playback={data.playback} />
|
||||
</PlaybackTimer>
|
||||
<PlaybackButtons
|
||||
playback={data.playback}
|
||||
numEvents={data.numEvents}
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
/>
|
||||
<ExtraTimer />
|
||||
<AuxTimer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.addTime {
|
||||
grid-area: addtime;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.addButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.tallButtons {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useLocalStorage } from '../../../../common/hooks/useLocalStorage';
|
||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
import style from './AddTime.module.scss';
|
||||
|
||||
interface AddTimeProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage('add-time', 300_000); // 5 minutes
|
||||
|
||||
const handleTimeChange = (_field: string, value: string) => {
|
||||
const newTime = forgivingStringToMillis(value);
|
||||
setTime(newTime);
|
||||
};
|
||||
|
||||
const handleAddTime = (direction: 'add' | 'remove') => {
|
||||
// API expects input in seconds
|
||||
if (direction === 'add') {
|
||||
setPlayback.addTime(time / MILLIS_PER_SECOND);
|
||||
} else {
|
||||
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND);
|
||||
}
|
||||
};
|
||||
|
||||
const canAddTime = playback === Playback.Play || playback === Playback.Pause;
|
||||
const doDisableButtons = !canAddTime || time === 0;
|
||||
|
||||
return (
|
||||
<div className={style.addTime}>
|
||||
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' />
|
||||
<div className={style.addButtons}>
|
||||
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
|
||||
<IoRemove />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add time' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<TapButton onClick={() => handleAddTime('add')} disabled={doDisableButtons} className={style.tallButtons}>
|
||||
<IoAdd />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-9
@@ -6,16 +6,16 @@ import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { setExtraTimer, useExtraTimerControl, useExtraTimerTime } from '../../../../common/hooks/useSocket';
|
||||
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
import style from './ExtraTimer.module.scss';
|
||||
import style from './AuxTimer.module.scss';
|
||||
|
||||
export function ExtraTimer() {
|
||||
const { playback, direction } = useExtraTimerControl();
|
||||
export function AuxTimer() {
|
||||
const { playback, direction } = useAuxTimerControl();
|
||||
|
||||
const { start, pause, stop, setDirection } = setExtraTimer;
|
||||
const { start, pause, stop, setDirection } = setAuxTimer;
|
||||
|
||||
const toggleDirection = () => {
|
||||
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
|
||||
@@ -30,7 +30,7 @@ export function ExtraTimer() {
|
||||
|
||||
return (
|
||||
<div className={style.extraRow}>
|
||||
<ExtraTimeInput />
|
||||
<AuxTimerInput />
|
||||
<TapButton onClick={toggleDirection} aspect='tight'>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
|
||||
@@ -59,9 +59,9 @@ export function ExtraTimer() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExtraTimeInput() {
|
||||
const time = useExtraTimerTime();
|
||||
const { setDuration } = setExtraTimer;
|
||||
function AuxTimerInput() {
|
||||
const time = useAuxTimerTime();
|
||||
const { setDuration } = setAuxTimer;
|
||||
|
||||
const handleTimeUpdate = (_field: string, value: string) => {
|
||||
const newTime = forgivingStringToMillis(value);
|
||||
+30
-55
@@ -1,16 +1,17 @@
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'ind clk clk btn'
|
||||
'... sta fin btn';
|
||||
'indicators timer addtime'
|
||||
'status status addtime';
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1.25rem 1fr 1fr 5rem;
|
||||
grid-template-columns: 1.25rem 1fr 6.5rem;
|
||||
gap: $element-inner-spacing;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
// ---------> INDICATORS
|
||||
|
||||
.indicators {
|
||||
grid-area: ind;
|
||||
grid-area: indicators;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -18,72 +19,46 @@
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.indRoll,
|
||||
.indDelay,
|
||||
.indNegative {
|
||||
.indicatorRoll,
|
||||
.indicatorDelay,
|
||||
.indicatorNegative {
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.indRoll,
|
||||
.indRollActive,
|
||||
.indDelay,
|
||||
.indDelayActive {
|
||||
.indicatorRoll,
|
||||
.indicatorDelay {
|
||||
margin: 0 auto;
|
||||
border-radius: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.indRollActive {
|
||||
.indicatorRoll[data-active='true'] {
|
||||
background-color: $ontime-roll;
|
||||
}
|
||||
|
||||
.indNegative,
|
||||
.indNegativeActive {
|
||||
margin: 0 auto;
|
||||
width: 90%;
|
||||
height: 0.25rem;
|
||||
}
|
||||
|
||||
.indNegativeActive {
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
|
||||
.indDelayActive {
|
||||
.indicatorDelay[data-active='true'] {
|
||||
background-color: $ontime-delay;
|
||||
}
|
||||
|
||||
.btn {
|
||||
grid-area: btn;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 100%;
|
||||
gap: $element-inner-spacing;
|
||||
.indicatorNegative {
|
||||
margin: 0 auto;
|
||||
width: 90%;
|
||||
height: 0.25rem;
|
||||
|
||||
&[data-active='true'] {
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
|
||||
.minus {
|
||||
grid-area: min;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
// ---------> LABELS
|
||||
|
||||
.start,
|
||||
.finish,
|
||||
.roll {
|
||||
.status {
|
||||
grid-area: status;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.start {
|
||||
grid-area: sta;
|
||||
}
|
||||
|
||||
.finish {
|
||||
grid-area: fin;
|
||||
}
|
||||
|
||||
.roll {
|
||||
grid-area: 2 / 2 / 2 / 4 ;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
||||
|
||||
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
import style from './PlaybackTimer.module.scss';
|
||||
@@ -13,8 +12,30 @@ interface PlaybackTimerProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const { playback } = props;
|
||||
function resolveAddedTimeLabel(addedTime: number) {
|
||||
function resolveClosestUnit(ms: number) {
|
||||
if (ms < 6000) {
|
||||
return `${millisToSeconds(ms)} seconds`;
|
||||
} else if (ms < 12000) {
|
||||
return '1 minute';
|
||||
} else {
|
||||
return `${millisToMinutes(ms)} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedTime > 0) {
|
||||
return `Added ${resolveClosestUnit(addedTime)}`;
|
||||
}
|
||||
|
||||
if (addedTime < 0) {
|
||||
return `Removed ${resolveClosestUnit(addedTime)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProps>) {
|
||||
const { playback, children } = props;
|
||||
const timer = useTimer();
|
||||
|
||||
const started = millisToString(timer.startedAt);
|
||||
@@ -22,88 +43,43 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isStopped = playback === Playback.Stop;
|
||||
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
|
||||
const disableButtons = isStopped || isRolling;
|
||||
const isOvertime = timer.current !== null && timer.current < 0;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
const resolveAddedTimeLabel = () => {
|
||||
function resolveClosestUnit(ms: number) {
|
||||
if (ms < 6000) {
|
||||
return `${millisToSeconds(ms)} seconds`;
|
||||
} else if (ms < 12000) {
|
||||
return '1 minute';
|
||||
} else {
|
||||
return `${millisToMinutes(ms)} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
if (timer.addedTime > 0) {
|
||||
return `Added ${resolveClosestUnit(timer.addedTime)}`;
|
||||
}
|
||||
|
||||
if (timer.addedTime < 0) {
|
||||
return `Removed ${resolveClosestUnit(timer.addedTime)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel();
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
return (
|
||||
<div className={style.timeContainer}>
|
||||
<div className={style.indicators}>
|
||||
<Tooltip label={rollLabel}>
|
||||
<div className={isRolling ? style.indRollActive : style.indRoll} />
|
||||
<div className={style.indicatorRoll} data-active={isRolling} />
|
||||
</Tooltip>
|
||||
<div className={isOvertime ? style.indNegativeActive : style.indNegative} />
|
||||
<div className={style.indicatorNegative} data-active={isOvertime} />
|
||||
<Tooltip label={addedTimeLabel}>
|
||||
<div className={hasAddedTime ? style.indDelayActive : style.indDelay} />
|
||||
<div className={style.indicatorDelay} data-active={hasAddedTime} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
{isWaiting ? (
|
||||
<div className={style.roll}>
|
||||
<div className={style.status}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</div>
|
||||
<div className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={style.btn}>
|
||||
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
|
||||
-1
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
|
||||
+1
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
|
||||
-5
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
|
||||
+5
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use '../../../../theme/viewerDefs' as *;
|
||||
|
||||
.timer {
|
||||
grid-area: clk;
|
||||
grid-area: timer;
|
||||
white-space: nowrap;
|
||||
max-width: 18.75rem;
|
||||
min-width: 5em;
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function CuesheetWrapper() {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue = event.custom[accessor]?.value;
|
||||
const previousValue = event.custom[accessor];
|
||||
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getHotkeyHandler } from '@mantine/hooks';
|
||||
|
||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
||||
|
||||
@@ -12,12 +13,24 @@ const EditableCell = (props: EditableCellProps) => {
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const ref = useRef<HTMLAreaElement>();
|
||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
||||
|
||||
// We'll only update the external data when the input is blurred
|
||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
||||
|
||||
//TODO: maybe we can unify this with `useReactiveTextInput`
|
||||
const onKeyDown = getHotkeyHandler([
|
||||
['mod + Enter', () => ref.current?.blur()],
|
||||
[
|
||||
'Escape',
|
||||
() => {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => ref.current?.blur());
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
@@ -27,9 +40,11 @@ const EditableCell = (props: EditableCellProps) => {
|
||||
<AutoTextArea
|
||||
size='sm'
|
||||
value={value}
|
||||
inputref={ref}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={1}
|
||||
onKeyDown={onKeyDown}
|
||||
transition='none'
|
||||
spellCheck={false}
|
||||
style={{ padding: 0 }}
|
||||
|
||||
@@ -49,7 +49,7 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
|
||||
}
|
||||
|
||||
// events dont necessarily contain all custom fields
|
||||
const initialValue = event.custom[column.id]?.value ?? '';
|
||||
const initialValue = event.custom[column.id] ?? '';
|
||||
|
||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
|
||||
// for custom fields, we need to extract the value from the custom object
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
const value = entry.custom[fieldLabel]?.value;
|
||||
const value = entry.custom[fieldLabel];
|
||||
row.push(parseField(fieldLabel, value));
|
||||
} else {
|
||||
// @ts-expect-error -- it is ok, we will just not have the data for other fields
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use './EditorMixin' as editor;
|
||||
|
||||
$min-playback-width: 27rem;
|
||||
$max-playback-width: 35rem;
|
||||
$max-playback-width: 30rem;
|
||||
$panel-gap: 0.5rem;
|
||||
|
||||
.corner {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, useCallback, useEffect } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
@@ -62,6 +63,15 @@ export default function Editor() {
|
||||
|
||||
useWindowTitle('Editor');
|
||||
|
||||
// listen to shutdown request from electron process
|
||||
useEffect(() => {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.on('user-request-shutdown', () => {
|
||||
setLocation('shutdown');
|
||||
});
|
||||
}
|
||||
}, [setLocation]);
|
||||
|
||||
return (
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
@@ -75,9 +85,9 @@ export default function Editor() {
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='ontime-subtle-white'
|
||||
variant={isSettingsOpen ? 'ontime-subtle' : 'ontime-subtle-white'}
|
||||
size='lg'
|
||||
icon={<IoSettingsOutline />}
|
||||
icon={isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
</EditorOverview>
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function Operator() {
|
||||
|
||||
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||
const secondaryField = getPropertyValue(entry, secondary) ?? '';
|
||||
const subscribedData = entry.custom[subscribe]?.value;
|
||||
const subscribedData = entry.custom[subscribe];
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
|
||||
@@ -116,7 +116,7 @@ function RuntimeOverview() {
|
||||
const { clock, offset } = useRuntimePlaybackOverview();
|
||||
|
||||
const offsetText = getOffsetText(offset);
|
||||
const offsetClasses = offset === null ? undefined : offset > 0 ? style.behind : style.ahead;
|
||||
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -38,10 +38,5 @@ export function getOffsetText(offset: MaybeNumber): string {
|
||||
if (offset === null) {
|
||||
return enDash;
|
||||
}
|
||||
const isAhead = offset <= 0;
|
||||
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
|
||||
if (offsetText !== enDash) {
|
||||
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
|
||||
}
|
||||
return offsetText;
|
||||
return millisToString(offset, { fallback: enDash });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
|
||||
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
@@ -27,10 +28,9 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const [statefulEntries, setStatefulEntries] = useState(order);
|
||||
|
||||
const featureData = useRundownEditor();
|
||||
const { addEvent, reorderEvent } = useEventAction();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const linkPrevious = eventSettings.linkPrevious;
|
||||
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
|
||||
|
||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||
|
||||
// cursor
|
||||
const { cursor, mode: appMode, setCursor } = useAppMode();
|
||||
@@ -41,9 +41,21 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const deleteAtCursor = useCallback(
|
||||
(cursor: string | null) => {
|
||||
if (!cursor) return;
|
||||
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
|
||||
deleteEvent(cursor);
|
||||
setCursor(previous);
|
||||
},
|
||||
[deleteEvent, order, rundown, setCursor],
|
||||
);
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(type: SupportedEvent | 'clone', cursor: string | null) => {
|
||||
if (cursor === null) {
|
||||
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => {
|
||||
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor;
|
||||
|
||||
if (adjustedCursor === null) {
|
||||
// we cant clone without selection
|
||||
if (type === 'clone') {
|
||||
return;
|
||||
@@ -54,7 +66,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
}
|
||||
|
||||
if (type === 'clone') {
|
||||
const cursorEvent = rundown[cursor];
|
||||
const cursorEvent = rundown[adjustedCursor];
|
||||
if (cursorEvent?.type === SupportedEvent.Event) {
|
||||
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
|
||||
addEvent(newEvent);
|
||||
@@ -64,113 +76,90 @@ export default function Rundown({ data }: RundownProps) {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
const options = {
|
||||
after: cursor,
|
||||
defaultPublic,
|
||||
lastEventId: cursor,
|
||||
linkPrevious,
|
||||
after: adjustedCursor,
|
||||
lastEventId: adjustedCursor,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
} else {
|
||||
addEvent({ type }, { after: cursor });
|
||||
addEvent({ type }, { after: adjustedCursor });
|
||||
}
|
||||
},
|
||||
[addEvent, rundown, defaultPublic, linkPrevious],
|
||||
[rundown, order, addEvent],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
const selectEntry = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
let newCursor: string | undefined;
|
||||
if (cursor === null) {
|
||||
// there is no cursor, we select the first or last depending on direction if it exists
|
||||
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id;
|
||||
} else {
|
||||
// otherwise we select the next or previous
|
||||
newCursor =
|
||||
direction === 'up'
|
||||
? getPreviousNormal(rundown, order, cursor).entry?.id
|
||||
: getNextNormal(rundown, order, cursor).entry?.id;
|
||||
}
|
||||
|
||||
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
|
||||
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
|
||||
|
||||
if (modKeysAlt) {
|
||||
switch (event.code) {
|
||||
case 'ArrowDown': {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
const nextEvent =
|
||||
cursor === null ? getFirstNormal(rundown, order) : getNextNormal(rundown, order, cursor)?.nextEvent;
|
||||
if (nextEvent) {
|
||||
setCursor(nextEvent.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
|
||||
const previousEvent =
|
||||
cursor === null
|
||||
? getFirstNormal(rundown, order)
|
||||
: getPreviousNormal(rundown, order, cursor).previousEvent;
|
||||
if (previousEvent) {
|
||||
setCursor(previousEvent.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'KeyE': {
|
||||
event.preventDefault();
|
||||
insertAtCursor(SupportedEvent.Event, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyD': {
|
||||
event.preventDefault();
|
||||
insertAtCursor(SupportedEvent.Delay, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyB': {
|
||||
event.preventDefault();
|
||||
insertAtCursor(SupportedEvent.Block, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyC': {
|
||||
event.preventDefault();
|
||||
insertAtCursor('clone', cursor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (modKeysCtrlAlt) {
|
||||
if (order.length < 2 || cursor == null) {
|
||||
return;
|
||||
}
|
||||
// Alt + Ctrl + Arrow Down
|
||||
if (event.code == 'ArrowDown') {
|
||||
const { nextEvent, nextIndex } = getNextNormal(rundown, order, cursor);
|
||||
if (nextEvent && nextIndex !== null) {
|
||||
reorderEvent(cursor, nextIndex - 1, nextIndex);
|
||||
}
|
||||
// Alt + Ctrl + Arrow Up
|
||||
} else if (event.code == 'ArrowUp') {
|
||||
const { previousEvent, previousIndex } = getPreviousNormal(rundown, order, cursor);
|
||||
if (previousEvent && previousIndex !== null) {
|
||||
reorderEvent(cursor, previousIndex + 1, previousIndex);
|
||||
}
|
||||
}
|
||||
if (newCursor) {
|
||||
setCursor(newCursor);
|
||||
}
|
||||
},
|
||||
[order, cursor, rundown, setCursor, insertAtCursor, reorderEvent],
|
||||
[order, rundown, setCursor],
|
||||
);
|
||||
|
||||
const moveEntry = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 2 || cursor == null) {
|
||||
return;
|
||||
}
|
||||
const { index } =
|
||||
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
||||
|
||||
if (index !== null) {
|
||||
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
|
||||
reorderEvent(cursor, offsetIndex, index);
|
||||
}
|
||||
},
|
||||
[order, reorderEvent, rundown],
|
||||
);
|
||||
|
||||
// shortcuts
|
||||
useHotkeys([
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
|
||||
|
||||
['Escape', () => setCursor(null), { preventDefault: true }],
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
|
||||
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
|
||||
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }],
|
||||
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }],
|
||||
|
||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
]);
|
||||
|
||||
// we copy the state from the store here
|
||||
// to workaround async updates on the drag mutations
|
||||
useEffect(() => {
|
||||
setStatefulEntries(order);
|
||||
}, [order]);
|
||||
|
||||
// listen to keys
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
@@ -61,10 +60,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const { selectedEvents, clearSelectedEvents } = useEventSelection();
|
||||
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const linkPrevious = eventSettings.linkPrevious;
|
||||
|
||||
const removeOpenEvent = useCallback(() => {
|
||||
if (selectedEvents.has(data.id)) {
|
||||
clearSelectedEvents();
|
||||
@@ -88,9 +83,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
after: data.id,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
}
|
||||
@@ -98,8 +91,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
after: previousEventId,
|
||||
defaultPublic,
|
||||
linkPrevious,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ $block-cursor-color: $orange-400;
|
||||
|
||||
@mixin block-styling() {
|
||||
box-sizing: content-box;
|
||||
border: 1px solid $white-10;
|
||||
font-family: $ontime-font-family;
|
||||
border: 1px solid $white-7;
|
||||
border-radius: $block-border-radius;
|
||||
margin: 0.25rem 0.125rem;
|
||||
margin-block: 0.25rem;
|
||||
margin-right: 0.125rem;
|
||||
position: relative;
|
||||
color: $block-text-color;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.block {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
margin-left: 0.5rem;
|
||||
|
||||
background-color: $block-bg2;
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useRef } from 'react';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
|
||||
import BlockDelete from './BlockDelete';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
@@ -19,6 +19,7 @@ interface BlockBlockProps {
|
||||
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { data, hasCursor, onDelete } = props;
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
@@ -45,14 +46,7 @@ export default function BlockBlock(props: BlockBlockProps) {
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
size='sm'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-subtle'
|
||||
color='#FA5656'
|
||||
onClick={onDelete}
|
||||
/>
|
||||
<BlockDelete onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
|
||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||
|
||||
interface BlockDeleteProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function BlockDelete(props: BlockDeleteProps) {
|
||||
const { onDelete } = props;
|
||||
const mode = useAppMode((state) => state.mode);
|
||||
|
||||
const isRunMode = mode === AppMode.Run;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
size='sm'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-subtle'
|
||||
color='#FA5656'
|
||||
onClick={onDelete}
|
||||
isDisabled={isRunMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -17,7 +17,7 @@ interface TitleEditorProps {
|
||||
export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
const { title, eventId, placeholder, className } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback(
|
||||
(text: string) => {
|
||||
if (text === title) {
|
||||
@@ -30,7 +30,7 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
[title, updateEvent, eventId],
|
||||
);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, {
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
|
||||
submitOnEnter: true,
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
<Input
|
||||
data-testid='block__title'
|
||||
variant='ontime-ghosted'
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={classes}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.delay {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
margin-left: 0.5rem;
|
||||
|
||||
background-color: $block-bg2;
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.decorated {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useEventSelection } from '../useEventSelection';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventTextArea from './composite/EventTextArea';
|
||||
import EventEditorEmpty from './EventEditorEmpty';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
|
||||
@@ -54,7 +55,7 @@ export default function EventEditor() {
|
||||
(field: EditorUpdateFields, value: string) => {
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
|
||||
updateEvent({ id: event?.id, custom: { [fieldLabel]: value } });
|
||||
} else {
|
||||
updateEvent({ id: event?.id, [field]: value });
|
||||
}
|
||||
@@ -63,15 +64,11 @@ export default function EventEditor() {
|
||||
);
|
||||
|
||||
const handleOpenCustomManager = () => {
|
||||
setSearchParams({ settings: 'project_settings__custom' });
|
||||
setSearchParams({ settings: 'feature_settings__custom' });
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
Select an event to edit
|
||||
</div>
|
||||
);
|
||||
return <EventEditorEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,17 +105,18 @@ export default function EventEditor() {
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(customFields).map((label) => {
|
||||
const key = `${event.id}-${label}`;
|
||||
const fieldName = `custom-${label}`;
|
||||
const initialValue = event.custom[label]?.value ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[label].colour);
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = event.custom[fieldKey] ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={label}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.eventEditor {
|
||||
color: $label-gray;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.shortcutSection {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
margin-top: 0.5rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 1rem 0;
|
||||
|
||||
tr {
|
||||
td:nth-child(odd) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { memo } from 'react';
|
||||
import { Kbd } from '@chakra-ui/react';
|
||||
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
|
||||
import style from './EventEditorEmpty.module.scss';
|
||||
|
||||
export default memo(EventEditorEmpty);
|
||||
|
||||
function EventEditorEmpty() {
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
<div className={style.prompt}>Select an event to edit</div>
|
||||
<div className={style.shortcutSection}>
|
||||
<div className={style.prompt}>Shortcut navigation</div>
|
||||
|
||||
<table className={style.shortcuts}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> / <Kbd>↓</Kbd>
|
||||
</td>
|
||||
<td>Select entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>{deviceMod}</Kbd> + <Kbd>↑</Kbd> / <Kbd>↑</Kbd>
|
||||
</td>
|
||||
<td>Reorder selected entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>E</Kbd>
|
||||
</td>
|
||||
<td>Add event below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>E</Kbd>
|
||||
</td>
|
||||
<td>Add event above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>B</Kbd>
|
||||
</td>
|
||||
<td>Add block below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>B</Kbd>
|
||||
</td>
|
||||
<td>Add block above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>D</Kbd>
|
||||
</td>
|
||||
<td>Add delay below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>D</Kbd>
|
||||
</td>
|
||||
<td>Add delay above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>Esc</Kbd>
|
||||
</td>
|
||||
<td>Deselect entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>⌫</Kbd>
|
||||
</td>
|
||||
<td>Delete selected entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>C</Kbd>
|
||||
</td>
|
||||
<td>Copy selected entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>↑</Kbd> + <Kbd>V</Kbd>
|
||||
</td>
|
||||
<td>Paste above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>V</Kbd>
|
||||
</td>
|
||||
<td>Paste below</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CSSProperties, useCallback, useRef } from 'react';
|
||||
|
||||
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -18,10 +18,12 @@ interface CountedTextAreaProps {
|
||||
|
||||
export default function EventTextArea(props: CountedTextAreaProps) {
|
||||
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
});
|
||||
const classes = cx([style.inputLabel, className]);
|
||||
|
||||
return (
|
||||
@@ -31,6 +33,7 @@ export default function EventTextArea(props: CountedTextAreaProps) {
|
||||
</label>
|
||||
<AutoTextArea
|
||||
id={field}
|
||||
inputref={ref}
|
||||
rows={1}
|
||||
size='sm'
|
||||
resize='none'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Input, InputProps } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -15,10 +15,10 @@ interface CountedTextInputProps extends InputProps {
|
||||
|
||||
export default function EventTextInput(props: CountedTextInputProps) {
|
||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnEnter: true,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
|
||||
</label>
|
||||
<Input
|
||||
id={field}
|
||||
ref={ref}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textfield'
|
||||
|
||||
@@ -72,6 +72,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Event
|
||||
</Button>
|
||||
@@ -81,6 +82,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Delay
|
||||
</Button>
|
||||
@@ -90,6 +92,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
@@ -12,22 +21,48 @@ export default function RundownMenu() {
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const { deleteAllEvents } = useEventAction();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
clearSelectedEvents();
|
||||
setCursor(null);
|
||||
}, [clearSelectedEvents, deleteAllEvents, setCursor]);
|
||||
onClose();
|
||||
}, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-outlined'
|
||||
leftIcon={<IoTrash />}
|
||||
onClick={deleteAll}
|
||||
color='#FA5656'
|
||||
isDisabled={appMode === 'run'}
|
||||
>
|
||||
Clear rundown
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-outlined'
|
||||
leftIcon={<IoTrash />}
|
||||
onClick={onOpen}
|
||||
color='#FA5656'
|
||||
isDisabled={appMode === 'run'}
|
||||
>
|
||||
Clear rundown
|
||||
</Button>
|
||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Clear rundown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>
|
||||
You will lose all data in your rundown. <br /> Are you sure?
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme='red' onClick={deleteAll} ml={4}>
|
||||
Delete all
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,9 @@ type WithDataProps = {
|
||||
external: Message;
|
||||
general: ProjectData;
|
||||
isMirrored: boolean;
|
||||
lower: Message;
|
||||
nextId: string | null;
|
||||
onAir: boolean;
|
||||
pres: TimerMessage;
|
||||
publ: Message;
|
||||
publicEventNext: OntimeEvent | null;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
publicSelectedId: string | null;
|
||||
@@ -106,11 +104,9 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
external={message.external}
|
||||
general={project}
|
||||
isMirrored={isMirrored}
|
||||
lower={message.lower}
|
||||
nextId={nextId}
|
||||
onAir={onAir}
|
||||
pres={message.timer}
|
||||
publ={message.public}
|
||||
publicEventNext={publicEventNext}
|
||||
publicEventNow={publicEventNow}
|
||||
publicSelectedId={publicSelectedId}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
' info info schedule';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
@@ -33,20 +33,7 @@
|
||||
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: message;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container,
|
||||
.public-container {
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
@@ -61,11 +48,6 @@
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - NOW ===================*/
|
||||
@@ -155,7 +137,6 @@
|
||||
}
|
||||
|
||||
.qr {
|
||||
margin-left: clamp(16px, 5vw, 64px);
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
@@ -174,4 +155,4 @@
|
||||
100% {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
@@ -28,7 +28,6 @@ import './Backstage.scss';
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
publ: Message;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
time: ViewExtendedTimer;
|
||||
@@ -43,7 +42,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
const {
|
||||
customFields,
|
||||
isMirrored,
|
||||
publ,
|
||||
eventNow,
|
||||
eventNext,
|
||||
time,
|
||||
@@ -84,7 +82,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
|
||||
const showPublicMessage = publ.text && publ.visible;
|
||||
const showProgress = time.playback !== 'stop';
|
||||
|
||||
const secondarySource = searchParams.get('secondary-src');
|
||||
@@ -173,11 +170,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
<Schedule isProduction className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
|
||||
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>{getLocalizedString('common.public_message')}</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
|
||||
{general.backstageInfo && <div className='info__message'>{general.backstageInfo}</div>}
|
||||
|
||||
@@ -41,16 +41,13 @@ export function isStringBoolean(text: string | null) {
|
||||
* Considers custom fields
|
||||
*/
|
||||
export function getPropertyValue(event: OntimeEvent | null, property: MaybeString): string | undefined {
|
||||
if (!event) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof property !== 'string') {
|
||||
if (!event || typeof property !== 'string' || property === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (property.startsWith('custom-')) {
|
||||
const field = property.split('custom-')[1];
|
||||
return event.custom?.[field]?.value;
|
||||
return event.custom?.[field];
|
||||
}
|
||||
|
||||
return event[property as keyof OntimeEvent] as string;
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
||||
text-align: var(--lowerThird-text-align-override, left);
|
||||
white-space: nowrap;
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
.data-top {
|
||||
@@ -86,7 +89,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes in {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, Message, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
|
||||
@@ -11,14 +11,8 @@ import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import './LowerThird.scss';
|
||||
|
||||
enum TriggerType {
|
||||
Event = 'event',
|
||||
Manual = 'manual',
|
||||
}
|
||||
|
||||
type LowerOptions = {
|
||||
width: number;
|
||||
trigger: TriggerType;
|
||||
topSrc: string;
|
||||
bottomSrc: string;
|
||||
topColour: string;
|
||||
@@ -38,12 +32,10 @@ interface LowerProps {
|
||||
customFields: CustomFields;
|
||||
eventNow: OntimeEvent | null;
|
||||
viewSettings: ViewSettings;
|
||||
lower: Message;
|
||||
}
|
||||
|
||||
const defaultOptions: Readonly<LowerOptions> = {
|
||||
width: 45,
|
||||
trigger: TriggerType.Event,
|
||||
topSrc: 'title',
|
||||
bottomSrc: 'lowerMsg',
|
||||
topColour: '000000ff',
|
||||
@@ -60,8 +52,14 @@ const defaultOptions: Readonly<LowerOptions> = {
|
||||
};
|
||||
|
||||
export default function LowerThird(props: LowerProps) {
|
||||
const { customFields, eventNow, lower, viewSettings } = props;
|
||||
const { customFields, eventNow, viewSettings } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const previousId = useRef<string>();
|
||||
const animationTimeout = useRef<NodeJS.Timeout>();
|
||||
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
|
||||
useWindowTitle('Lower Third');
|
||||
|
||||
const options = useMemo(() => {
|
||||
const newOptions = { ...defaultOptions };
|
||||
@@ -71,11 +69,6 @@ export default function LowerThird(props: LowerProps) {
|
||||
newOptions.width = Number(width);
|
||||
}
|
||||
|
||||
const trigger = Object.values(TriggerType).find((s) => s === searchParams.get('trigger'));
|
||||
if (trigger) {
|
||||
newOptions.trigger = trigger;
|
||||
}
|
||||
|
||||
const topSrc = searchParams.get('top-src');
|
||||
if (topSrc) {
|
||||
newOptions.topSrc = topSrc;
|
||||
@@ -139,50 +132,51 @@ export default function LowerThird(props: LowerProps) {
|
||||
return newOptions;
|
||||
}, [searchParams]);
|
||||
|
||||
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
|
||||
useWindowTitle('Lower Third');
|
||||
|
||||
const trigger = useMemo(() => {
|
||||
if (options.trigger === TriggerType.Event) {
|
||||
return eventNow?.id;
|
||||
} else if (options.trigger === TriggerType.Manual) {
|
||||
return lower.visible;
|
||||
}
|
||||
return false;
|
||||
}, [eventNow?.id, lower.visible, options.trigger]);
|
||||
|
||||
// coordinate load-unload of lower third
|
||||
// on unmount, cancel any ongoing animations
|
||||
useEffect(() => {
|
||||
if (options.trigger === TriggerType.Event && trigger) {
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
clearTimeout(animationTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// check if data has changed and schedule animations
|
||||
useEffect(() => {
|
||||
const hasChanged = eventNow?.id !== previousId.current;
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousId.current = eventNow?.id;
|
||||
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
|
||||
|
||||
const reschedule = (newState: 'pre' | 'in' | 'out') => {
|
||||
clearTimeout(animationTimeout.current);
|
||||
animationTimeout.current = setTimeout(() => setPlayState(newState), animateOutInMs);
|
||||
};
|
||||
if (eventNow?.id == null) {
|
||||
setPlayState('out');
|
||||
reschedule('pre');
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventNow.id && !previousId.current) {
|
||||
setPlayState('in');
|
||||
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
|
||||
const timeout = setTimeout(() => {
|
||||
setPlayState('out');
|
||||
}, animateOutInMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else if (options.trigger === TriggerType.Manual) {
|
||||
setPlayState(trigger ? 'in' : 'out');
|
||||
} else {
|
||||
setPlayState('pre');
|
||||
reschedule('out');
|
||||
return;
|
||||
}
|
||||
return () => null;
|
||||
}, [options.delay, options.transition, options.trigger, trigger]);
|
||||
|
||||
const topText = useMemo(() => {
|
||||
if (options.topSrc === 'lowerMsg') {
|
||||
return lower.text;
|
||||
if (playState === 'in') {
|
||||
// event has changed, we just reschedule the timeout
|
||||
reschedule('out');
|
||||
return;
|
||||
}
|
||||
return getPropertyValue(eventNow, options.topSrc) ?? '';
|
||||
}, [eventNow, lower.text, options]);
|
||||
setPlayState('in');
|
||||
reschedule('out');
|
||||
}, [eventNow?.id, options.delay, options.transition, playState, previousId]);
|
||||
|
||||
const bottomText = useMemo(() => {
|
||||
if (options.bottomSrc === 'lowerMsg') {
|
||||
return lower.text;
|
||||
}
|
||||
return getPropertyValue(eventNow, options.bottomSrc) ?? '';
|
||||
}, [eventNow, lower.text, options]);
|
||||
const topText = getPropertyValue(eventNow, options.topSrc) ?? '';
|
||||
const bottomText = getPropertyValue(eventNow, options.bottomSrc) ?? '';
|
||||
|
||||
const transition = `${options.transition}s`;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
' info info schedule';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
@@ -33,20 +33,7 @@
|
||||
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: message;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container,
|
||||
.public-container {
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
@@ -60,11 +47,6 @@
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - NOW ===================*/
|
||||
@@ -100,7 +82,6 @@
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
align-self: flex-end;
|
||||
overflow: hidden;
|
||||
|
||||
&__message {
|
||||
@@ -112,7 +93,6 @@
|
||||
}
|
||||
|
||||
.qr {
|
||||
margin-left: clamp(16px, 5vw, 64px);;
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
@@ -24,7 +24,6 @@ import './Public.scss';
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
publ: Message;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
publicEventNext: OntimeEvent | null;
|
||||
time: ViewExtendedTimer;
|
||||
@@ -39,7 +38,6 @@ export default function Public(props: BackstageProps) {
|
||||
const {
|
||||
customFields,
|
||||
isMirrored,
|
||||
publ,
|
||||
publicEventNow,
|
||||
publicEventNext,
|
||||
time,
|
||||
@@ -61,7 +59,6 @@ export default function Public(props: BackstageProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showPublicMessage = publ.text && publ.visible;
|
||||
const clock = formatTime(time.clock);
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
|
||||
@@ -120,11 +117,6 @@ export default function Public(props: BackstageProps) {
|
||||
<Schedule className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
|
||||
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>{getLocalizedString('common.public_message')}</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
|
||||
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
@use './theme/ontimeStyles' as *;
|
||||
|
||||
/* open-sans-latin-300-normal */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
/* open-sans-latin-400-normal */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
/* open-sans-latin-600-normal */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
$track-color: $white-1;
|
||||
$thumb-color: $gray-1100;
|
||||
$thumb-color-hover: $gray-900;
|
||||
|
||||
@@ -10,3 +10,11 @@ export const ontimeAlertOnDark = {
|
||||
color: '#578AF4', // $blue-500
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeDialog = {
|
||||
container: {
|
||||
backgroundColor: '#1a1a1a', // $gray-1300
|
||||
color: '#e2e2e2', // $gray-200
|
||||
borderRadius: '3px',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ontimeInputGhosted = {
|
||||
backgroundColor: 'transparent',
|
||||
color: '#f6f6f6', // $gray-50
|
||||
_hover: {
|
||||
backgroundColor: 'transparent', // $gray-1100
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #2B5ABC', // $blue-500
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { extendTheme } from '@chakra-ui/react';
|
||||
|
||||
import { ontimeAlertOnDark } from './OntimeAlert';
|
||||
import { ontimeAlertOnDark, ontimeDialog } from './OntimeAlert';
|
||||
import {
|
||||
ontimeButtonFilled,
|
||||
ontimeButtonGhosted,
|
||||
@@ -35,6 +35,11 @@ const theme = extendTheme({
|
||||
'ontime-on-dark-info': { ...ontimeAlertOnDark },
|
||||
},
|
||||
},
|
||||
AlertDialog: {
|
||||
variants: {
|
||||
ontime: { ...ontimeDialog },
|
||||
},
|
||||
},
|
||||
Button: {
|
||||
baseStyle: {
|
||||
letterSpacing: '0.3px',
|
||||
@@ -75,6 +80,14 @@ const theme = extendTheme({
|
||||
'ontime-ghosted': { ...ontimeInputGhosted },
|
||||
},
|
||||
},
|
||||
Kbd: {
|
||||
baseStyle: {
|
||||
borderRadius: '2px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.20)',
|
||||
background: 'rgba(255, 255, 255, 0.10)',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
Menu: {
|
||||
variants: {
|
||||
'ontime-on-dark': { ...ontimeMenuOnDark },
|
||||
|
||||
@@ -52,6 +52,9 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
legalComments: 'none',
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
@@ -3,8 +3,8 @@ module.exports = {
|
||||
shutdownCode: 99,
|
||||
},
|
||||
reactAppUrl: {
|
||||
development: (port = 4001) => `http://localhost:${port}/editor`,
|
||||
production: (port = 4001) => `http://localhost:${port}/editor`,
|
||||
development: (port = 4001) => `http://localhost:${port}`,
|
||||
production: (port = 4001) => `http://localhost:${port}`,
|
||||
},
|
||||
server: {
|
||||
pathToEntrypoint: '../extraResources/server/index.cjs',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
||||
const path = require('path');
|
||||
const electronConfig = require('./electron.config');
|
||||
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
||||
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const isProduction = env === 'production';
|
||||
@@ -162,8 +163,12 @@ app.whenReady().then(() => {
|
||||
? electronConfig.reactAppUrl.production(port)
|
||||
: electronConfig.reactAppUrl.development(port);
|
||||
|
||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl);
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
win
|
||||
.loadURL(clientUrl)
|
||||
.loadURL(`${clientUrl}/editor`)
|
||||
.then(() => {
|
||||
win.webContents.setBackgroundThrottling(false);
|
||||
|
||||
@@ -208,12 +213,10 @@ app.whenReady().then(() => {
|
||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
|
||||
|
||||
});
|
||||
|
||||
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
||||
const template = getApplicationMenu(isMac, askToQuit);
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
// unregister shortcuts before quitting
|
||||
app.once('will-quit', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-prerelease",
|
||||
"version": "3.0.0-beta.1",
|
||||
"version": "3.0.0-beta.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -4,7 +4,7 @@ const { shell } = require('electron');
|
||||
* @param {boolean} isMac - Whether the target platform is mac
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
*/
|
||||
function getApplicationMenu(isMac, askToQuit) {
|
||||
function getApplicationMenu(isMac, askToQuit, urlBase) {
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
@@ -18,7 +18,7 @@ function getApplicationMenu(isMac, askToQuit) {
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'quit',
|
||||
label: 'Quit',
|
||||
click: () => askToQuit(),
|
||||
accelerator: 'Cmd+Q',
|
||||
},
|
||||
@@ -63,68 +63,68 @@ function getApplicationMenu(isMac, askToQuit) {
|
||||
label: 'Timer',
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/timer');
|
||||
await shell.openExternal(`${urlBase}/timer`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/clock');
|
||||
await shell.openExternal(`${urlBase}/clock`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Minimal Timer',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/minimal');
|
||||
await shell.openExternal(`${urlBase}/minimal`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Backstage',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/backstage');
|
||||
await shell.openExternal(`${urlBase}/backstage`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Public',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/public');
|
||||
await shell.openExternal(`${urlBase}/public`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Lower Thirds',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/lower');
|
||||
await shell.openExternal(`${urlBase}/lower`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Studio Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/studio');
|
||||
await shell.openExternal(`${urlBase}/studio`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Countdown',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/countdown');
|
||||
await shell.openExternal(`${urlBase}/countdown`);
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Editor',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/editor');
|
||||
await shell.openExternal(`${urlBase}/editor`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cuesheet',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/cuesheet');
|
||||
await shell.openExternal(`${urlBase}/cuesheet`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Operator',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/operator');
|
||||
await shell.openExternal(`${urlBase}/operator`);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.0.0-beta.1",
|
||||
"version": "3.0.0-beta.3",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -120,7 +120,7 @@ export class SocketServer implements IAdapter {
|
||||
if (reply) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'ontime-change',
|
||||
type,
|
||||
payload: reply.payload,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getSettings(_req: Request, res: Response<Settings>) {
|
||||
}
|
||||
|
||||
if (settings.operatorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
obfuscatedSettings.operatorKey = obfuscate(settings.operatorKey);
|
||||
}
|
||||
|
||||
res.status(200).send(obfuscatedSettings);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
|
||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||
import { extraTimerService } from '../services/extra-timer-service/ExtraTimerService.js';
|
||||
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
|
||||
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
||||
@@ -46,7 +46,8 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
if (typeof property !== 'string' || value === undefined) {
|
||||
throw new Error('Invalid property or value');
|
||||
}
|
||||
const newObjectProperty = parseProperty(property, value);
|
||||
// all custom fields keys are lowercase
|
||||
const newObjectProperty = parseProperty(property.toLowerCase(), value);
|
||||
|
||||
if (patchEvent.custom && newObjectProperty.custom) {
|
||||
Object.assign(patchEvent.custom, newObjectProperty.custom);
|
||||
@@ -56,7 +57,6 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
});
|
||||
|
||||
updateEvent(patchEvent);
|
||||
Object.assign(patchEvent);
|
||||
|
||||
return { payload: 'success' };
|
||||
},
|
||||
@@ -66,8 +66,6 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
|
||||
const patch: DeepPartial<MessageState> = {
|
||||
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
|
||||
public: 'public' in payload ? validateMessage(payload.public) : undefined,
|
||||
lower: 'lower' in payload ? validateMessage(payload.lower) : undefined,
|
||||
external: 'external' in payload ? validateMessage(payload.external) : undefined,
|
||||
};
|
||||
|
||||
@@ -77,7 +75,7 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
/* Playback */
|
||||
start: (payload) => {
|
||||
if (payload === undefined) {
|
||||
return successPayloadOrError(runtimeService.start(), 'Uable to start');
|
||||
return successPayloadOrError(runtimeService.start(), 'Unable to start');
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
@@ -191,26 +189,26 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
const command = payload['1'];
|
||||
if (typeof command === 'string') {
|
||||
if (command === SimplePlayback.Start) {
|
||||
const reply = extraTimerService.start();
|
||||
const reply = auxTimerService.start();
|
||||
return { payload: reply };
|
||||
}
|
||||
if (command === SimplePlayback.Pause) {
|
||||
const reply = extraTimerService.pause();
|
||||
const reply = auxTimerService.pause();
|
||||
return { payload: reply };
|
||||
}
|
||||
if (command === SimplePlayback.Stop) {
|
||||
const reply = extraTimerService.stop();
|
||||
const reply = auxTimerService.stop();
|
||||
return { payload: reply };
|
||||
}
|
||||
} else if (command && typeof command === 'object') {
|
||||
const reply = { payload: {} };
|
||||
if ('duration' in command) {
|
||||
const time = numberOrError(command.duration);
|
||||
reply.payload = extraTimerService.setTime(time * 1000); //frontend is seconds based
|
||||
reply.payload = auxTimerService.setTime(time * 1000); //frontend is seconds based
|
||||
}
|
||||
if ('direction' in command) {
|
||||
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
|
||||
reply.payload = extraTimerService.setDirection(command.direction);
|
||||
reply.payload = auxTimerService.setDirection(command.direction);
|
||||
} else {
|
||||
throw new Error('Invalid direction payload');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { ErrorResponse, LogOrigin, RuntimeStore } from 'ontime-types';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
@@ -14,7 +14,6 @@ import { integrationPayloadFromPath } from '../adapters/utils/parse.js';
|
||||
|
||||
import { dispatchFromAdapter } from './integration.controller.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { isEmptyObject } from '../utils/parserUtils.js';
|
||||
|
||||
export const integrationRouter = express.Router();
|
||||
@@ -52,13 +51,3 @@ integrationRouter.get('/*', (req: Request, res: Response) => {
|
||||
res.status(500).send({ message: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
integrationRouter.get('/poll', (_req: Request, res: Response<Partial<RuntimeStore> | ErrorResponse>) => {
|
||||
try {
|
||||
const state = eventStore.poll();
|
||||
res.status(200).send(state);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message: `Could not get sync data: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const whitelistedPayload = {
|
||||
note: coerceString,
|
||||
cue: coerceString,
|
||||
|
||||
duration: (value: unknown) => coerceNumber(value) * 1000, //frontend is seconds based
|
||||
duration: (value: unknown) => coerceNumber(value) * 1000,
|
||||
|
||||
isPublic: coerceBoolean,
|
||||
skip: coerceBoolean,
|
||||
@@ -27,7 +27,7 @@ export function parseProperty(property: string, value: unknown) {
|
||||
throw new Error(`Custom field ${customKey} not found`);
|
||||
}
|
||||
const parserFn = whitelistedPayload.custom;
|
||||
return { custom: { [customKey]: { value: parserFn(value) } } };
|
||||
return { custom: { [customKey]: parserFn(value) } };
|
||||
}
|
||||
|
||||
if (!isKeyOfType(property, whitelistedPayload)) {
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { OntimeEvent, Playback, RuntimeStore } from 'ontime-types';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import * as runtimeState from '../stores/runtimeState.js';
|
||||
import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js';
|
||||
|
||||
import { restoreService } from './RestoreService.js';
|
||||
import type { UpdateResult } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
* Service manages Ontime's main timer
|
||||
* It is responsible for streaming the data to the event store
|
||||
*/
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timer;
|
||||
/** how often we update the socket */
|
||||
static _updateInterval: number;
|
||||
/** how often we recalculate */
|
||||
static _refreshInterval: number;
|
||||
/** last time we updated the socket */
|
||||
static previousUpdate: number;
|
||||
/** last known state */
|
||||
static previousState: RuntimeState;
|
||||
|
||||
/** when timer will be finished */
|
||||
private endCallback: NodeJS.Timer;
|
||||
@@ -39,10 +27,6 @@ export class TimerService {
|
||||
updateInterval: number;
|
||||
onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
}) {
|
||||
TimerService.previousUpdate = -1;
|
||||
TimerService.previousState = {} as RuntimeState;
|
||||
|
||||
TimerService._updateInterval = timerConfig.updateInterval;
|
||||
TimerService._refreshInterval = timerConfig.refresh;
|
||||
|
||||
this.onUpdateCallback = timerConfig.onUpdateCallback;
|
||||
@@ -51,7 +35,6 @@ export class TimerService {
|
||||
}, TimerService._refreshInterval);
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
start() {
|
||||
if (!runtimeState.start()) {
|
||||
return false;
|
||||
@@ -63,7 +46,6 @@ export class TimerService {
|
||||
return true;
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
pause() {
|
||||
if (!runtimeState.pause()) {
|
||||
return false;
|
||||
@@ -74,7 +56,6 @@ export class TimerService {
|
||||
return true;
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
stop() {
|
||||
if (!runtimeState.stop()) {
|
||||
return false;
|
||||
@@ -89,7 +70,6 @@ export class TimerService {
|
||||
* Adds time to running timer by given amount
|
||||
* @param {number} amount
|
||||
*/
|
||||
@broadcastResult
|
||||
addTime(amount: number): boolean {
|
||||
if (!runtimeState.addTime(amount)) {
|
||||
return false;
|
||||
@@ -105,7 +85,6 @@ export class TimerService {
|
||||
/**
|
||||
* Update the app at regular intervals
|
||||
*/
|
||||
@broadcastResult
|
||||
update() {
|
||||
const updateResult = runtimeState.update();
|
||||
// pass the result to the parent
|
||||
@@ -116,7 +95,6 @@ export class TimerService {
|
||||
* Loads roll information into timer service
|
||||
* @param {OntimeEvent[]} rundown -- list of events to run
|
||||
*/
|
||||
@broadcastResult
|
||||
roll(rundown: OntimeEvent[]) {
|
||||
runtimeState.roll(rundown);
|
||||
}
|
||||
@@ -126,99 +104,3 @@ export class TimerService {
|
||||
clearTimeout(this.endCallback);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
// call the original method and get the state
|
||||
const result = originalMethod.apply(this, args);
|
||||
const state = runtimeState.getState();
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// some of the data, we only update at intervals
|
||||
const isTimeToUpdate =
|
||||
state.clock < TimerService.previousUpdate ||
|
||||
state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
|
||||
|
||||
// some changes need an immediate update
|
||||
const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id;
|
||||
|
||||
const hasSkippedBack = state.clock < TimerService.previousUpdate;
|
||||
const justStarted = !TimerService.previousState?.timer;
|
||||
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
|
||||
const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback;
|
||||
|
||||
if (hasChangedPlayback) {
|
||||
eventStore.set('onAir', state.timer.playback !== Playback.Stop);
|
||||
}
|
||||
|
||||
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
|
||||
eventStore.set('timer', state.timer);
|
||||
TimerService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime))) {
|
||||
eventStore.set('runtime', state.runtime);
|
||||
TimerService.previousState.runtime = { ...state.runtime };
|
||||
}
|
||||
|
||||
// Update the events if they have changed
|
||||
updateEventIfChanged('eventNow', state);
|
||||
updateEventIfChanged('publicEventNow', state);
|
||||
updateEventIfChanged('eventNext', state);
|
||||
updateEventIfChanged('publicEventNext', state);
|
||||
|
||||
if (isTimeToUpdate) {
|
||||
TimerService.previousUpdate = state.clock;
|
||||
eventStore.set('clock', state.clock);
|
||||
saveRestoreState(state);
|
||||
}
|
||||
|
||||
// Helper function to update an event if it has changed
|
||||
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
|
||||
const previous = TimerService.previousState?.[eventKey];
|
||||
const now = state[eventKey];
|
||||
|
||||
// if there was nothing, and there is nothing, noop
|
||||
if (!previous?.id && !now?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if load status changed, save new
|
||||
if (previous?.id !== now?.id) {
|
||||
storeKey(eventKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// maybe the event itself has changed
|
||||
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
|
||||
storeKey(eventKey);
|
||||
return;
|
||||
}
|
||||
|
||||
function storeKey(eventKey: keyof RuntimeStore) {
|
||||
eventStore.set(eventKey, state[eventKey]);
|
||||
TimerService.previousState[eventKey] = { ...state[eventKey] };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to save the restore state
|
||||
function saveRestoreState(state: RuntimeState) {
|
||||
restoreService.save({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
startedAt: state.timer.startedAt,
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
firstStart: state.runtime.actualStart,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@@ -1401,7 +1401,7 @@ describe('updateRoll()', () => {
|
||||
});
|
||||
|
||||
describe('getRuntimeOffset()', () => {
|
||||
it('calculates the difference between schedule and actual start', () => {
|
||||
it('is the difference between scheduled and when we actually started', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
@@ -1409,7 +1409,7 @@ describe('getRuntimeOffset()', () => {
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
addedTime: 10,
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
_timer: {
|
||||
@@ -1421,10 +1421,33 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(60);
|
||||
expect(offset).toBe(-50);
|
||||
});
|
||||
|
||||
it('adds the overtime time of the current timer', () => {
|
||||
it('added time adds time ahead (positive offset)', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 100,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150, // we started 50ms delayed
|
||||
addedTime: 10, // we compensated with 10ms
|
||||
current: 10, // we are 10ms into the timer
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 150,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(-40);
|
||||
});
|
||||
|
||||
it('considers running overtime (negative offset)', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
@@ -1432,9 +1455,9 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 140,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 100,
|
||||
current: -10,
|
||||
addedTime: 0,
|
||||
startedAt: 100, // we started ontime
|
||||
current: -10, // we are 10 seconds over
|
||||
addedTime: 0, // we have not compensated with added time
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
@@ -1445,10 +1468,10 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(10);
|
||||
expect(offset).toBe(-10);
|
||||
});
|
||||
|
||||
it('accounts for paused time', () => {
|
||||
it('paused time is delayed time (negative offset)', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
@@ -1457,12 +1480,12 @@ describe('getRuntimeOffset()', () => {
|
||||
},
|
||||
clock: 150,
|
||||
timer: {
|
||||
startedAt: 100,
|
||||
current: 25,
|
||||
startedAt: 100, // started on time
|
||||
current: 25, // are 25ms into it
|
||||
addedTime: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 125,
|
||||
pausedAt: 125, // we have been paused for 25ms (see clock)
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 100,
|
||||
@@ -1470,33 +1493,19 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(25);
|
||||
expect(offset).toBe(-25);
|
||||
});
|
||||
|
||||
it('can only count once started', () => {
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
type: 'event',
|
||||
title: '',
|
||||
timeStart: 77400000,
|
||||
timeEnd: 81000000,
|
||||
duration: 3600000,
|
||||
timeStrategy: 'lock-duration',
|
||||
linkStart: null,
|
||||
endAction: 'none',
|
||||
timerType: 'count-down',
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
colour: '',
|
||||
cue: '1',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
delay: 0,
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
@@ -1530,8 +1539,6 @@ describe('getRuntimeOffset()', () => {
|
||||
clock: 79521653,
|
||||
eventNow: {
|
||||
id: '835242',
|
||||
type: 'event',
|
||||
title: '',
|
||||
timeStart: 81000000,
|
||||
timeEnd: 84600000,
|
||||
duration: 3600000,
|
||||
@@ -1539,15 +1546,6 @@ describe('getRuntimeOffset()', () => {
|
||||
linkStart: null,
|
||||
endAction: 'none',
|
||||
timerType: 'count-down',
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
colour: '',
|
||||
cue: '2',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
delay: 0,
|
||||
},
|
||||
runtime: {
|
||||
@@ -1574,10 +1572,10 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(79521653 - 81000000);
|
||||
expect(offset).toBe(81000000 - 79521653); // clock - timestart
|
||||
});
|
||||
|
||||
it('handles time-to-end', () => {
|
||||
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20
|
||||
eventNow: {
|
||||
@@ -1629,7 +1627,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('handles time-to-end with delays', () => {
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
eventNow: {
|
||||
@@ -1686,25 +1684,13 @@ describe('getRuntimeOffset()', () => {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
type: 'event',
|
||||
title: '',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.TimeToEnd,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
colour: '',
|
||||
cue: '1',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
delay: 0,
|
||||
timerType: TimerType.TimeToEnd, // <--- but this is time to end
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { eventStore } from '../../stores/EventStore.js';
|
||||
export type EmitFn = (state: SimpleTimerState) => void;
|
||||
export type GetTimeFn = () => number;
|
||||
|
||||
export class ExtraTimerService {
|
||||
export class AuxTimerService {
|
||||
private timer: SimpleTimer;
|
||||
private interval: NodeJS.Timer | null = null;
|
||||
private emit: EmitFn;
|
||||
@@ -76,4 +76,4 @@ function broadcastReturn(_target: any, _propertyKey: string, descriptor: Propert
|
||||
const emit = (state: SimpleTimerState) => eventStore.set('auxtimer1', state);
|
||||
const timeNow = () => Date.now();
|
||||
|
||||
export const extraTimerService = new ExtraTimerService(emit, timeNow);
|
||||
export const auxTimerService = new AuxTimerService(emit, timeNow);
|
||||
@@ -8,8 +8,6 @@ let instance: MessageService | null = null;
|
||||
|
||||
class MessageService {
|
||||
timer: TimerMessage;
|
||||
public: Message;
|
||||
lower: Message;
|
||||
external: Message;
|
||||
|
||||
private throttledSet: PublishFn;
|
||||
@@ -38,16 +36,6 @@ class MessageService {
|
||||
blackout: false,
|
||||
};
|
||||
|
||||
this.public = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.lower = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.external = {
|
||||
text: '',
|
||||
visible: false,
|
||||
@@ -62,16 +50,12 @@ class MessageService {
|
||||
getState(): MessageState {
|
||||
return {
|
||||
timer: this.timer,
|
||||
public: this.public,
|
||||
lower: this.lower,
|
||||
external: this.external,
|
||||
};
|
||||
}
|
||||
|
||||
patch(message: DeepPartial<MessageState>) {
|
||||
if (message.timer) this.timer = { ...this.timer, ...message.timer };
|
||||
if (message.public) this.public = { ...this.public, ...message.public };
|
||||
if (message.lower) this.lower = { ...this.lower, ...message.lower };
|
||||
if (message.external) this.external = { ...this.external, ...message.external };
|
||||
|
||||
const newState = this.getState();
|
||||
|
||||
@@ -14,8 +14,6 @@ describe('MessageService', () => {
|
||||
it('should patch the message state', () => {
|
||||
const message = {
|
||||
timer: { text: 'new text', visible: true },
|
||||
public: { text: 'public text', visible: false },
|
||||
lower: { text: 'lower text' },
|
||||
external: { visible: true },
|
||||
};
|
||||
|
||||
@@ -23,11 +21,6 @@ describe('MessageService', () => {
|
||||
|
||||
expect(newState).toEqual({
|
||||
timer: { text: 'new text', visible: true, blackout: false, blink: false },
|
||||
public: { text: 'public text', visible: false },
|
||||
lower: {
|
||||
text: 'lower text',
|
||||
visible: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: true,
|
||||
@@ -38,18 +31,12 @@ describe('MessageService', () => {
|
||||
it('should not affect other properties when patching', () => {
|
||||
const initialMessage = {
|
||||
timer: { text: 'initial text', visible: true },
|
||||
public: { text: 'public text', visible: false },
|
||||
};
|
||||
|
||||
const newState = messageService.patch(initialMessage);
|
||||
|
||||
expect(newState).toEqual({
|
||||
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
|
||||
public: { text: 'public text', visible: false },
|
||||
lower: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user