Compare commits

..

58 Commits

Author SHA1 Message Date
Carlos Valente bc036bdae7 refactor: forviging test
Allow a scenario where the operation execution happens in the next millisecond
2024-05-12 20:15:59 +02:00
Carlos Valente 266a84e5d3 chore: update keywords 2024-05-12 20:15:59 +02:00
Carlos Valente 92c3148e49 chore: bump version to 3.0.1 2024-05-12 20:15:59 +02:00
Carlos Valente efd05bcc34 refactor: simplify overview logic 2024-05-12 17:16:33 +02:00
Carlos Valente 3c7e061e51 refactor: improve detection of day after
fix: prevent mutation of initial object

refactor: download project file

Update apps/client/src/features/rundown/event-block/EventBlock.utils.ts

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

Update apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2024-05-12 17:16:33 +02:00
Carlos Valente 07074260a4 refactor: improve integration updates 2024-05-12 17:03:13 +02:00
Carlos Valente c57b0423af refactor: leading updates in timers 2024-05-12 17:03:13 +02:00
Carlos Valente 52145d5e1e chore: update element order 2024-05-11 14:58:28 +02:00
Alex Christoffer Rasmussen b2668e61a5 refactor: improve logging from http integration (#946) 2024-05-11 14:23:07 +02:00
Carlos Valente 7da3480dfe style: improve readability of shortcuts 2024-05-11 14:22:29 +02:00
Carlos Valente e8cfdbe3b9 refactor: simplify tests 2024-05-11 14:22:29 +02:00
Carlos Valente fd052355c3 style: uniform button size 2024-05-11 14:22:29 +02:00
Carlos Valente 00f04fb78c refactor: download project file 2024-05-10 22:35:12 +02:00
Carlos Valente 6f80de58ca fix: prevent mutation of initial object 2024-05-10 21:59:01 +02:00
Carlos Valente 5977266332 chore: update readme 2024-05-07 21:34:49 +02:00
Carlos Valente a40fa0dbf8 chore: version bump 2024-05-07 21:00:42 +02:00
arc-alex e92f59ac55 await and display errors 2024-05-07 15:49:07 +02:00
Carlos Valente 077c67e912 chore: sign macos dist 2024-05-07 12:42:47 +02:00
Alex Christoffer Rasmussen 9644cbde92 fix: demo page, update docs link and fix data destructuring (#932) 2024-05-02 12:17:08 +02:00
Charalambos Makridakis 8b0e03e980 #914 Refactor: Luxon-Depenency, formatFromMillis() (#924) 2024-04-29 23:20:12 +02:00
Carlos Valente 0966659596 chore: version bump 2024-04-29 22:44:24 +02:00
Carlos Valente 1123e29499 fix: explicit rundown order in tests 2024-04-29 22:41:02 +02:00
Carlos Valente 3d2248cceb style: remove unnecessary padding 2024-04-29 22:41:02 +02:00
Carlos Valente 043ba38916 chore: update typeface 2024-04-29 22:41:02 +02:00
Carlos Valente 5b6d4c5b28 style: toggle settings button 2024-04-29 22:41:02 +02:00
Carlos Valente 07e8bedff4 style: text formatting in menu 2024-04-29 22:41:02 +02:00
Carlos Valente 6e61a7ecf9 refactor: runtime side effects 2024-04-29 22:40:39 +02:00
Alex Christoffer Rasmussen accdf2c396 Fix: custom fields with undefined colour is not accepted by parseCustomFields (#925) 2024-04-29 22:33:58 +02:00
Alex Christoffer Rasmussen 5e39c9b51f Feat: export custom fileds to sheets (#926)
* export custom keys to sheets

* use `:` in place of `-`
2024-04-29 15:15:18 +02:00
Carlos Valente b52fb0f9ee chore: update automation scripts 2024-04-28 22:11:42 +02:00
Carlos Valente 54f30e69a1 chore: version bump 2024-04-27 22:39:04 +02:00
Carlos Valente 189cee0104 fix: state stale in integrations 2024-04-27 22:34:08 +02:00
Alex Christoffer Rasmussen 9e04a1dcfa shortcuts using useHotkeys from mantine/hooks (#907)
---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-04-27 18:00:41 +02:00
Carlos Valente 0c3c08ac98 refactor: custom fields casing 2024-04-27 15:36:29 +02:00
Carlos Valente 95b437e18c refactor(build): remove legal 2024-04-27 10:18:10 +02:00
Carlos Valente 75b5118b0f refactor: user alerts on destructive actions 2024-04-27 10:18:10 +02:00
Carlos Valente dcbb92c470 style: prevent overflow on defaults 2024-04-26 19:40:21 +02:00
Carlos Valente 8da8a08464 refactor: invert offset 2024-04-26 19:39:05 +02:00
Carlos Valente 8b5034efbf refactor: added time is time ahead 2024-04-26 19:39:05 +02:00
Alex Christoffer Rasmussen 74418ce17e Link start times from excel import (#903)
* add test for linking start time in excel import

* link ids

* add link to UI

* ensure times with cachedRundown

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-04-26 18:09:59 +02:00
Carlos Valente d8a8f0ad92 style: use user given capitalisation 2024-04-26 15:05:17 +02:00
Carlos Valente 817ebcdd26 Event defaults (#910)
* chore: cleanup outdated comment

* style: align blocks

* style: use ui-black background

* chore: clarify docs

* feat: event defaults

* chore: cleanup
2024-04-24 20:13:51 +02:00
Carlos Valente c4f51192de chore: test custom field renaming 2024-04-23 22:05:56 +02:00
Carlos Valente 5cf5b2fae3 chore: cleanup 2024-04-23 22:05:56 +02:00
Carlos Valente 8f249b9d51 refactor: rundown must be in order 2024-04-19 20:49:37 +02:00
Carlos Valente bef69e7d0f fix: obfuscation logic for operator pin 2024-04-19 20:38:01 +02:00
Carlos Valente dd81b92584 refactor: simplify l3 2024-04-19 20:38:01 +02:00
Alex Christoffer Rasmussen b14ac07568 excel import leading and trailing whitespace (#902)
* add test for leading and trailing whitespace

* trim whitespace
2024-04-19 20:36:06 +02:00
Carlos Valente c4136a0ec7 refactor: simplify custom field shape 2024-04-19 12:06:19 +02:00
Carlos Valente cf14b0b427 refactor: consolidate language of AuxTimer (#896) 2024-04-17 21:31:10 +02:00
Carlos Valente 16a169b22c Beta 2 review (#897)
* style: subdue secondary buttons

* refactor: remove public message
2024-04-17 21:25:03 +02:00
Carlos Valente 8af5feb9af Delete block (#895) 2024-04-17 21:24:27 +02:00
Carlos Valente 88b45fd952 fix: shutdown from menu (#893) 2024-04-16 21:21:01 +02:00
jwetzell a8e9962eed make application menu aware of what port should be used in link items (#890) 2024-04-16 21:20:41 +02:00
Carlos Valente 70c93a69fe Custom add (#889) 2024-04-14 21:52:12 +02:00
Carlos Valente 0ef1b44d9e chore: update README 2024-04-14 12:55:03 +02:00
cv 84839824fb fix: link to managing custom fields 2024-04-14 12:55:03 +02:00
Carlos Valente b63e3ed8f3 chore: update readme (#888)
* chore: update readme

* docs: funding
2024-04-13 11:30:51 +02:00
159 changed files with 2884 additions and 1950 deletions
+2
View File
@@ -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:
@@ -31,7 +30,14 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac
timeout-minutes: 60
- name: Release
uses: softprops/action-gh-release@v1
@@ -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
-108
View File
@@ -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:
-106
View File
@@ -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
+98 -129
View File
@@ -1,5 +1,8 @@
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
## Download the latest releases here
@@ -10,196 +13,162 @@
<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
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
### For live environments
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
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.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/editor.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/ontime-overview.webp)
[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
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or
`Ontime can't be opened because it is from an unidentified developer`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
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 Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `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,
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
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
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 />
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
# Help
## Links
Help is underway! ... and can be found [here](https://docs.getontime.no)
See the [Ontime website](https://getontime.no) here and the link to the [documentation](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
View File
@@ -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.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.0-beta.1",
"version": "3.0.1",
"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"
},
-30
View File
@@ -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}>
+4 -4
View File
@@ -11,15 +11,15 @@ const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
return axios.get(`${dbPath}/download`);
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { fileName });
}
/**
* Request download of the current project file
* @param fileName
*/
export async function downloadProject(fileName: string = 'ontime-project') {
export async function downloadProject(fileName: string) {
try {
const { data, name } = await fileDownload(fileName);
@@ -152,7 +152,7 @@ export async function getInfo(): Promise<GetInfo> {
* @returns
*/
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb();
const response = await getDb(fileName);
const headerLine = response.headers['Content-Disposition'];
@@ -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',
+16 -4
View File
@@ -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],
);
+3 -9
View File
@@ -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 }),
}));
+54 -41
View File
@@ -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 }),
}));
-8
View File
@@ -23,14 +23,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
blink: false,
blackout: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
@@ -1,3 +1,5 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
@@ -6,22 +8,22 @@ describe('test forgivingStringToMillis()', () => {
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '1', expect: MILLIS_PER_MINUTE },
{ value: '-1', expect: MILLIS_PER_MINUTE },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: 1000 },
{ value: '0h1m0s', expect: 1000 * 60 },
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '2m', expect: 2 * 60 * 1000 },
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
{ value: '0h0m1s', expect: MILLIS_PER_SECOND },
{ value: '0h1m0s', expect: MILLIS_PER_MINUTE },
{ value: '1h0m0s', expect: MILLIS_PER_HOUR },
{ value: '23h0m0s', expect: 23 * MILLIS_PER_HOUR },
{ value: '12h12m12s', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '12H12M12S', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '2m', expect: 2 * MILLIS_PER_MINUTE },
{ value: '1h5s', expect: MILLIS_PER_HOUR + 5 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value} to left`, () => {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
@@ -30,10 +32,10 @@ describe('test forgivingStringToMillis()', () => {
describe('parses strings correctly', () => {
const ts = [
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
{ value: '1.1.1', expect: MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.1.1', expect: 12 * MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.1', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.40', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + 40 * MILLIS_PER_SECOND },
];
for (const s of ts) {
@@ -45,15 +47,15 @@ describe('test forgivingStringToMillis()', () => {
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
{ value: '10m', expect: 10 * 60 * 1000 },
{ value: '10s', expect: 10 * 1000 },
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
{ value: '120m', expect: 120 * 60 * 1000 },
{ value: '120s', expect: 120 * 1000 },
{ value: '1h2m3s', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '1h3s', expect: MILLIS_PER_HOUR + 3 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '10h', expect: 10 * MILLIS_PER_HOUR },
{ value: '10m', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10s', expect: 10 * MILLIS_PER_SECOND },
{ value: '120h', expect: 120 * MILLIS_PER_HOUR },
{ value: '120m', expect: 120 * MILLIS_PER_MINUTE },
{ value: '120s', expect: 120 * MILLIS_PER_SECOND },
];
for (const s of ts) {
@@ -65,20 +67,20 @@ describe('test forgivingStringToMillis()', () => {
describe('handles am/pm', () => {
const ampm = [
{ value: '9:10:11am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '9:10:11am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9am', expect: 9 * MILLIS_PER_HOUR },
{ value: '9a', expect: 9 * MILLIS_PER_HOUR },
{ value: '9pm', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '9p', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '12am', expect: 0 },
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
{ value: '12pm', expect: 12 * MILLIS_PER_HOUR },
];
for (const s of ampm) {
@@ -90,12 +92,12 @@ describe('test forgivingStringToMillis()', () => {
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: 1000 * 60 }, // 00:01:00
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 12:34:56
{ value: '1', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '12', expect: 12 * MILLIS_PER_MINUTE }, // 00:12:00
{ value: '123', expect: MILLIS_PER_HOUR + 23 * MILLIS_PER_MINUTE }, // 01:23:00
{ value: '1234', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE }, // 12:34:00
{ value: '12345', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 5 * MILLIS_PER_SECOND }, // 12:34:05
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND }, // 12:34:56
];
for (const s of testCases) {
@@ -105,12 +107,12 @@ describe('test forgivingStringToMillis()', () => {
}
const sixDigits = [
{ value: '000000', expect: 0 },
{ value: '000001', expect: 1000 },
{ value: '000100', expect: 1000 * 60 },
{ value: '010000', expect: 1000 * 60 * 60 },
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '000000', expect: 0 }, // 00:00:00
{ value: '000001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '000100', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230000', expect: MILLIS_PER_HOUR * 23 }, // 23:00:00
{ value: '121212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND }, // 12:12:12
];
for (const s of sixDigits) {
@@ -121,13 +123,13 @@ describe('test forgivingStringToMillis()', () => {
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: 1000 }, // 00:00:01
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:01
{ value: '00001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '00010', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '00100', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '01000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '10000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '23000', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '12121', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 1 * MILLIS_PER_SECOND }, // 12:12:01
];
for (const s of fiveDigits) {
@@ -138,12 +140,12 @@ describe('test forgivingStringToMillis()', () => {
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: 1000 * 60 }, // 00:01:00
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:00
{ value: '0001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '0010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '0100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '1000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '2300', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '1212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE }, // 12:12:00
];
for (const s of fourDigits) {
@@ -154,11 +156,11 @@ describe('test forgivingStringToMillis()', () => {
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: 1000 * 60 }, // 00:01:00
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 01:21:00
{ value: '001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230', expect: 2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE }, // 02:30:00
{ value: '121', expect: MILLIS_PER_HOUR + 21 * MILLIS_PER_MINUTE }, // 01:21:00
];
for (const s of threeDigits) {
@@ -169,9 +171,9 @@ describe('test forgivingStringToMillis()', () => {
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: 1000 * 60 }, // 00:01:00
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '23', expect: 1000 * 60 * 23 }, // 00:23:00
{ value: '01', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '10', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '23', expect: 23 * MILLIS_PER_MINUTE }, // 00:23:00
];
for (const s of twoDigits) {
@@ -192,18 +194,17 @@ describe('test forgivingStringToMillis()', () => {
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '2.0.0', expect: 1000 * 60 * 120 },
{ value: '99', expect: 1000 * 60 * 99 },
{ value: '1.39.0', expect: 1000 * 60 * 99 },
{ value: '99', expect: 99 * MILLIS_PER_MINUTE },
{ value: '1.39.0', expect: 99 * MILLIS_PER_MINUTE },
// seconds overflow
{ value: '0.0.120', expect: 120 * 1000 },
{ value: '0.2.0', expect: 120 * 1000 },
{ value: '0.0.99', expect: 99 * 1000 },
{ value: '0.1.39', expect: 99 * 1000 },
{ value: '0.0.120', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.2.0', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.0.99', expect: 99 * MILLIS_PER_SECOND },
{ value: '0.1.39', expect: 99 * MILLIS_PER_SECOND },
// hours overflow
{ value: '25.0.0', expect: 1000 * 60 * 60 * 25 },
{ value: '25.0.0', expect: 25 * MILLIS_PER_HOUR },
// hours overflow
{ value: '50.0.0', expect: 1000 * 60 * 60 * 50 },
{ value: '50.0.0', expect: 50 * MILLIS_PER_HOUR },
];
for (const s of ts) {
@@ -216,10 +217,10 @@ describe('test forgivingStringToMillis()', () => {
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: 3723000 },
{ value: '2,10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
{ value: '2.10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
{ value: '2 10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
{ value: '1:2:3:10', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND }, // 01:02:03
{ value: '2,10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2.10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2 10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
@@ -232,8 +233,8 @@ describe('test forgivingStringToMillis()', () => {
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
{ value: '1.70', expect: 60 * 60 * 1000 + 70 * 60 * 1000 },
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.70', expect: MILLIS_PER_HOUR + 70 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
@@ -246,8 +247,8 @@ describe('test forgivingStringToMillis()', () => {
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * 60 * 1000 },
{ value: '0.99', expect: 99 * 60 * 1000 },
{ value: '0.120', expect: 120 * MILLIS_PER_MINUTE },
{ value: '0.99', expect: 99 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
@@ -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;
}
@@ -93,10 +93,10 @@ export default function UrlPresetsForm() {
<Panel.SubHeader>
URL presets
<div className={style.actionButtons}>
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
@@ -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}
@@ -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
@@ -169,7 +169,7 @@ function ActionMenu({
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem>
{current && <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>}
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={handleDelete}>
Delete
</MenuItem>
@@ -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>
</>
);
@@ -33,6 +33,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const authenticationError = useSheetStore((state) => state.stepData.authenticate.error);
/** Check if we are authenticated */
const getAuthStatus = async () => {
@@ -44,6 +45,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
/** check if the current session has been authenticated */
useEffect(() => {
patchStepData({ authenticate: { available: false, error: '' } });
untilAuthenticated();
}, []);
@@ -150,7 +152,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
</Panel.Title>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Panel.Error>{authenticationError}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
@@ -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({
@@ -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;
}
}
@@ -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;
}
@@ -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>;
})}
@@ -25,9 +25,9 @@ export default function useGoogleSheet() {
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
/** we do not handle errors here */
return await verifyAuthenticationStatus();
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
@@ -37,18 +37,18 @@ export default function useGoogleSheet() {
sheetId: string,
): Promise<{ verification_url: string; user_code: string } | void> => {
try {
return requestConnection(file, sheetId);
} catch (_error) {
/** we do not handle errors here */
return await requestConnection(file, sheetId);
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
/** requests the revoking of an existing authenticated session */
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return revokeAuthentication();
} catch (_error) {
/** we do not handle errors here */
return await revokeAuthentication();
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
@@ -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>
);
}
@@ -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);
@@ -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 {
+12 -2
View File
@@ -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
@@ -95,7 +95,7 @@ function TitlesOverview() {
}
function TimerOverview() {
const {current} = useTimer();
const { current } = useTimer();
const display = millisToString(current);
@@ -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 (
<>
@@ -0,0 +1,22 @@
import { dayInMs } from 'ontime-utils';
import { calculateEndAndDaySpan } from '../overviewUtils';
describe('calculateEndAndDaySpan', () => {
it('should return [null, 0] when end is null', () => {
const result = calculateEndAndDaySpan(null);
expect(result).toEqual([null, 0]);
});
it('should return [end, 0] when end is less than or equal to dayInMs', () => {
const end = dayInMs / 2;
const result = calculateEndAndDaySpan(end);
expect(result).toEqual([end, 0]);
});
it('should return [end % dayInMs, Math.floor(end / dayInMs)] when end is greater than dayInMs', () => {
const end = dayInMs * 1.5;
const result = calculateEndAndDaySpan(end);
expect(result).toEqual([end % dayInMs, Math.floor(end / dayInMs)]);
});
});
@@ -13,20 +13,14 @@ export function formatedTime(time: MaybeNumber) {
}
/**
* Calculates a day span from a number range
* @param end
* @returns
* Calculates how long a time is and how many days it spans
*/
export function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
let maybeEnd = end;
let maybeDaySpan = 0;
if (end !== null) {
if (end > dayInMs) {
maybeEnd = end % dayInMs;
maybeDaySpan = Math.floor(end / dayInMs);
}
if (end !== null && end > dayInMs) {
return [end % dayInMs, Math.floor(end / dayInMs)];
}
return [maybeEnd, maybeDaySpan];
return [end, 0];
}
/**
@@ -38,10 +32,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 });
}
+87 -98
View File
@@ -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;
@@ -242,13 +242,7 @@ export default function EventBlock(props: EventBlockProps) {
onContextMenu={onContextMenu}
id='event-block'
>
<RundownIndicators
timeStart={timeStart}
timeEnd={timeEnd}
previousStart={previousStart}
previousEnd={previousEnd}
delay={delay}
/>
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
@@ -10,24 +10,41 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
return `New start ${timeTag}`;
}
/**
* Utility function checks whether a given event is the day after from its predecessor
* We consider an event to be the day after, if it begins before the start of the previous
* @example day after
* // 09:00 - 10:00
* // 08:00 - 10:30 <--- day after
* @example same day
* // 09:00 - 10:00
* // 09:30 - 10:30 <--- same day
*/
function checkIsNextDay(previousStart: number, timeStart: number): boolean {
return timeStart < previousStart;
}
export function formatOverlap(
previousStart: MaybeNumber,
previousEnd: MaybeNumber,
timeStart: number,
timeEnd: number,
): string | undefined {
if (previousEnd === null) return;
const noPreviousElement = previousEnd === null || previousStart === null;
if (noPreviousElement) return;
const overlap = previousEnd - timeStart;
if (overlap === 0) return;
if (previousStart && timeStart < previousEnd) {
const overlap = timeEnd - previousStart;
if (overlap > 0) {
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `Overlap ${overlapString}`;
}
const gap = timeStart + dayInMs - previousEnd;
const previousCrossMidnight = previousStart > previousEnd;
const isNextDay = previousCrossMidnight
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight
: checkIsNextDay(previousStart, timeStart);
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
if (isNextDay) {
const gap = dayInMs - correctedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
return `Gap ${gapString} (next day)`;
}
@@ -6,16 +6,15 @@ import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
timeEnd: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
delay: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, timeEnd, previousStart, previousEnd, delay } = props;
const { timeStart, previousStart, previousEnd, delay } = props;
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart);
const hasDelay = formatDelay(timeStart, delay);
return (
@@ -1,3 +1,5 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { formatDelay, formatOverlap } from '../EventBlock.utils';
describe('formatDelay()', () => {
@@ -14,35 +16,47 @@ describe('formatOverlap()', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const timeEnd = 90000; // 1:30 min
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 0:30');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 5:00');
});
it('handles events the day after, without overlap', () => {
const previousStart = new Date(0).setUTCHours(11);
const previousEnd = new Date(0).setUTCHours(12);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(10);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 18:00:00 (next day)');
});
it('handles events the day after, with overlap', () => {
const previousStart = new Date(0).setUTCHours(9);
const previousEnd = new Date(0).setUTCHours(10);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Overlap 02:00:00');
});
it('handles events the day after, with gap', () => {
const previousStart = new Date(0).setUTCHours(17);
const previousEnd = new Date(0).setUTCHours(23);
const timeStart = new Date(0).setUTCHours(9);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 10:00:00 (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00 (next day)');
});
it('handles events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00');
});
});
@@ -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,40 @@
.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;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
@@ -0,0 +1,144 @@
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>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
<td>Select entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
<td>Reorder selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
<td>Add event below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
<td>Add event above</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
<td>Add block below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
<td>Add block above</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
<td>Add delay below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>+</AuxKey>
<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>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
</td>
<td>Delete selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>C</Kbd>
</td>
<td>Copy selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
<td>Paste above</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
<td>Paste below</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
function AuxKey({ children }: { children: React.ReactNode }) {
return <span className={style.divider}>{children}</span>;
}
@@ -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>}
+31
View File
@@ -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;
+8
View File
@@ -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',
},
};
+1 -1
View File
@@ -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
},
},
+18 -1
View File
@@ -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,18 @@ const theme = extendTheme({
'ontime-ghosted': { ...ontimeInputGhosted },
},
},
Kbd: {
baseStyle: {
borderRadius: '2px',
border: 'none',
background: 'rgba(255, 255, 255, 0.10)',
padding: '0.125rem 0.5rem',
color: '#f6f6f6', // $ui-white
fontWeight: 400,
boxShadow: '0px 0px 3px 0px rgba(0,0,0,0.4)',
fontSize: 'calc(1rem - 2px)',
},
},
Menu: {
variants: {
'ontime-on-dark': { ...ontimeMenuOnDark },
+3
View File
@@ -52,6 +52,9 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
esbuild: {
legalComments: 'none',
},
css: {
preprocessorOptions: {
scss: {
+2 -2
View File
@@ -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',
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+8 -5
View File
@@ -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', () => {
+15 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-prerelease",
"version": "3.0.0-beta.1",
"name": "ontime",
"version": "3.0.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -13,7 +13,7 @@
"main": "main.js",
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"electron-builder": "^24.13.3",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
@@ -23,20 +23,27 @@
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"build": {
"productName": "ontime-prerelease",
"appId": "no.lightdev.ontime.prerelease",
"productName": "ontime",
"appId": "no.lightdev.ontime",
"asar": true,
"dmg": {
"artifactName": "ontime-macOS-${arch}.dmg",
"icon": "icon.icns"
},
"mac": {
"notarize": {
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "./entitlements.plist",
"entitlementsInherit": "./entitlements.plist",
"target": {
"target": "dmg",
"arch": [

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