Compare commits

...

112 Commits

Author SHA1 Message Date
Carlos Valente 79a24a134e Hotfix/6.0.2 (#94)
* hotfix/6.0.2 fix issue where events could not have an ID
* hotfix/6.0.2 version bump
* hotfix/6.0.2 testing
2022-01-14 22:11:33 +01:00
Carlos Valente acb5d8ae08 chore/update-deps update socket.io (#93) 2022-01-14 21:00:51 +01:00
cv 5ca8202368 version bump 2022-01-14 20:49:24 +01:00
Carlos Valente ba782d5d22 hotfix/92-osc timer is absolute (#92)
Fixes issues with approximating timers in the app views
2022-01-14 20:47:39 +01:00
Carlos Valente 48093b8651 Ux/54 help (#88)
* ux/54-help tooltips
* ux/54-help refetch after mutation
* ux/54-help broadcast event index
* ux/54-help prevent multiple keypresses when key held
* ux/54-help fat clock
* ux/54-help progress bar user defined
* ux/54-help onAir is button
* ux/54-help OSC: add missing presenter message
* ux/54-help OSC: enable / disable OSC
* ux/54-help handle timezone in excel date import
* ux/54-help tray left click to show app
* ux/54-help create queriable endpoint
* ux/54-help fix memoisation in lower thirds
* ux/54-help revise icons
* ux/54-help clarify text
* ux/54-help forgiving text parsing
* ux/54-help add smart keywords
* ux/54-help version bump
2022-01-12 22:41:12 +01:00
Carlos Valente c3f18feaae Fix/82 import (#83)
* fix/82-import fix import extension filter
* fix/82-import version bump
2022-01-05 22:15:21 +01:00
Carlos Valente 2fa397548a Feat/navigate (#81)
* feat/navigate upgrade react router
* feat/navigate migrate to react router 6
* feat/navigate style modal
* feat/navigate create endpoints for app settings
* feat/navigate protect editor with pin
* feat/navigate apply dynamic routing draft
* feat/navigate upgrade relevant packages
* feat/navigate restructure directory and add tests
* feat/navigate test aliases validation
* feat/navigate validate aliases before sending
* feat/navigate create data endpoint
* feat/navigate config: prettier
* feat/navigate invalidate empty strings
* feat/navigate create endpoints
* feat/navigate parse on import
* feat/navigate navigate to alias
* feat/navigate user help and sample data
* feat/navigate refact aliases modal
* feat/navigate refact settings style
* feat/navigate update sample db
* feat/navigate link is relative to hostname
* feat/navigate navigate to first match
* feat/navigate update readme and version bump
* feat/navigate config: create shared module
* feat/navigate config: cheat module install
* feat/navigate fix tests
* feat/navigate run tests in pull request
* Update ontime_cy.yml
2022-01-04 22:12:59 +01:00
xztraz dd43d5e20a added studio clock (#80) 2022-01-03 22:24:24 +01:00
Carlos Valente 9fc154955a Feat/62 logger (#79)
* feat/62-logger request log data in app
* feat/62-logger refact osc integration
* feat/62-logger feedback on osc
* feat/62-logger refact broadcast on triggers
* feat/62-logger log triggers
* feat/62-logger add link to studio
* feat/62-logger replace toasts with logger context
* feat/62-logger style improvements
* feat/62-logger refactor code duplications
* feat/62-logger cleanup and version bump
2021-12-25 19:39:40 +01:00
Carlos Valente 160ccabebc Config/eslint (#78)
* config/eslint: upgrade eslint config
* config/eslint: version bump
2021-12-22 22:22:21 +01:00
Carlos Valente 4974898050 version bump 2021-12-22 18:19:35 +01:00
Carlos Valente ac0d5832b6 event cucle (#76)
* install sass
* refact: integration settings
- OSC in its own HTTP endpoint
- OSC settings have own object in db
* refact: simplify event cycle
* refact: restructure external triggers http
* refact: restructure external triggers osc+socket
* refact: restructure data updates
* feat: osc integration class
* IO improvements
- timer uses osc integration
- create trigger handler to manage external triggers
* refact: refract state machine update
* Integration: simple HTTP Client
* Integration: http options in datamodel
* Integration: call http send on life cycle
* feat/62-logging: fix issue #71
2021-12-22 18:17:48 +01:00
Carlos Valente 2b2bafa8c6 add mock object (#75) 2021-12-19 16:36:11 +01:00
Carlos Valente fad34eeab2 add mock object (#74) 2021-12-19 14:30:14 +01:00
Carlos Valente c12bca05aa Chore/tests (#73)
* chore/tests: install testing libraries
* chore/tests: upgrade dependencies
* chore/tests: configure linters
* chore/tests: add basic component tests
* chore/tests: version bump
2021-12-19 14:22:02 +01:00
Carlos Valente 1e4206fcb2 Refract/updatecritical (#69) 2021-12-18 14:57:07 +01:00
Carlos Valente 2751ed3d33 fix/58-countdown
correct issues on countdown clock (#68)
2021-12-16 22:13:41 +01:00
Carlos Valente cd911aaaa2 Fix/58 roll issues (#67)
* fix/58-roll-issues: handle edge cases
2021-12-15 22:31:34 +01:00
cv 1321aa555e Revert "hotfix: add missing font"
This reverts commit 28c9cb0864.
2021-12-15 20:48:43 +01:00
cv 12e4eaef67 Merge branch 'master' of https://github.com/cpvalente/ontime 2021-12-15 20:47:07 +01:00
cv 28c9cb0864 hotfix: add missing font 2021-12-15 20:47:05 +01:00
Carlos Valente afdfae9073 Hotfix/4.0.3 (#66)
* hotfix 4.0.3: style tweaks
* hotfix 4.0.3: replace font
2021-12-15 20:43:21 +01:00
Carlos Valente 0736ce93c6 Hotfix/4.0.3 (#65)
* hotfix 4.0.3: style tweaks
* hotfix 4.0.3: replace font
2021-12-14 23:19:38 +01:00
Carlos Valente 52f02f153c Hotfix/4.0.2 (#64)
* hotfix 4.0.2: implement playback router
* hotfix 4.0.2: fix studio clock styles
* hotfix 4.0.2: broadcast clock always
* hotfix 4.0.2: fix overflow in timer control
2021-12-14 22:39:01 +01:00
Carlos Valente 3582ce18e1 Feat/studio clock (#61)
* Studio Clock: broadcast change from integration
2021-12-13 21:50:44 +01:00
Carlos Valente 80c2e9a8ba Feat/studio clock (#60) 2021-12-13 21:29:56 +01:00
Carlos Valente 627cce7ba6 Hotfix/4.0.0 (#48)
* validate time string
* handle time entered in seconds
* fix: id in delays and blocks
2021-11-27 20:35:06 +01:00
Carlos Valente 8c740b7111 Feat/excel (#45)
* add dependency

adding dependency to parse the excel file in the node side

* import file

ability to import file
- add extension
- safeguard file type and size

* user is able to upload json or excel

* chore: extract logic into self contained function

* associate model with version

* test id generation

* parse excel data

* prevent UI crash on bad data

* extract validation into self contained function

* chore: add more tests for bad data

* config: stop from opening browser on start

* fix comma in sample db

* handle corrupt files

* error boundary around main components

* jsdocs

* increase id length

* parse excel time string

* feat: excel parsing

* validate json db before import

* chore: test event validator

* feat: make function to clean convert strings

* chore: test parser

* chore: jsdocs

* fix: bug on upload

prevent bug where the component would prevent upload of same file twice
2021-11-24 22:07:31 +01:00
cv bf8170a947 version bump 2021-11-22 22:53:39 +01:00
Carlos Valente eb2abb7026 fix: bug in osc goto (#44) 2021-11-22 22:48:55 +01:00
Carlos Valente f3320efc5c Fix/ios (#43)
* remove tiny keys

* fix: replace library with code

* cleanup

* add lodash throttle

add dependency to handle event throttling

* Revert "add lodash throttle"

This reverts commit abcbd3556b.
2021-11-17 22:50:54 +01:00
cv 17d5c84133 version bump 2021-11-16 21:02:37 +01:00
Carlos Valente 09a5aca25a Chore/qol (#41)
* style: prevent timer from resizing container

* style: review half and third screens

* style: prevent delay time from resizing blocks

* style: show now / next titles when container is collapsed

* docs: link to documentation in readme

* Update README.md

* Update README.md

* Update README.md

* style: new image assets

* cleanup

* style: prevent long titles from overflowing containers

* fix: send titles on change

fixed issue that prevented the next title from being broadcast on change
2021-11-16 20:54:36 +01:00
Carlos Valente 885d965db1 Fix/roll (#39)
* roll locks to first

in case there are events with overlapping time, roll will play the one that started first

* sanitize data entry

fixes issue where input field would not return seconds if not modified

* update chakra

* fix time input in chrome

fix issue in chrome where value of input does not contain seconds
2021-11-14 21:13:47 +01:00
Carlos Valente 26cb8a5bfc Feat/seconds (#38)
* change directory structure

move test file to __tests__ at the module directory

* test function

Add more tests to ensure the function remains safe

* fix coverage filter

* change directory structure

move to utilities folder

* refract formatDisplay with tst

* test millis

* test millis to seconds

* refract millis functions

* refractor timeStringToMillis

* create utility consts

* style component

* fixed bug with hidezero parameter

* fix issue with cancelling input edit

* style tweak
2021-11-13 22:39:57 +01:00
cv 8520d08927 version bump 2021-11-09 22:33:05 +01:00
Carlos Valente 8d8bbef546 small QOL improvements (#37)
* feat: selected event if is first

* style: fix wrap

- remove wrap tag (broken in firefox)

* feat: add more text to notes

this is a quick fix and does not replace the real deal.
using a text input makes it difficult to edit, this should be revised with a textarea on note field

* upgrade electron

* Stage timer in /sm has placeholder when not running

* fix style issue where clock would move depending on whether there are events selected

* ensure ontime logo and text have a minimum readable size

* fix scrollbar styling

* clean debugging logs

* link to latest release

using gitreleases.dev service for this.
this is a temporary fix until we have a release (in opposition to pre-release) so we can use githubs latest link

* scale gracefully

* fixed error with naming of maxChar
2021-11-09 22:31:55 +01:00
Carlos Valente 207612e3b8 Feat/osc feedback (#35)
* Add roll function call to OSC

* send finished message

* request presenter

* handling badly formatted messages

* loadeventbyindex

add step so that we can pause the titles when loading a new event via an API call (eg. OSC)

* broadcast title data

* handle empty title fields

* send finished message in roll mode

* calling stop is same as calling unload
2021-11-08 22:15:18 +01:00
Carlos Valente 98e5cddad5 Fix/roll titles (#33)
Roll is now calculated with awareness of public and backstage titles
Style improvements on providing feedback on roll mode
2021-11-07 13:13:06 +01:00
Carlos Valente f6edb88944 Fixed issues with style on small windows (#29) 2021-11-04 21:12:53 +01:00
cv 3d2a79f92a docs
- bump minor version
- review issues list
2021-10-31 22:25:34 +01:00
Carlos Valente f73d8ae8f4 Merge pull request #21 from cpvalente/fix/roll
Fix/roll
2021-10-31 22:16:56 +01:00
Carlos Valente 1138b4dd98 Merge pull request #20 from cpvalente/fix/DST
fix: time from midnight
2021-10-31 22:16:15 +01:00
cv ad89a780be fix: time from midnight
fix issue where time from midnight was wrong on days where DST changes
2021-10-31 22:04:19 +01:00
cv 9bc2923586 fix: handle midnight
fixed issue where roll mode wouldnt recognise a end time lower than start time (go through midnight)
2021-10-31 13:38:28 +01:00
cv 93580554bd fix: roll state
fix issue when the call of roll() was resetting the state after trying to load titles
2021-10-31 13:26:32 +01:00
cv 03ef3e58ed style: button disable
disable playback buttons while rolling (interface only)
2021-10-31 13:12:22 +01:00
cv 4d2769dee4 fix: recalculate timers
- recalculate timers in same update call if necessary
- use new variable to keep track of upcoming rolling events without recalculating titles
2021-10-31 12:53:46 +01:00
cv 0bb728d76c refract: broadcast state
- ability to broadcast state without recalculating timer
2021-10-31 11:20:35 +01:00
cv 5a68d292e1 fix: reset timers
reset timers as needed with roll mode
2021-10-31 11:16:32 +01:00
Carlos Valente 418f8018fe Merge pull request #19 from cpvalente/refract/update
Refract/update
2021-10-29 23:17:04 +02:00
cv 89d3bc967c revert upgrade electron
- GL issue with electron version
- re build yarn lock
2021-10-29 23:16:28 +02:00
cv 144ce1c046 upgrade electron-builder 2021-10-29 22:52:00 +02:00
cv 513e50a998 upgrade electron 2021-10-29 22:48:33 +02:00
cv f0ecb85c29 upgrade react 2021-10-29 22:32:20 +02:00
Carlos Valente c0e0631cd2 Update README.md
update download link
2021-10-29 14:07:00 +02:00
Carlos Valente dca7533a2b Merge pull request #18 from cpvalente/chore/cleanup
Chore/cleanup
2021-10-29 13:48:15 +02:00
cv 8fcf33933e feat: stage route
add route for stage timer 'stage'
2021-10-29 13:47:35 +02:00
Carlos Valente 1c327cde1f Merge pull request #17 from cpvalente/fix/roll
Fix/roll
2021-10-29 13:44:19 +02:00
cv 3a85a483c1 fix: load titles
fixed issue where titles where not loaded in roll mode
2021-10-29 13:25:45 +02:00
cv ba3f0372dd refract: secondary timer
small fixes on how the timer is calculated
2021-10-29 13:03:47 +02:00
cv 31f1786eae docs: add logs
- add log messages on playback controller
2021-10-29 13:02:57 +02:00
cv 6648586c26 style: improve visibility of roll mode
- display message when waiting
- add tooltips
- block non necessary buttons when rolling
2021-10-29 12:56:52 +02:00
cv e4b712593d fix: roll active with upcoming event
fixed an bug where the next event index 0 was treated as no event found
2021-10-28 17:47:03 +02:00
cv 1305fad6b5 fix: reset timers
reset timers correctly when current event is deleted
2021-10-28 15:27:23 +02:00
cv e1f9d322da feat: open help from browser 2021-10-28 15:17:12 +02:00
cv 1ffd6ff7b0 fix: shutdown
restore shutdown
2021-10-28 14:43:46 +02:00
cv c8e4704daf fix: upload in electron
- fixed issue where upload would fail if folder did not exist
2021-10-28 14:43:34 +02:00
cv b08bc64bd4 feat: import settings 2021-10-28 14:18:59 +02:00
cv 4dc7198c52 config: small improvements
- added small improvements to installation
- show custom icons on install / uninstall
- handle developer tools in app
2021-10-28 14:10:50 +02:00
cv 7d99301beb fix: show in taskbar
if app is not in task bar, it disappears on alt+tab
2021-10-28 12:32:33 +02:00
cv f3a4e6405b fix: cleanup merge 2021-10-28 12:31:24 +02:00
cv 0bf663b149 Merge branch 'chore/cleanup' of https://github.com/cpvalente/ontime into chore/cleanup 2021-10-28 12:09:19 +02:00
cv 167174a914 cleanup 2021-10-28 12:08:02 +02:00
Carlos Valente 4ed385dd08 Merge pull request #16 from cpvalente/chore/settings
Chore/settings
2021-10-28 11:55:04 +02:00
cv 1afdd67760 refract
remove unused
cleanup styles
simplify post code
commentz
2021-10-27 22:54:50 +02:00
cv 0a5fa12c21 feat: app settings
- user can change the service ports
!!! we cannot change the express server port yet
2021-10-27 22:52:43 +02:00
cv 6b5fb186cc feat: post info 2021-10-27 22:51:52 +02:00
cv b2a1974a84 feat api endpoint
create post info endpoint
2021-10-27 22:51:22 +02:00
cv 218c94ebd6 chore: update model def 2021-10-27 10:57:02 +02:00
cv 13487867bc feat: end message
- set end message in settings
2021-10-27 10:04:46 +02:00
cv 1cad360f55 refract: async
ensure execution order by awaiting function calls
2021-10-26 22:50:30 +02:00
cv a237537366 feat: start services from config
ability to override default ports and whatnot. as of now, it would need a server restart
2021-10-26 22:17:38 +02:00
cv ec714b341f mock components
- App Settings
- Event Settings
- Alias
2021-10-26 21:48:45 +02:00
cv f999f19aa9 refract: updgrade UI library
upgrade chakra tries to resolve issues with modals and sliders
2021-10-26 09:26:58 +02:00
cv a9641f0c54 refract; update fe dependencies
update react qr code
update react icons
2021-10-25 22:59:50 +02:00
cv a70cb23242 refract: update socket.io 2021-10-25 22:54:39 +02:00
cv 5a87668fac fix: stu test fixes
- QR code url was hardcoded
- fix language in add menus
- changed release symbol
- changed order of release and reload
2021-10-25 22:19:02 +02:00
cv 23ef4ce155 refract: ship with working event list 2021-10-25 11:40:51 +02:00
cv 21c6303e87 style: interface spacing
- less padding between elements
2021-10-25 11:33:58 +02:00
cv d4cf983d5d fix: open links when using browser 2021-10-25 10:42:03 +02:00
cv d03424d5c4 Update README.md
- add default URLs
2021-10-25 10:06:01 +02:00
cv 0977ee6683 refract: remove unused dependencies
- date fns
- concurrently
2021-10-24 22:55:59 +02:00
cv affeb75bcd refract: update dependency
update node-osc
2021-10-24 22:44:29 +02:00
cv 3dc8483d4f refract: update dependency
update lowdb
2021-10-24 22:41:13 +02:00
cv 4f5d13214b refract: cleanup unused 2021-10-24 22:00:51 +02:00
cv 33d8cbef08 fix: download db path
path changed with folder restructure
2021-10-24 21:28:32 +02:00
cv 541117b31b chore: cleanup logs 2021-10-24 20:51:39 +02:00
cv 5c659758b1 refract: cleanup folder structure
rename data folder models to differentiate from directory of db
2021-10-24 20:50:24 +02:00
cv b62d376e64 style: cleanup public and backstage views
- redistribute grid sizes to fit titles
2021-10-24 13:26:47 +02:00
cv 41e2b88376 Update README.md
- add some known issues to readme
2021-10-24 13:09:39 +02:00
cv 310579143e fix: revert css
max-width property breaks views
2021-10-24 12:57:55 +02:00
cv a0f3390ad3 style: black text 2021-10-24 12:41:30 +02:00
cv abdd2606ee style: consistent button style
- modify add button to be consistent with main menu colours
2021-10-24 12:40:12 +02:00
cv d36adf08ba style: animate event collapse / expand 2021-10-24 12:39:12 +02:00
cv a638e5962c style fix: handle long titles
long titles would overflow
- list editor
- info titles
- backstage view
- public view
2021-10-24 12:38:57 +02:00
cv 759dece70f refract: update notification style
Notifications title is app (windows)
notifications now have title and message
2021-10-23 22:30:00 +02:00
cv 6ca2eb77a1 fix: stop IPC errors when testing locally 2021-10-02 16:48:15 +02:00
Carlos Valente 8335af4ed0 Merge pull request #15 from cpvalente/chore/modal-manager
modal manager
2021-10-02 16:43:31 +02:00
cv de44ea81d3 modal manager
- Create a modal with tabs for reusability
2021-10-02 16:42:51 +02:00
cv 260b0fc97f nodejs dependencies 2021-08-17 22:07:38 +02:00
cv 06ab1185c9 add link to release download 2021-08-17 10:50:09 +02:00
206 changed files with 20983 additions and 11548 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"parserOptions": {
"ecmaVersion": 2020
},
"env": {
"es6": true,
"jest": true
},
"extends": [
"eslint:recommended"
],
"rules": {
// disallow certain object properties
// https://eslint.org/docs/rules/no-restricted-properties
"no-restricted-properties": [
"error",
{
"object": "global",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "self",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "window",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
}
]
}
}
View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+6 -1
View File
@@ -40,8 +40,13 @@ jobs:
run: yarn build
working-directory: ./client
# install node dependencies
- name: Install nodejs dependencies
run: yarn install
working-directory: ./server/src
# install and build electron
- name: Install Electron + nodejs dependencies
- name: Install Electron dependencies
run: yarn install
working-directory: ./server
- name: Build Electron App
+70
View File
@@ -0,0 +1,70 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: ontime_test_CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '14.x'
# Utils server
- name: Utils - Install dependencies
run: yarn install
working-directory: ./server/utils
- name: Utils - run link command
run: yarn link
working-directory: ./server/utils
# React
- name: React - Link to utils
run: yarn link ontime-utils
working-directory: ./client
- name: React - Install dependencies
run: yarn install
working-directory: ./client
- name: React - Run tests
run: yarn test:pipeline
working-directory: ./client
- name: React - Build project
run: yarn build
working-directory: ./client
# Node server
- name: React - Link to utils
run: yarn link ontime-utils
working-directory: ./server/src
- name: Server - Install dependencies
run: yarn install
working-directory: ./server/src
# App
- name: Electron - Install dependencies
run: yarn install && yarn make && yarn setdb
working-directory: ./server
- name: Electron - Run tests
run: yarn test
working-directory: ./server
- name: Cypress run
uses: cypress-io/github-action@v2
with:
working-directory: ./server
start: yarn cypress
+12 -7
View File
@@ -6,7 +6,8 @@ node_modules/
.pnp.js
# testing
/coverage
coverage/
*.mp4
# production
build/
@@ -23,13 +24,17 @@ dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
ontime.code-workspace
TODO.md
# working stuff
_SS/
.vscode/launch.json
db.json
.eslintrc.json
db.json
db backup.json
server/src/data/db.json
server/src/models/db.json
TODO.md
# vscode stuff
.vscode/*
ontime.code-workspace
# webstorm stuff
.idea/*
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+37
View File
@@ -0,0 +1,37 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 100
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

+74 -18
View File
@@ -1,13 +1,46 @@
[![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) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://cpvalente.gitbook.io/ontime/)
Download the latest releases here
- [Windows](https://gitreleases.dev/gh/cpvalente/ontime/latest/ontime-win64.exe)
# Ontime
Ontime is an application for managing event rundowns and running stage timers.
It allows a center application to be able to distribute event information in the local network. This minimises needs for using Media Server outputs or expensive video distribution while allowing easy integration in workflows including OBS and d3.
![App Window](https://github.com/cpvalente/ontime/blob/master/01_app.png)
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/01_app.png)
![Views](https://github.com/cpvalente/ontime/blob/master/02_screentypes.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/02_screentypes.png)
## Using Ontime
Once installed and running, ontime starts a background server that is the heart of all processes.
The app, is used to add / edit your running order in the event list, and running the timers using the Playback Control function.
From here, any device in the same network with a browser is able to render the views as described. This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001` or `192.168.1.3:4001`
You can then use the ontime logo in the top right corner to select the desired view (event in the lower thirds view, where it is hidden).
In case of unattended machines or automations, it is possible to use different URL to recall individual views
and extend with using the URL aliases feature
```
For the presentation views...
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
IP.ADDRESS:4001/preseter > Presenter / Stage timer view
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/pip > Picture in Picture view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
...and for the editor (the control interface, same as the app)
-------------------------------------------------------------
IP.ADDRESS:4001/studio > Studio Clock
```
More documentation available [here](https://cpvalente.gitbook.io/ontime/)
## Feature List (in no specific order)
- [x] Distribute Data over network and render in the browser
- [x] Different screen types
@@ -19,33 +52,56 @@ It allows a center application to be able to distribute event information in the
- [x] Send live messages to different screen types
- [x] Ability to differentiate between backstage and public data
- [x] Manage delays workflow
- [x] OSC Control and Feedback
- [x] Roll mode: run independently from system clock
- [x] Open Sound Control (OSC) Control and Feedback
- [x] Roll mode: run independently using the system clock
- [x] Import event list from Excel
- [x] URL Aliases (define configurable aliases to ease onsite setup)
- [x] Logging view
- [x] Edit anywhere: run ontime in your local network and use any machine to reach the editor page (same as app)
## Unopinionated
We are not interested in forcing workflows and have made ontime so it is flexible to whichever way you would like to work.
We are not interested in forcing workflows and have made ontime, so it is flexible to whichever way you would like to work.
- [x] You do not need an order list to use the timer. Create an empty event and the OSC API works just the same
- [x] If you want just the info screens, no need to use the timer!
- [x] Dont have or care for a schedule?
- [x] Don't have or care for a schedule?
- [x] a single event with no data is enough to use the OSC API and get going
- [x] use the order list to create a set of quick timers by setting the beggining and start times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quick recall this with OSC as always
- [x] use the order list to create a set of quick timers by setting the beginning and start times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quickly recall this with OSC as always
## Integrations and Workflow
The app is being currently developed to a wide user base, from broadcast to entertainment and conference halls.
Taking advantage of the integrations in Ontime, we currently use Ontime with:
- `disguise`: trigger ontime from d3's timeline using the **OSC API**, **render views** using d3's webmodule
- `OBS`: **render views** using the Browser Module
- `QLab`: trigger ontime using **OSC API**
- `Companion`: trigger ontime and manipulate timer using **OSC API**
## Roadmap
### Continued development
There are several features planned in the roadmap.
These will be implemented in a development friendly order unless there is user demand to bump any of them.
- [ ] Linux version
- [ ] Headless version (run server only anywhere, configure from a browser locally)
- [ ] Companion module
- [ ] Lower Third Manager
- [ ] Note only event
- [ ] Reach Schedule: way to speedup timer to meet a deadline
- [ ] vMix integration
### For version 1
Almost reaching a feature set that we can call v1. Before that:
- [ ] Mac OS version
- [ ] Finish Documentation
### Continuing
- [ ] Linux version
- [ ] Headless version (run server only anywhere, configure from a browser locally)
- [ ] Companion integration
- [ ] Lower Third Manager
- [ ] Note only event
- [ ] URL Aliases (define configurable aliases to ease onsite setup)
- [ ] Logging view
- [ ] Reach Schedule: way to speedup timer to meet a deadline
### Issues
The app is still in pre-release and there are a few issues, mainly concerning style.
This will be receiving attention as we near v1 release
#### Style
- [ ] App needs improvement on handling zoomed interfaces: Please ensure that windows settings have no display zoom (it is 125% by default)
- [ ] Very long titles might cause interface to shift
# Help
Help is underway! ... and can be viewed [here](https://cpvalente.gitbook.io/ontime/)
+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"react-app",
"react-app/jest"
],
"plugins": ["react", "testing-library", "jest"],
"rules": {
"jest/no-mocks-import": "warn",
"no-useless-concat": "warn",
"prefer-template": "warn"
}
}
+21 -22
View File
@@ -3,43 +3,37 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^1.4.1",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@chakra-ui/react": "^1.7.3",
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"date-fns": "^2.20.1",
"axios": "^0.24.0",
"framer-motion": "^4.1.6",
"jotai": "^0.16.5",
"react": "^17.0.1",
"ontime-utils": "link: ../server/utils/",
"react": "17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.1",
"react-fast-compare": "^3.2.0",
"react-icons": "^4.2.0",
"react-qr-code": "^1.1.1",
"react-query": "^3.13.5",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"socket.io-client": "^4.0.1",
"tinykeys": "^1.1.2",
"react-icons": "^4.3.1",
"react-qr-code": "2.0.3",
"react-query": "^3.34.5",
"react-router-dom": "^6.2.1",
"react-scripts": "5.0.0",
"socket.io-client": "4.4.0",
"typeface-open-sans": "^1.1.13",
"use-fit-text": "^2.4.0",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"start": "set BROWSER=none&&react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"test:pipeline": "react-scripts test --watchAll=false --runInBand --detectOpenHandles --forceExit ",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
@@ -52,5 +46,10 @@
"last 1 safari version"
]
},
"devDependencies": {}
"devDependencies": {
"@testing-library/react-hooks": "^7.0.2",
"prop-types": "^15.8.1",
"react-test-renderer": "^17.0.2",
"sass": "^1.44.0"
}
}
+14 -7
View File
@@ -5,14 +5,21 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#121212" />
<meta
name="onsite"
content="onsite - event timer manager"
/>
<meta name="onsite" content="onsite - event timer manager" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest">
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-16
View File
@@ -1,16 +0,0 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body,
html,
.App {
margin: 0px auto;
overflow: hidden;
overflow: clip;
height: 100vh;
-webkit-user-select: none;
user-select: none;
-webkit-app-region: drag;
}
+97 -39
View File
@@ -1,17 +1,22 @@
import { lazy, Suspense } from 'react';
import { Route, Switch } from 'react-router-dom';
import './App.css';
import { QueryClient, QueryClientProvider } from 'react-query';
import SocketProvider from 'app/context/socketContext';
import { lazy, Suspense, useCallback, useEffect } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import './App.scss';
import withSocket from 'features/viewers/ViewWrapper';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
import ProtectRoute from './common/components/protectRoute/ProtectRoute';
import { useFetch } from './app/hooks/useFetch';
import { ALIASES } from './app/api/apiConstants';
import { getAliases } from './app/api/ontimeApi';
const Editor = lazy(() => import('features/editors/Editor'));
const PresenterView = lazy(() =>
import('features/viewers/presenter/PresenterView')
const TimerView = lazy(() =>
import('features/viewers/timer/Timer')
);
const PresenterSimple = lazy(() =>
import('features/viewers/presenter/PresenterSimple')
const MinimalTimerView = lazy(() =>
import('features/viewers/timer/MinimalTimer')
);
const StageManager = lazy(() =>
import('features/viewers/backstage/StageManager')
);
@@ -20,44 +25,97 @@ const Lower = lazy(() =>
import('features/viewers/production/lower/LowerWrapper')
);
const Pip = lazy(() => import('features/viewers/production/Pip'));
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
const queryClient = new QueryClient();
// Seemed to cause issues
// broadcastQueryClient({
// queryClient,
// broadcastChannel: 'ontime',
// });
const SSpeaker = withSocket(PresenterView);
const SSpeakerSimple = withSocket(PresenterSimple);
const STimer = withSocket(TimerView);
const SMinimalTimer = withSocket(MinimalTimerView);
const SStageManager = withSocket(StageManager);
const SPublic = withSocket(Public);
const SLowerThird = withSocket(Lower);
const SPip = withSocket(Pip);
const SStudio = withSocket(StudioClock);
function App() {
const { data } = useFetch(ALIASES, getAliases);
const location = useLocation();
const navigate = useNavigate();
// Handle keyboard shortcuts
const handleKeyPress = useCallback((e) => {
// handle held key
if (e.repeat) return;
// check if the alt key is pressed
if (e.altKey) {
if (e.key === 't' || e.key === 'T') {
// if we are in electron
if (window.process?.type === undefined) return;
if (window.process.type === 'renderer') {
// ask to see debug
window.ipcRenderer.send('set-window', 'show-dev');
}
}
}
}, []);
useEffect(() => {
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
// remove the event listener
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
// navigate if is alias route
useEffect(() => {
if (data == null) return;
for (const d of data) {
if (`/${d.alias}` === location.pathname && d.enabled) {
navigate(`/${d.pathAndParams}`);
break;
}
}
}, [data, location, navigate]);
return (
<SocketProvider>
<QueryClientProvider client={queryClient}>
<div className='App'>
<Suspense fallback={null}>
<Switch>
<Route exact path='/' component={SSpeaker} />
<Route exact path='/sm' component={SStageManager} />
<Route exact path='/speaker' component={SSpeaker} />
<Route exact path='/speakersimple' component={SSpeakerSimple} />
<Route exact path='/editor' component={Editor} />
<Route exact path='/public' component={SPublic} />
<Route exact path='/pip' component={SPip} />
{/* Lower cannot have fallback */}
<Route exact path='/lower' component={SLowerThird} />
{/* Send to default if nothing found */}
<Route component={SSpeaker} />
</Switch>
</Suspense>
</div>
</QueryClientProvider>
</SocketProvider>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<STimer />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/sm' element={<SStageManager />} />
<Route path='/backstage' element={<SStageManager />} />
<Route path='/public' element={<SPublic />} />
<Route path='/pip' element={<SPip />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
{/*/!* Protected Routes *!/*/}
<Route
path='/editor'
element={
<ProtectRoute>
<Editor />
</ProtectRoute>
}
/>
{/* Send to default if nothing found */}
<Route path='*' element={<STimer />} />
</Routes>
</Suspense>
</ErrorBoundary>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-color: rgba(255, 255, 255, 0.15) rgba(255, 255, 255, 0.01);
scrollbar-width: thin;
}
body,
html,
.App {
margin: 0px auto;
overflow: hidden;
overflow: clip;
height: 100vh;
-webkit-user-select: none;
user-select: none;
-webkit-app-region: drag;
}
option {
color: black;
}
/* width */
::-webkit-scrollbar {
width: 0.5em;
}
/* Track */
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.01);
border-radius: 4px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+9
View File
@@ -0,0 +1,9 @@
import {QueryClient} from "react-query";
export const queryClientMock = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
+3
View File
@@ -1,7 +1,10 @@
export const NODE_PORT = 4001;
export const EVENT_TABLE = 'event';
export const ALIASES = 'aliases';
export const EVENTS_TABLE = 'events';
export const APP_TABLE = 'appinfo';
export const OSC_SETTINGS = 'oscSettings';
export const APP_SETTINGS = 'appSettings';
const calculateServer = () => {
return window.location.origin.replace(window.location.port, `${NODE_PORT}/`);
+8 -15
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { eventsURL } from '../api/apiConstants';
import { eventsURL } from './apiConstants';
export const fetchAllEvents = async () => {
const res = await axios.get(eventsURL);
@@ -7,38 +7,31 @@ export const fetchAllEvents = async () => {
};
export const requestPost = async (data) => {
const res = await axios.post(eventsURL, data);
return res;
return await axios.post(eventsURL, data);
};
export const requestPut = async (data) => {
const res = await axios.put(eventsURL, data);
return res;
return await axios.put(eventsURL, data);
};
export const requestPatch = async (data) => {
const res = await axios.patch(eventsURL, data);
return res;
return await axios.patch(eventsURL, data);
};
export const requestReorder = async (data) => {
const action = 'reorder';
const res = await axios.patch(eventsURL + '/' + action, data);
return res;
return await axios.patch(eventsURL + '/' + action, data);
};
export const requestApplyDelay = async (eventId) => {
const action = 'applydelay';
const res = await axios.patch(eventsURL + '/' + action + '/' + eventId);
return res;
return await axios.patch(eventsURL + '/' + action + '/' + eventId);
};
export const requestDelete = async (eventId) => {
const res = await axios.delete(eventsURL + '/' + eventId);
return res;
return await axios.delete(eventsURL + '/' + eventId);
};
export const requestDeleteAll = async () => {
const res = await axios.delete(eventsURL + '/all');
return res;
return await axios.delete(eventsURL + '/all');
};
+118 -9
View File
@@ -3,21 +3,132 @@ import { ontimeURL } from './apiConstants';
export const ontimePlaceholderInfo = {
networkInterfaces: [],
settings: {
version: '',
serverPort: 4001,
},
};
export const ontimePlaceholderSettings = {
pinCode: null,
};
export const eventPlaceholderSettings = {
title: '',
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
};
export const oscPlaceholderSettings = {
port: '',
portOut: '',
targetIP: '',
enabled: false,
};
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
};
export const ontimeVars = [
{
name: '$timer',
description: 'Current running timer',
},
{
name: '$title',
description: 'Current title',
},
{
name: '$presenter',
description: 'Current timer',
},
{
name: '$subtitle',
description: 'Current subtitle',
},
{
name: '$next-title',
description: 'Next title',
},
{
name: '$next-presenter',
description: 'Next timer',
},
{
name: '$next-subtitle',
description: 'Next subtitle',
},
];
export const getSettings = async () => {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
};
export const postSettings = async (data) => {
return await axios.post(`${ontimeURL}/settings`, data);
};
export const getInfo = async () => {
const res = await axios.get(ontimeURL + '/info');
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
};
export const postInfo = async (data) => {
return await axios.post(`${ontimeURL}/info`, data);
};
export const getAliases = async () => {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
};
export const postAliases = async (data) => {
return await axios.post(`${ontimeURL}/aliases`, data);
};
export const getOSC = async () => {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
};
export const postOSC = async (data) => {
return await axios.post(`${ontimeURL}/osc`, data);
};
export const downloadEvents = async () => {
await axios({
url: ontimeURL + '/db',
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
let headerLine = response.headers['Content-Disposition'];
console.log(response);
let filename = 'events.json';
// try and get the filename from the response
@@ -38,17 +149,15 @@ export const downloadEvents = async () => {
export const uploadEvents = async (file) => {
const formData = new FormData();
formData.append('jsondb', file); // appending file
formData.append('userFile', file); // appending file
await axios
.post(ontimeURL + '/db', formData, {
.post(`${ontimeURL}/db`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((res) => console.log(res.data))
.catch((err) => console.error(err));
});
};
export const uploadEventsWithPath = async (filepath) => {
await axios.post(ontimeURL + '/dbpath', { path: filepath });
await axios.post(`${ontimeURL}/dbpath`, { path: filepath });
};
+14
View File
@@ -0,0 +1,14 @@
// Exported viewer links
const speakerLink = 'http://localhost:4001/speaker';
const smLink = 'http://localhost:4001/sm';
const publicLink = 'http://localhost:4001/public';
const pipLink = 'http://localhost:4001/pip';
const studioLink = 'http://localhost:4001/studio';
export const viewerLinks = [
{ link: speakerLink, label: 'Speaker Screen' },
{ link: smLink, label: 'Backstage Screen' },
{ link: publicLink, label: 'Public Screen' },
{ link: pipLink, label: 'Picture in Picture' },
{ link: studioLink, label: 'Studio Clock' }
];
+37
View File
@@ -0,0 +1,37 @@
import { createContext, useCallback, useEffect, useState } from 'react';
import { useFetch } from '../hooks/useFetch';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
export const AppContext = createContext({
auth: false,
data: {
pinCode: null
}
});
export const AppContextProvider = (props) => {
const [auth, setAuth] = useState(true);
const { data } = useFetch(APP_SETTINGS, getSettings);
useEffect(() => {
if (data == null) return;
if (data?.pinCode === null || data?.pinCode === '') {
setAuth(true);
} else {
setAuth(false);
}
},[data])
const validate = useCallback((pin) => {
const correct = pin === data.pinCode;
setAuth(correct);
return correct;
}, [data]);
return (
<AppContext.Provider value={{ auth, validate }}>
{props.children}
</AppContext.Provider>
);
};
+96
View File
@@ -0,0 +1,96 @@
import { useSocket } from './socketContext';
import { createContext, useCallback, useEffect, useState } from 'react';
import { generateId } from 'ontime-utils/generate_id';
import { nowInMillis, stringFromMillis } from 'ontime-utils/time';
export const LoggingContext = createContext({
logData: [],
emitInfo: () => undefined,
emitWarning: () => undefined,
emitError: () => undefined,
clearLog: () => undefined
});
export const LoggingProvider = (props) => {
const MAX_MESSAGES = 100;
const socket = useSocket();
const [logData, setLogData] = useState([]);
const origin = 'USER';
// handle incoming messages
useEffect(() => {
if (socket == null) return;
// Ask for log data
socket.emit('get-logger');
socket.on('logger', (data) => {
setLogData((l) => [data, ...l]);
});
// Clear listener
return () => {
socket.off('logger');
};
}, [socket]);
/**
* Utility function sends message over socket
* @param text
* @param level
* @private
*/
const _send = useCallback((text, level) => {
if (socket != null) {
const m = {
id: generateId(),
origin,
time: stringFromMillis(nowInMillis()),
level,
text
}
setLogData((l) => [m, ...l]);
socket.emit('logger', m);
}
if (logData.length > MAX_MESSAGES) {
setLogData((l) => l.pop());
}
},[logData, socket]);
/**
* Sends a message with level INFO
* @param text
*/
const emitInfo = useCallback((text) => {
_send(text, 'INFO');
}, [_send]);
/**
* Sends a message with level WARN
* @param text
*/
const emitWarning = useCallback((text) => {
_send(text, 'WARN');
}, [_send]);
/**
* Sends a message with level ERROR
* @param text
*/
const emitError = useCallback((text) => {
_send(text, 'ERROR');
}, [_send]);
/**
* Clears running log
*/
const clearLog = useCallback(() => {
setLogData([])
}, []);
return (
<LoggingContext.Provider value = {{ emitInfo, logData, emitWarning, emitError, clearLog }}>
{props.children}
</LoggingContext.Provider>
)
}
+13
View File
@@ -0,0 +1,13 @@
/**
* @description Validates two time entries
* @param {number} timeStart
* @param {number} timeEnd
* @returns {{catch: string, value: boolean}}
*/
export const validateTimes = (timeStart, timeEnd) => {
let validate = { value: true, catch: '' };
if (timeStart > timeEnd) {
validate.catch = 'Start time later than end time';
}
return validate;
};
+1 -1
View File
@@ -2,7 +2,7 @@ const dummy = new Date();
export const sampleData = {
presenterMessage: {
text: 'Only the presenter sees this',
text: 'Only the timer sees this',
active: false,
},
publicMessage: {
-7
View File
@@ -1,7 +0,0 @@
export const userConfig = {
timerColorOnPause: '#555',
timerColorOnRunning: '#FFF',
timerColorOnMessage: '#CCC',
timerColorOnTimeOver: '#F00',
overTimeText: '',
}
-2
View File
@@ -1,2 +0,0 @@
export const clamp = (num, a, b) =>
Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
@@ -0,0 +1,27 @@
import { validateAlias } from '../aliases';
describe('An alias fails if incorrect', () => {
const testsToFail = [
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test'
];
testsToFail.forEach((t) => (
test(`${t}`, () => {
expect(validateAlias(t).status).toBeFalsy();
})
)
);
});
@@ -0,0 +1,21 @@
import { clamp } from '../math';
test('Clamps a set of numbers correctly', () => {
const testCases = [
{ num: 10, min: 0, max: 20, result: 10 },
{ num: 0, min: 0, max: 20, result: 0 },
{ num: 20, min: 0, max: 20, result: 20 },
{ num: 20, min: 0, max: 20, result: 20 },
{ num: -20, min: 0, max: 20, result: 0 },
{ num: -0, min: 0, max: 20, result: 0 },
{ num: -50, min: -30, max: -20, result: -30 },
{ num: -50, min: 0, max: 0, result: 0 },
{ num: 50.5, min: 0, max: 100, result: 50.5 },
{ num: 50, min: 0, max: 20.32, result: 20.32 },
{ num: 10, min: 20.32, max: 40, result: 20.32 }
];
testCases.forEach((t) => (
expect(clamp(t.num, t.min, t.max)).toBe(t.result)
));
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Validates an alias against defined parameters
* @param {string} alias
* @returns {{message: string, status: boolean}}
*/
export const validateAlias = (alias) => {
const valid = { status: true, message: 'ok' };
if (alias === '' || alias == null) {
// cannot be empty
valid.status = false;
valid.message = 'should not be empty';
} else if (alias.includes('http') || alias.includes('https') || alias.includes('www')) {
// cannot contain http, https or www
valid.status = false;
valid.message = 'should not include http, https, www';
} else if (alias.includes('127.0.0.1') || alias.includes('localhost') || alias.includes('0.0.0.0')) {
// aliases cannot contain hostname
valid.status = false;
valid.message = 'should not include hostname';
} else if (alias.includes('editor')) {
// no editor
valid.status = false;
valid.message = 'No aliases to editor page allowed';
}
return valid;
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Clamps a value between a min and a max
* @param {number} num - Value to clamp
* @param {number} min - min value
* @param {number} max - max value
* @returns {number}
*/
export const clamp = (num, min, max) =>
Math.max(Math.min(num, Math.max(min, max)), Math.min(min, max));
Binary file not shown.
@@ -7,9 +7,11 @@ export default function AddIconBtn(props) {
<IconButton
size={props.size || 'xs'}
icon={<FiPlus />}
colorScheme='blue'
onClick={clickhandler}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
onClick={clickhandler}
{...rest}
/>
);
@@ -1,6 +1,7 @@
import { IconButton } from '@chakra-ui/button';
import { useState } from 'react';
import { FiMinus } from 'react-icons/fi';
import { IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function DeleteIconBtn(props) {
const { actionHandler, ...rest } = props;
@@ -12,15 +13,17 @@ export default function DeleteIconBtn(props) {
};
return (
<IconButton
size={props.size || 'xs'}
icon={<FiMinus />}
colorScheme='red'
onClick={handleClick}
_focus={{ boxShadow: 'none' }}
disabled={loading}
isLoading={loading}
{...rest}
/>
<Tooltip label='Delete'>
<IconButton
size={props.size || 'xs'}
icon={<IoRemove />}
colorScheme='red'
onClick={handleClick}
_focus={{ boxShadow: 'none' }}
disabled={loading}
isLoading={loading}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,18 @@
import { IoCloseSharp, IoCheckmarkSharp } from 'react-icons/io5';
import { Button } from '@chakra-ui/button';
export default function EnableBtn(props) {
const { active, text, actionHandler } = props;
return (
<Button
size={props.size || 'xs'}
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={actionHandler}
_focus={{ boxShadow: 'none' }}
>
{text}
</Button>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiSkipForward } from 'react-icons/fi';
import { IoPlaySkipForward } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function NextIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiSkipForward />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Next event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlaySkipForward size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,22 @@
import { IconButton } from '@chakra-ui/button';
import { IoMicSharp, IoMicOffOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function OnAirIconBtn(props) {
const { actionHandler, active, ...rest } = props;
return (
<Tooltip label={active ? 'Go Off Air' : 'Go On Air'} openDelay={500}>
<IconButton
size={props.size || 'xs'}
icon={active ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiPause } from 'react-icons/fi';
import { IoPause } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PauseIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiPause />}
colorScheme='orange'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Pause timer' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPause size='24px' />}
colorScheme='orange'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiSkipBack } from 'react-icons/fi';
import { IoPlaySkipBack } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PrevIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiSkipBack />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Previous event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlaySkipBack size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiRefreshCcw } from 'react-icons/fi';
import { IoReload } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ReloadIconButton(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiRefreshCcw />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Reload event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoReload size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiClock } from 'react-icons/fi';
import { IoTimeOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function RollIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiClock />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Roll mode' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoTimeOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiPlay } from 'react-icons/fi';
import { IoPlay } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function StartIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiPlay />}
colorScheme='green'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Start timer' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlay size='24px' />}
colorScheme='green'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiCornerLeftUp } from 'react-icons/fi';
import { IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function UnloadIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiCornerLeftUp />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Unload event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoStop size='22px' />}
colorScheme='red'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,19 +1,22 @@
import { IconButton } from '@chakra-ui/button';
import { FiSun } from 'react-icons/fi';
import { IoSunny } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function VisibleIconBtn(props) {
const { actionHandler, active, ...rest } = props;
return (
<IconButton
size={props.size || 'xs'}
icon={<FiSun />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label={active ? 'Make invisible' : 'Make visible'} openDelay={500}>
<IconButton
size={props.size || 'xs'}
icon={<IoSunny size={'18px'}/>}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,24 @@
import PropTypes from "prop-types";
import style from "../../../features/info/Info.module.scss";
import {Icon} from "@chakra-ui/react";
import {FiChevronUp} from "react-icons/fi";
export default function CollapseBar(props) {
const {title = 'Collapse bar', isCollapsed = false, onClick}= props;
return(
<div className={style.header}>
{title}
<Icon
className={isCollapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={onClick}
/>
</div>
)
}
CollapseBar.propTypes = {
title: PropTypes.string,
isCollapsed: PropTypes.bool,
onClick: PropTypes.func,
}
@@ -0,0 +1,17 @@
.header,
.header__roll {
padding: 0;
margin: 0;
font-size: 0.9em;
color: #ccc;
display: flex;
justify-content: space-between;
}
.header {
color: #ccc;
}
.header__roll {
color: #2b6cb0;
}
@@ -1,14 +1,16 @@
import { memo } from 'react';
import { formatDisplay } from 'common/dateConfig';
import { formatDisplay } from 'common/utils/dateConfig';
import PropTypes from 'prop-types';
import styles from './Countdown.module.css';
const Countdown = ({ time, small, negative, hideZeroHours }) => {
let display = '-- : -- : --';
const Countdown = ({ time, small, isNegative, hideZeroHours }) => {
// prepare display string
if (time != null && !isNaN(time))
display = formatDisplay(Math.abs(time), hideZeroHours);
const colour = negative ? '#ff7597' : '#fffffa';
const display =
time != null && !isNaN(time)
? formatDisplay(time, hideZeroHours)
: '-- : -- : --';
const colour = isNegative ? '#ff7597' : '#fffffa';
return (
<div
@@ -21,3 +23,10 @@ const Countdown = ({ time, small, negative, hideZeroHours }) => {
};
export default memo(Countdown);
Countdown.propTypes = {
time: PropTypes.number.isRequired,
small: PropTypes.bool,
isNegative: PropTypes.bool,
hideZeroHour: PropTypes.bool,
};
@@ -0,0 +1,33 @@
import React from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
class ErrorBoundary extends React.Component {
static contextType = LoggingContext;
constructor(props) {
super(props);
this.state = { error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so next render shows fallback UI.
return { errorMessage: error.toString() };
}
componentDidCatch(error, info) {
this.setState({
error: error,
errorInfo: info,
});
this.context.emitError(error.toString());
}
render() {
if (this.state.errorMessage) {
return <p>:/</p>;
}
return this.props.children;
}
}
export default ErrorBoundary;
@@ -1,24 +1,33 @@
import EditableTimer from 'common/input/EditableTimer';
import { showWarningToast } from 'common/helpers/toastManager';
import { useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
export default function EventTimes(props) {
const { actionHandler, delay, timeStart, timeEnd } = props;
const { actionHandler, delay, timeStart, timeEnd, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, v) => {
// we dont inforce validation here
if (v == null || timeStart == null || timeEnd == null) return true;
const handleValidate = (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let validate = { value: true, catch: '' };
if (entry === 'timeStart' && v > timeEnd)
validate.catch = 'Start time later than end time';
else if (entry === 'timeEnd' && v < timeStart)
validate.catch = 'End time earlier than start time';
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else {
return;
}
if (validate.catch !== '')
showWarningToast('Time Input Warning', validate.catch);
return validate.value;
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
};
return (
@@ -29,6 +38,7 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<EditableTimer
name='timeEnd'
@@ -36,7 +46,16 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
</>
);
}
EventTimes.propTypes = {
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,6 +1,9 @@
import EditableTimer from 'common/input/EditableTimer';
import { showWarningToast } from 'common/helpers/toastManager';
import { stringFromMillis } from 'common/dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
import { useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
const label = {
fontSize: '0.75em',
@@ -8,8 +11,7 @@ const label = {
};
const TimesDelayed = (props) => {
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration } =
props;
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration, previousEnd } = props;
const scheduledStart = stringFromMillis(timeStart, false);
const scheduledEnd = stringFromMillis(timeEnd, false);
@@ -25,6 +27,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<span style={label}>
End <span>{scheduledEnd}</span>
@@ -35,6 +38,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
<span style={label}>Duration</span>
<EditableTimer
@@ -43,13 +47,24 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
};
TimesDelayed.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
const Times = (props) => {
const { handleValidate, actionHandler, timeStart, timeEnd, duration } = props;
const { handleValidate, actionHandler, timeStart, timeEnd, duration, previousEnd } = props;
return (
<>
@@ -60,6 +75,7 @@ const Times = (props) => {
actionHandler={actionHandler}
time={timeStart}
delay={0}
previousEnd={previousEnd}
/>
<span style={label}>End</span>
<EditableTimer
@@ -68,6 +84,7 @@ const Times = (props) => {
actionHandler={actionHandler}
time={timeEnd}
delay={0}
previousEnd={previousEnd}
/>
<span style={label}>Duration</span>
<EditableTimer
@@ -76,31 +93,48 @@ const Times = (props) => {
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
};
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration } = props;
const handleValidate = (entry, v) => {
// we dont enforce validation here
Times.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
if (v == null || timeStart == null || timeEnd == null) return true;
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let validate = { value: true, catch: '' };
if (entry === 'timeStart' && v > timeEnd)
validate.catch = 'Start time later than end time';
else if (entry === 'timeEnd' && v < timeStart)
validate.catch = 'End time earlier than start time';
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else {
return;
}
if (validate.catch !== '')
showWarningToast('Time Input Warning', validate.catch);
return validate.value;
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
};
return (delay != null) & (delay > 0) ? (
return delay != null && delay > 0 ? (
<TimesDelayed
handleValidate={handleValidate}
actionHandler={props.actionHandler}
@@ -108,6 +142,7 @@ export default function EventTimesVertical(props) {
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
) : (
<Times
@@ -116,6 +151,15 @@ export default function EventTimesVertical(props) {
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
);
}
EventTimesVertical.propTypes = {
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,4 +1,4 @@
import { clamp } from 'app/utils';
import { clamp } from 'app/utils/math';
import styles from './MyProgressBar.module.css';
export default function MyProgressBar(props) {
+49 -26
View File
@@ -1,12 +1,13 @@
import { Link, Redirect } from 'react-router-dom';
import PropTypes from "prop-types";
import { Link } from 'react-router-dom';
import { Image } from '@chakra-ui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import tinykeys from 'tinykeys';
import { useState, useEffect, useCallback } from 'react';
import navlogo from 'assets/images/logos/LOGO-72.png';
import style from './NavLogo.module.css';
import style from './NavLogo.module.scss';
export default function NavLogo() {
export default function NavLogo(props) {
const {isHidden} = props;
const [showNav, setShowNav] = useState(false);
const handleClick = () => {
@@ -14,20 +15,29 @@ export default function NavLogo() {
};
// Handle keyboard shortcuts
useEffect(() => {
let unsubscribe = tinykeys(window, {
Space: () => {
setShowNav((s) => !s);
},
});
return () => {
unsubscribe();
};
const handleKeyPress = useCallback((e) => {
// handle held key
if (e.repeat) return;
// Space bar
if (e.keyCode === 32) {
setShowNav((s) => !s);
}
}, []);
useEffect(() => {
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
// remove the event listener
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
const baseOpacity = (isHidden) ? 0 : 0.5
return (
<motion.div
initial={{ opacity: 0.5 }}
initial={{ opacity: baseOpacity }}
whileHover={{ opacity: 1 }}
className={style.navContainer}
>
@@ -46,48 +56,61 @@ export default function NavLogo() {
className={showNav ? style.nav : style.navHidden}
>
<Link
to='/speaker'
to='/timer'
className={style.navItem}
tabIndex={1}
onKeyDownCapture={() => <Redirect push to='/speaker' />}
>
Speaker
Timer
</Link>
<Link
to='/minimal'
className={style.navItem}
tabIndex={2}
>
Minimal Timer
</Link>
<Link
to='/sm'
className={style.navItem}
tabIndex={2}
onKeyDownCapture={() => <Redirect push to='/sm' />}
tabIndex={3}
>
Backstage
</Link>
<Link
to='/public'
className={style.navItem}
tabIndex={3}
onKeyDownCapture={() => <Redirect push to='/public' />}
tabIndex={4}
>
Public
</Link>
<Link
to='/lower'
className={style.navItem}
tabIndex={4}
onKeyDownCapture={() => <Redirect push to='/lower' />}
tabIndex={5}
>
Lower Thirds
</Link>
<Link
to='/pip'
className={style.navItem}
tabIndex={4}
onKeyDownCapture={() => <Redirect push to='/pip' />}
tabIndex={6}
>
PIP
</Link>
<Link
to='/studio'
className={style.navItem}
tabIndex={7}
>
Studio Clock
</Link>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
NavLogo.propTypes = {
isHidden: PropTypes.bool,
}
@@ -1,15 +1,18 @@
$nav-color: #fff;
.navContainer {
position: absolute;
right: 2vw;
top: 1vw;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 1vw;
font-size: max(1vw, 16px);
z-index: 10;
}
.logo {
width: 3vw;
width: max(3vw, 32px);
opacity: 0.5;
}
@@ -23,4 +26,5 @@
background-color: rgba(0, 0, 0, 0.7);
padding: 0.5vh 1vw;
border-radius: 4px;
color: $nav-color;
}
@@ -0,0 +1,67 @@
import PropTypes from 'prop-types';
import style from './ProtectRoute.module.scss';
import { PinInput, PinInputField } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/button';
import { FiCheck } from 'react-icons/fi';
import { AppContext } from '../../../app/context/AppContext';
import { useContext, useEffect, useState } from 'react';
export default function ProtectRoute(props) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const { auth, validate } = useContext(AppContext);
// Set window title
useEffect(() => {
document.title = 'ontime';
}, []);
const handleValidation = () => {
const r = validate(pin);
if (!r) {
setFailed(true);
}
}
return (
<>
{!isLocal && !auth ? (
<div className={style.container}>
ontime
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
aria-label='Enter'
size='lg'
isRound
icon={<FiCheck />}
style={{ fontSize: '1.5em' }}
onClick={() => handleValidation()}
/>
</div>
</div>
) : (
props.children
)}
</>
);
}
ProtectRoute.propTypes = {
children: PropTypes.node.isRequired
};
@@ -0,0 +1,47 @@
@use '../../../styles/main' as *;
@use '../../../styles/mixins' as *;
.container {
background: #222;
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
color: $ontime-pink;
font-family: 'Open Sans', sans-serif;
font-weight: 200;
text-align: center;
font-size: 3vw;
}
.pin,
.pin__failed {
display: flex;
gap: 10px;
padding: 20px;
input {
border-radius: 50%;
}
button {
margin-left: 20px;
}
}
.pin__failed {
input {
animation: colourFade 1.5s ease;
}
}
@keyframes colourFade {
from {
background: $ontime-pink;
}
to {
background: rgba($ontime-pink, 0);
}
}
@@ -1,10 +0,0 @@
import styles from './SmallTimer.module.css';
export default function SmallTimer({ label, time }) {
return (
<div className={styles.SmallTimer}>
<div className={styles.label}>{label}</div>
<div className={styles.timer}>{time}</div>
</div>
);
}
@@ -1,23 +0,0 @@
.smallTimer {
display: flex;
flex-direction: column;
}
.label,
.timer {
font-size: 1vw;
text-align: center;
}
.label {
font-size: 1.5vw;
color: #888;
margin-bottom: 0;
}
.timer {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.125em;
}
@@ -7,7 +7,6 @@ export default function Paginator(props) {
const { events, selectedId } = props;
const LIMIT_PER_PAGE = props.limit || 8;
const SCROLL_TIME = props.time * 1000 || 10000;
const SCROLL_PAST = false;
const [numEvents, setNumEvents] = useState(0);
const [page, setPage] = useState([]);
const [pages, setPages] = useState(0);
@@ -37,6 +37,10 @@
.entryTitle {
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entryPast,
@@ -1,4 +1,4 @@
import { stringFromMillis } from 'common/dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
import style from './Paginator.module.css';
export default function TodayItem(props) {
const { selected, timeStart, timeEnd, title, backstageEvent } = props;
@@ -19,7 +19,7 @@ export default function TodayItem(props) {
}`}
>{`${start} · ${end}`}</div>
<div className={style.entryTitle}>{title}</div>
{backstageEvent && <div className={style.backstageInd}></div>}
{backstageEvent && <div className={style.backstageInd}/>}
</div>
);
}
-49
View File
@@ -1,49 +0,0 @@
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
// time string from miliseconds
export const stringFromMillis = (
ms,
showSeconds = true,
delim = ':',
ifNull = '...'
) => {
if (ms === null || isNaN(ms)) return ifNull;
const showWith0 = (value) => (value < 10 ? `0${value}` : value);
const hours = showWith0(Math.floor(((ms / (1000 * 60 * 60)) % 60) % 24));
const minutes = showWith0(Math.floor((ms / (1000 * 60)) % 60));
const seconds = showWith0(Math.floor((ms / 1000) % 60));
return showSeconds
? `${
parseInt(hours) ? `${hours}${delim}` : `00${delim}`
}${minutes}${delim}${seconds}`
: `${parseInt(hours) ? `${hours}` : '00'}${delim}${minutes}`;
};
// another go at simpler string formatting (counters)
export function formatDisplay(seconds, hideZero) {
const format = (val) => `0${Math.floor(val)}`.slice(-2);
const hours = seconds / 3600;
const minutes = (seconds % 3600) / 60;
if (hideZero && hours < 1)
return [minutes, seconds % 60].map(format).join(':');
else return [hours, minutes, seconds % 60].map(format).join(':');
}
// millis to seconds
export const millisToSeconds = (millis) => {
return Math.floor(millis / 1000);
};
// millis to minutes
export const millisToMinutes = (millis) => {
return Math.floor(millis / 60000);
};
// timeStringToMillis
export const timeStringToMillis = (string) => {
let time = string.split(':');
return time[0] * 3600000 + time[1] * 60000;
};
@@ -1,28 +0,0 @@
import { createStandaloneToast } from '@chakra-ui/react';
const toast = createStandaloneToast();
// const customToast = createStandaloneToast({ theme: yourCustomTheme })
// error toast
export const showErrorToast = (title, description) => {
toast({
title: title,
description: description,
position: 'top-left',
variant: 'subtle',
status: 'error',
isClosable: true,
});
};
// warning toast
export const showWarningToast = (title, description) => {
toast({
title: title,
description: description,
position: 'top-left',
variant: 'subtle',
status: 'warning',
isClosable: true,
});
};
-22
View File
@@ -1,22 +0,0 @@
import { FormErrorMessage } from '@chakra-ui/form-control';
import { FormLabel } from '@chakra-ui/form-control';
import { FormControl } from '@chakra-ui/form-control';
import { Input } from '@chakra-ui/input';
import { Field } from 'formik';
export default function ChakraInput(props) {
const { label, name, ...rest } = props;
return (
<Field name={name}>
{({ field, form }) => {
return (
<FormControl isInvalid={form.errors[name] && form.touched[name]}>
<FormLabel htmlFor={name}>{label}</FormLabel>
<Input id={name} {...rest} {...field} />
<FormErrorMessage>{form.errors[name]}</FormErrorMessage>
</FormControl>
);
}}
</Field>
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import style from './EditableText.module.css';
export default function EditableText(props) {
const { label, defaultValue, placeholder, submitHandler, ...rest } = props;
const [text, setText] = useState(defaultValue || '');
const maxchar = props.maxchar || 40;
useEffect(() => {
if (defaultValue == null) setText('');
@@ -18,7 +19,7 @@ export default function EditableText(props) {
};
const handleChange = (val) => {
if (val.length < 40) setText(val);
if (val.length < maxchar) setText(val);
};
return (
+46 -23
View File
@@ -1,52 +1,66 @@
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useEffect, useState } from 'react';
import {
timeFormat,
stringFromMillis,
timeStringToMillis,
} from '../dateConfig';
import { showErrorToast } from '../helpers/toastManager';
import { useContext, useEffect, useState } from 'react';
import { forgivingStringToMillis } from '../utils/dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
import style from './EditableTimer.module.css';
import { LoggingContext } from '../../app/context/LoggingContext';
import PropTypes from 'prop-types';
export default function EditableTimer(props) {
const { name, actionHandler, time, delay, validate } = props;
const { name, actionHandler, time, delay, validate, previousEnd } = props;
const { emitError } = useContext(LoggingContext);
const [value, setValue] = useState('');
// prepare time fields
useEffect(() => {
if (time == null) return;
try {
setValue(stringFromMillis(time + delay, false));
setValue(stringFromMillis(time + delay));
} catch (error) {
showErrorToast('Error parsing date', error.text);
emitError(`Unable to parse date: ${error.text}`);
}
}, [time, delay]);
}, [time, delay, emitError]);
const validateValue = (value) => {
const success = handleSubmit(value);
if (success) setValue(value);
else setValue(stringFromMillis(time + delay, false));
else setValue(stringFromMillis(time + delay, true));
};
const handleSubmit = (value) => {
// Check if there is anything there
if (value === '') return false;
// Time now and time submitedVal
const original = stringFromMillis(time + delay, false);
let newValMillis;
// check for known aliases
if (value === 'p' || value === 'prev' || value === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
} else {
newValMillis = 0;
}
} else if (value.startsWith('+')) {
// string to pass should add to the end before
const val = value.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(value);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (value === original) return false;
// convert to millis object
const millis = timeStringToMillis(value, timeFormat);
if (newValMillis === originalMillis) return false;
// validate with parent
if (!validate(name, millis)) return false;
if (!validate(name, newValMillis)) return false;
// update entry
actionHandler('update', { field: name, value: millis });
actionHandler('update', { field: name, value: newValMillis });
return true;
};
@@ -55,12 +69,21 @@ export default function EditableTimer(props) {
<Editable
onChange={(v) => setValue(v)}
onSubmit={(v) => validateValue(v)}
onCancel={() => setValue(stringFromMillis(time + delay, true))}
value={value}
placeholder='--:--'
className={delay > 0 ? style.delayedEditable : style.editable}
>
<EditablePreview />
<EditableInput type='time' min='00:00' max='23:59' />
<EditableInput type='text' placeholder='--:--:--' />
</Editable>
);
}
EditableTimer.propTypes = {
name: PropTypes.string.isRequired,
actionHandler: PropTypes.func.isRequired,
time: PropTypes.number.isRequired,
delay: PropTypes.number.isRequired,
validate: PropTypes.func.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,9 +1,10 @@
.editable,
.delayedEditable {
background-color: rgba(255, 255, 255, 0.05);
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
width: 5em;
width: 6.5em;
letter-spacing: 1px;
height: fit-content;
text-align: center;
@@ -0,0 +1,312 @@
import {
formatDisplay,
isTimeString,
millisToMinutes,
millisToSeconds,
forgivingStringToMillis,
timeStringToMillis,
} from '../dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
describe('test string from formatDisplay function', () => {
it('test with null values', () => {
const t = { val: null, result: '00:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600, result: '01:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600, result: '01:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: '00:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: '00:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with 86400 (24 hours)', () => {
const t = { val: 86400, result: '00:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with 86401 (24 hours and 1 second)', () => {
const t = { val: 86401, result: '00:00:01' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with -86401 (-24 hours and 1 second)', () => {
const t = { val: -86401, result: '00:00:01' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
});
describe('test formatDisplay handles partial secs', () => {
it('test with 1795829', () => {
const t = { val: 1795829, result: '00:29:55' };
expect(stringFromMillis(t.val)).toBe(t.result);
});
});
describe('test string from formatDisplay function with hidezero', () => {
it('test with null values', () => {
const t = { val: null, result: '00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600, result: '01:00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600, result: '01:00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: '00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: '00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with 86400 (24 hours)', () => {
const t = { val: 86400, result: '00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with 86401 (24 hours and 1 second)', () => {
const t = { val: 86401, result: '00:01' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
it('test with -86401 (-24 hours and 1 second)', () => {
const t = { val: -86401, result: '00:01' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
});
describe('test millisToSeconds function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
});
describe('test millisToMinutes function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
});
describe('test timeStringToMillis function', () => {
it('test with null', () => {
const t = { val: null, result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:00', () => {
const t = { val: '00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:00', () => {
const t = { val: '-00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:01', () => {
const t = { val: '00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:01', () => {
const t = { val: '-00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 01:00:01', () => {
const t = { val: '01:00:01', result: 3601000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 24:00:01', () => {
const t = { val: '24:00:01', result: 86401000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:5', () => {
const t = { val: '00:00:5', result: 5000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:1:00', () => {
const t = { val: '00:1:00', result: 60000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1:00:00', () => {
const t = { val: '1:00:00', result: 3600000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1', () => {
const t = { val: '1', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 120', () => {
const t = { val: '120', result: 120000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 56', () => {
const t = { val: '56', result: 56000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:3', () => {
const t = { val: '2:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 02:3', () => {
const t = { val: '02:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:03', () => {
const t = { val: '2:03', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
});
describe('test isTimeString() function', () => {
test('it validates time strings', () => {
const ts = ['2', '2:10', '2:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(true);
}
});
test('it fails overloaded times', () => {
const ts = ['70', '89:10', '26:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(false);
}
});
});
describe('test isTimeString() function handle different separators', () => {
const ts = ['2:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(isTimeString(s)).toBe(true);
});
}
});
describe('test timeHelper() function handles separators', () => {
const ts = ['1:2:3:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(typeof forgivingStringToMillis(s)).toBe('number');
});
}
});
describe('test timeHelper() parses strings correctly', () => {
const ts = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '1.2', expect: 60 * 1000 + 2 * 1000 },
{ value: '1.70', expect: 60 * 1000 + 70 * 1000 },
{ 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 },
];
for (const s of ts) {
test(`it handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
@@ -0,0 +1,410 @@
import {formatEventList, getEventsWithDelay, trimEventlist} from "../eventsManager";
test('getEventsWithDelay function', () => {
const testData = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"duration": 60000,
"type": "delay",
"id": "24240"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"type": "event",
"id": "8ee5"
},
{
"title": "Use simpler times to create a timer",
"timeStart": 120000,
"timeEnd": 720000,
"type": "event",
"id": "8222"
},
{
"duration": 900000,
"type": "delay",
"revision": 0,
"id": "a386"
},
{
"title": "Add delay blocks to affect all events",
"timeStart": 37320000,
"timeEnd": 38520000,
"type": "event",
"id": "6dce"
},
{
"title": "Add and remove events with [+] and [-]",
"timeStart": 38520000,
"timeEnd": 45120000,
"type": "event",
"id": "2651"
},
{
"type": "block",
"id": "e6a1"
},
{
"title": "And control whether they are public",
"timeStart": 46800000,
"timeEnd": 57600000,
"type": "event",
"id": "1358"
}
];
const expected = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000+60000,
"timeEnd": 35520000+60000,
"type": "event",
"id": "8ee5"
},
{
"title": "Use simpler times to create a timer",
"timeStart": 120000+60000,
"timeEnd": 720000+60000,
"type": "event",
"id": "8222"
},
{
"title": "Add delay blocks to affect all events",
"timeStart": 37320000+60000+900000,
"timeEnd": 38520000+60000+900000,
"type": "event",
"id": "6dce"
},
{
"title": "Add and remove events with [+] and [-]",
"timeStart": 38520000+60000+900000,
"timeEnd": 45120000+60000+900000,
"type": "event",
"id": "2651"
},
{
"title": "And control whether they are public",
"timeStart": 46800000,
"timeEnd": 57600000,
"type": "event",
"id": "1358"
}
]
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
describe('getEventsWithDelay edge cases', () => {
test('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
}
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
test('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
}
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
test('given a corrupted event object', () => {
const testData = [
{
"title": "Welcome to Ontime",
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"duration": 60000,
"type": "delay",
"id": "24240"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"type": "event",
"id": "8ee5"
}
];
const expected = [
{
"title": "Welcome to Ontime",
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000+60000,
"timeEnd": 35520000+60000,
"type": "event",
"id": "8ee5"
}
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('given a corrupted delay object', () => {
const testData = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"type": "delay",
"id": "24240"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"type": "event",
"id": "8ee5"
}
];
const expected = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"type": "event",
"id": "8ee5"
}
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{id: '9'},
{id: '10'},
{id: '11'},
{id: '12'},
];
test('when we use the first item', () => {
const selectedId = '1';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
];
const l = trimEventlist(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
test('when we use the third item', () => {
const selectedId = '3';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'}
];
const l = trimEventlist(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
test('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{id: '9'}
];
const l = trimEventlist(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
test('if selected is not found', () => {
const selectedId = '15';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
];
const l = trimEventlist(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
"title": "Welcome to Ontime",
"subtitle": "Subtitles are useful",
"presenter": "cpvalente",
"note": "Maybe a running note for the operator?",
"timeStart": 28800000,
"timeEnd": 30600000,
"isPublic": false,
"type": "event",
"revision": 0,
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"subtitle": "",
"presenter": "",
"note": "In green, below",
"timeStart": 34800000,
"timeEnd": 35400000,
"isPublic": false,
"type": "event",
"revision": 0,
"id": "8ee5"
}
];
test ('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
},
]
const parsed = formatEventList(testEvent, selectedId, nextId, true);
expect(parsed).toStrictEqual(expected);
});
test ('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
},
]
const parsed = formatEventList(testEvent, selectedId, nextId,true);
expect(parsed).toStrictEqual(expected);
});
test ('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
},
]
const parsed = formatEventList(testEvent, selectedId, nextId, true);
expect(parsed).toStrictEqual(expected);
});
})
+113
View File
@@ -0,0 +1,113 @@
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
/**
* another go at simpler string formatting (counters)
* @description Converts seconds to string representing time
* @param {number} seconds - time in seconds
* @param {boolean} [hideZero] - whether to show hours in case its 00
* @returns {string} String representing absolute time 00:12:02
*/
export function formatDisplay(seconds, hideZero = false) {
// add an extra 0 if necessary
const format = (val) => `0${Math.floor(val)}`.slice(-2);
const s = Math.abs(seconds);
const hours = Math.floor((s / 3600) % 24);
const minutes = Math.floor((s % 3600) / 60);
if (hideZero && hours < 1) return [minutes, s % 60].map(format).join(':');
else return [hours, minutes, s % 60].map(format).join(':');
}
/**
* @description Converts milliseconds to seconds
* @param {number} millis - time in seconds
* @returns {number} Amount in seconds
*/
export const millisToSeconds = (millis) => {
return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts);
};
/**
* @description Converts milliseconds to seconds
* @param {number} millis - time in seconds
* @returns {number} Amount in seconds
*/
export const millisToMinutes = (millis) => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/**
* @description Converts timestring to milliseconds
* @param {string} string - time string "23:00:12"
* @returns {number} Amount in milliseconds
*/
export const timeStringToMillis = (string) => {
if (typeof string !== 'string') return 0;
const time = string.split(':');
if (time.length === 1) return Math.abs(time[0] * mts);
if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts;
if (time.length === 3) return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
else return 0;
};
/**
* @description Validates a time string
* @param {string} string - time string "23:00:12"
* @returns {boolean} string represents time
*/
export const isTimeString = (string) => {
// ^ # Start of string
// (?: # Try to match...
// (?: # Try to match...
// ([01]?\d|2[0-3]): # HH:
// )? # (optionally).
// ([0-5]?\d): # MM: (required)
// )? # (entire group optional, so either HH:MM:, MM: or nothing)
// ([0-5]?\d) # SS (required)
// $ # End of string
const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/;
return regex.test(string);
};
const parse = (valueAsString) => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Parses a time string to millis
* @param string - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (string) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = string.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (third == null) {
// if string has two sections, treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
} else if (second == null) {
// if string has one section, treat as [minutes]
millis = parse(first) * mtm;
}
return millis;
};
+84
View File
@@ -0,0 +1,84 @@
import { stringFromMillis } from 'ontime-utils/time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} events - given events
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (events) => {
if (events == null) return [];
const unfilteredEvents = [...events];
// Add running delay
let delay = 0;
for (const e of unfilteredEvents) {
if (e.type === 'block') delay = 0;
else if (e.type === 'delay') delay = delay + e.duration;
else if (e.type === 'event' && delay > 0) {
e.timeStart += delay;
e.timeEnd += delay;
}
}
// filter just events
return unfilteredEvents.filter((e) => e.type === 'event');
};
/**
* @description Returns trimmed event list array
* @param {Object[]} events - given events
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimEventlist = (events, selectedId, limit) => {
if (events == null) return [];
const BEFORE = 2;
const trimmedEvents = [...events];
// limit events length if necessary
if (limit != null) {
while (trimmedEvents.length > limit) {
const idx = trimmedEvents.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) { trimmedEvents.pop(); }
else { trimmedEvents.shift(); }
}
}
return trimmedEvents;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} events - given events
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {boolean} [showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (events, selectedId, nextId, showEnd = false) => {
if (events == null) return [];
const givenEvents = [...events];
// format list
let formattedEvents = [];
for (const g of givenEvents) {
const start = stringFromMillis(g.timeStart, false);
const end = stringFromMillis(g.timeEnd, false);
formattedEvents.push({
id: g.id,
time: showEnd ? `${start} - ${end}` : start,
title: g.title,
isNow: g.id === selectedId,
isNext: g.id === nextId,
});
}
return formattedEvents;
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Handles link to external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export default function handleLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
+59 -33
View File
@@ -2,7 +2,8 @@ import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useEffect, useState } from 'react';
import { useSocket } from 'app/context/socketContext';
import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn';
import style from './MessageControl.module.css';
import OnAirIconBtn from '../../common/components/buttons/OnAirIconBtn';
import style from './MessageControl.module.scss';
const inputProps = {
size: 'sm',
@@ -49,12 +50,13 @@ export default function MessageControl() {
text: '',
visible: false,
});
const [onAir, setonAir] = useState(false);
useEffect(() => {
if (socket == null) return;
// Handle presenter messages
socket.on('messages-presenter', (data) => {
// Handle timer messages
socket.on('messages-timer', (data) => {
setPres({ ...data });
});
@@ -68,24 +70,32 @@ export default function MessageControl() {
setLower({ ...data });
});
// Handle lower third messages
socket.on('onAir', (data) => {
setonAir(data);
});
// Ask for up to date data
socket.emit('get-messages');
// Ask for onAir state
socket.emit('get-onAir');
// Clear listeners
return () => {
socket.off('messages-public');
socket.off('messages-presenter');
socket.off('messages-timer');
socket.off('messages-lower');
socket.off('onAir');
};
}, [socket]);
const messageControl = async (action, payload) => {
switch (action) {
case 'pres-text':
socket.emit('set-presenter-text', payload);
socket.emit('set-timer-text', payload);
break;
case 'toggle-pres-visible':
socket.emit('set-presenter-visible', !pres.visible);
socket.emit('set-timer-visible', !pres.visible);
break;
case 'publ-text':
socket.emit('set-public-text', payload);
@@ -99,38 +109,54 @@ export default function MessageControl() {
case 'toggle-lower-visible':
socket.emit('set-lower-visible', !lower.visible);
break;
case 'toggle-onAir':
socket.emit('set-onAir', !onAir);
break;
default:
break;
}
};
return (
<div className={style.messageContainer}>
<InputRow
label='Presenter screen message'
placeholder='only the presenter screens see this'
text={pres.text}
visible={pres.visible}
changeHandler={(event) => messageControl('pres-text', event)}
actionHandler={() => messageControl('toggle-pres-visible')}
/>
<InputRow
label='Public screen message'
placeholder='public screens will render this'
text={publ.text}
visible={publ.visible}
changeHandler={(event) => messageControl('publ-text', event)}
actionHandler={() => messageControl('toggle-publ-visible')}
/>
<InputRow
label='Lower third message'
placeholder='visible in lower third screen'
text={lower.text}
visible={lower.visible}
changeHandler={(event) => messageControl('lower-text', event)}
actionHandler={() => messageControl('toggle-lower-visible')}
/>
</div>
<>
<div className={style.messageContainer}>
<InputRow
label='Presenter screen message'
placeholder='only the presenter screens see this'
text={pres.text}
visible={pres.visible}
changeHandler={(event) => messageControl('pres-text', event)}
actionHandler={() => messageControl('toggle-pres-visible')}
/>
<InputRow
label='Public screen message'
placeholder='public screens will render this'
text={publ.text}
visible={publ.visible}
changeHandler={(event) => messageControl('publ-text', event)}
actionHandler={() => messageControl('toggle-publ-visible')}
/>
<InputRow
label='Lower third message'
placeholder='visible in lower third screen'
text={lower.text}
visible={lower.visible}
changeHandler={(event) => messageControl('lower-text', event)}
actionHandler={() => messageControl('toggle-lower-visible')}
/>
</div>
<div className={style.onAirToggle}>
<OnAirIconBtn
className={style.btn}
active={onAir}
size='md'
actionHandler={() => messageControl('toggle-onAir')}
/>
<span className={style.onAirLabel}>On Air</span>
<span className={style.oscLabel}>
{`/ontime/offAir << OSC >> /ontime/onAir`}
</span>
</div>
</>
);
}
@@ -1,32 +0,0 @@
.messageContainer {
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 0.5em;
padding: 0.5em;
}
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
gap: 1em;
}
.label {
padding: 0;
margin: 0;
font-size: 0.9em;
color: #ccc;
}
.inline {
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.padleft {
padding-left: 0.5em;
}
@@ -0,0 +1,62 @@
@use '../../styles/main' as *;
@use '../../styles/mixins' as *;
.messageContainer,
.onAirToggle {
@include main-container;
display: flex;
gap: 0.5em;
padding: 0.5em;
}
.messageContainer {
flex-direction: column;
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
gap: 1em;
}
.label {
padding: 0;
margin: 0;
font-size: 0.9em;
color: #ccc;
}
.inline {
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.padleft {
padding-left: 0.5em;
}
}
.onAirToggle {
margin-top: 1em;
align-items: center;
display: grid;
grid-template-areas:
'btn label'
'btn osc';
grid-template-columns: 2.5em 1fr;
grid-template-rows: 1.2em 0.8em;
.btn {
grid-area: btn;
}
.onAirLabel {
grid-area: label;
font-size: 1.2em;
}
.oscLabel {
@include osc-label;
grid-area: osc;
}
}
+47 -12
View File
@@ -1,5 +1,6 @@
import { memo } from 'react';
import style from './PlaybackControl.module.css';
import PropTypes from 'prop-types';
import style from './PlaybackControl.module.scss';
import StartIconBtn from 'common/components/buttons/StartIconBtn';
import PauseIconBtn from 'common/components/buttons/PauseIconBtn';
import PrevIconBtn from 'common/components/buttons/PrevIconBtn';
@@ -10,57 +11,77 @@ import ReloadIconButton from 'common/components/buttons/ReloadIconBtn';
const areEqual = (prevProps, nextProps) => {
return (
prevProps.playback === nextProps.playback &&
prevProps.selectedId === nextProps.selectedId
prevProps.playback === nextProps.playback
&& prevProps.selectedId === nextProps.selectedId
&& prevProps.noEvents === nextProps.noEvents
);
};
const Playback = ({ playback, selectedId, playbackControl }) => {
const Playback = (props) => {
const { playback, selectedId, playbackControl, noEvents } = props;
const isRolling = playback === 'roll';
return (
<div className={style.playbackContainer}>
<StartIconBtn
active={playback === 'start'}
clickhandler={() => playbackControl('start')}
disabled={!selectedId}
disabled={!selectedId || isRolling || noEvents}
/>
<PauseIconBtn
active={playback === 'pause'}
clickhandler={() => playbackControl('pause')}
disabled={!selectedId}
disabled={!selectedId || isRolling || noEvents || playback !== 'start'}
/>
<RollIconBtn
active={playback === 'roll'}
disabled={playback === 'roll' || noEvents}
clickhandler={() => playbackControl('roll')}
/>
</div>
);
};
const Transport = ({ selectedId, playbackControl }) => {
const Transport = (props) => {
const { playback, selectedId, playbackControl, noEvents } = props;
const isRolling = playback === 'roll';
return (
<div className={style.playbackContainer}>
<PrevIconBtn clickhandler={() => playbackControl('previous')} />
<NextIconBtn clickhandler={() => playbackControl('next')} />
<UnloadIconBtn clickhandler={() => playbackControl('unload')} />
<PrevIconBtn
clickhandler={() => playbackControl('previous')}
disabled={isRolling || noEvents}
/>
<NextIconBtn
clickhandler={() => playbackControl('next')}
disabled={isRolling || noEvents}
/>
<ReloadIconButton
clickhandler={() => playbackControl('reload')}
disabled={!selectedId}
disabled={selectedId == null || isRolling || noEvents}
/>
<UnloadIconBtn
clickhandler={() => playbackControl('unload')}
disabled={(selectedId == null && !isRolling) || noEvents}
/>
</div>
);
};
const PlaybackButtons = (props) => {
const { playback, selectedId } = props;
const { playback, selectedId, noEvents } = props;
return (
<>
<Playback
playback={playback}
selectedId={selectedId}
noEvents={noEvents}
playbackControl={props.playbackControl}
/>
<Transport
playback={playback}
selectedId={selectedId}
noEvents={noEvents}
playbackControl={props.playbackControl}
/>
</>
@@ -68,3 +89,17 @@ const PlaybackButtons = (props) => {
};
export default memo(PlaybackButtons, areEqual);
PlaybackButtons.propTypes = {
playback: PropTypes.string,
selectedId: PropTypes.string,
playbackControl: PropTypes.func.isRequired,
noEvents: PropTypes.bool.isRequired,
};
Transport.propTypes = {
playback: PropTypes.string,
selectedId: PropTypes.string,
playbackControl: PropTypes.func.isRequired,
noEvents: PropTypes.bool.isRequired,
};
@@ -1,4 +1,4 @@
import style from './PlaybackControl.module.css';
import style from './PlaybackControl.module.scss';
import { useEffect, useState } from 'react';
import { useSocket } from 'app/context/socketContext';
import PlaybackButtons from './PlaybackButtons';
@@ -12,14 +12,17 @@ export default function PlaybackControl() {
running: null,
startedAt: null,
expectedFinish: null,
secondary: null,
});
const [selectedId, setSelectedId] = useState(null);
const [numEvents, setNumEvents] = useState(0);
const resetTimer = () => {
setTimer({
running: null,
startedAt: null,
expectedFinish: null,
secondary: null,
});
};
@@ -30,6 +33,7 @@ export default function PlaybackControl() {
socket.emit('get-timer');
socket.emit('get-playstate');
socket.emit('get-selected-id');
socket.emit('get-numevents');
// Handle playstate
socket.on('playstate', (data) => {
@@ -46,11 +50,16 @@ export default function PlaybackControl() {
setSelectedId(data);
});
socket.on('numevents', (data) => {
setNumEvents(data);
});
// Clear listener
return () => {
socket.off('playstate');
socket.off('timer');
socket.off('selected-id');
socket.off('numevents');
};
}, [socket]);
@@ -91,11 +100,13 @@ export default function PlaybackControl() {
<PlaybackTimer
timer={timer}
playback={playback}
selectedId={selectedId}
handleIncrement={(amount) => socket.emit('increment-timer', amount)}
/>
<PlaybackButtons
playback={playback}
selectedId={selectedId}
noEvents={numEvents < 1}
playbackControl={playbackControl}
/>
</div>
@@ -20,13 +20,15 @@
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 2fr 2fr 22%;
grid-template-columns: 1.5em 2fr 2fr 6em;
gap: 5px;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
@@ -95,6 +97,10 @@
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: #ccc;
font-size: 1.1em;
@@ -105,6 +111,11 @@
font-size: 0.9em;
}
.rolltag {
color: #2b6cb0;
font-size: 0.9em;
}
.playbackContainer {
display: flex;
justify-content: space-evenly;
+92 -41
View File
@@ -1,25 +1,30 @@
import style from './PlaybackControl.module.css';
import style from './PlaybackControl.module.scss';
import Countdown from 'common/components/countdown/Countdown';
import { stringFromMillis } from 'common/dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
import { Tooltip } from '@chakra-ui/react';
import { Button } from '@chakra-ui/button';
import { memo } from 'react';
import PropTypes from 'prop-types';
const areEqual = (prevProps, nextProps) => {
return (
prevProps.timer.running === nextProps.timer.running &&
prevProps.timer.isNegative === nextProps.timer.isNegative &&
prevProps.timer.expectedFinish === nextProps.timer.expectedFinish &&
prevProps.timer.startedAt === nextProps.timer.startedAt &&
prevProps.playback === nextProps.playback
prevProps.playback === nextProps.playback &&
prevProps.timer.secondary === nextProps.timer.secondary &&
prevProps.selectedId === nextProps.selectedId
);
};
const PlaybackTimer = (props) => {
const { timer, playback, handleIncrement } = props;
const { timer, playback, handleIncrement, selectedId } = props;
const started = stringFromMillis(timer.startedAt, true);
const finish = stringFromMillis(timer.expectedFinish, true);
const isNegative = timer.running < 0;
const isDelayed = false;
const isRolling = playback === 'roll';
const isWaiting = timer.secondary > 0 && timer.running == null;
const disableButtons = selectedId == null || isRolling;
const incrementProps = {
size: 'sm',
@@ -33,52 +38,91 @@ const PlaybackTimer = (props) => {
<>
<div className={style.timeContainer}>
<div className={style.indicators}>
<div className={isRolling ? style.indRollActive : style.indRoll} />
<Tooltip label='Roll mode active'>
<div className={isRolling ? style.indRollActive : style.indRoll} />
</Tooltip>
<div
className={isNegative ? style.indNegativeActive : style.indNegative}
className={timer.isNegative ? style.indNegativeActive : style.indNegative}
/>
<div className={style.indDelay} />
</div>
<div className={style.timer}>
<Countdown time={timer?.running} small negative={isNegative} />
</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}>Finish at </span>
<span className={style.time}>{finish}</span>
<Countdown
time={isWaiting ? timer.secondary : timer.running}
isNegative={timer.isNegative}
small
/>
</div>
{isWaiting ? (
<div className={style.roll}>
<span className={style.rolltag}>Roll: Countdown to start</span>
<span className={style.time}>{''}</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}>Finish at </span>
<span className={style.time}>{finish}</span>
</div>
</>
)}
<div className={style.btn}>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(-1)}
<Tooltip
label={'Remove 1 minute'}
delay={500}
shouldWrapChildren={disableButtons}
>
-1
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(1)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(-1)}
>
-1
</Button>
</Tooltip>
<Tooltip
label={'Add 1 minute'}
delay={500}
shouldWrapChildren={disableButtons}
>
+1
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(-5)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(1)}
>
+1
</Button>
</Tooltip>
<Tooltip
label={'Remove 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
>
-5
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(5)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(-5)}
>
-5
</Button>
</Tooltip>
<Tooltip
label={'Add 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
>
+5
</Button>
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(5)}
>
+5
</Button>
</Tooltip>
</div>
</div>
</>
@@ -86,3 +130,10 @@ const PlaybackTimer = (props) => {
};
export default memo(PlaybackTimer, areEqual);
PlaybackTimer.propTypes = {
timer: PropTypes.object.isRequired,
playback: PropTypes.string,
handleIncrement: PropTypes.func.isRequired,
selectedId: PropTypes.string,
};
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import SocketProvider from 'app/context/socketContext';
import MessageControl from '../MessageControl';
// need to inject the socket provider to make component
// render without failing
const MockMessageControl = () => {
return (
<SocketProvider>
<MessageControl />
</SocketProvider>
);
};
describe('Message Control input blocks', () => {
test('Presenter dialog', async () => {
// Presenter dialog and button
// substring match, ignore case
render(<MockMessageControl />);
expect(screen.getByPlaceholderText(/presenter/i)).toBeInTheDocument();
});
test('Public dialog', async () => {
// Public dialog and button
// substring match, ignore case
render(<MockMessageControl />);
expect(screen.getByPlaceholderText(/public/i)).toBeInTheDocument();
});
test('Lower third', async () => {
// Lower third dialog and button
// substring match, ignore case
render(<MockMessageControl />);
expect(screen.getByPlaceholderText(/lower third/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import SocketProvider from 'app/context/socketContext';
import PlaybackControl from '../PlaybackControl';
test('check that playback control renders', async () => {
// need to inject the socket provider to make component
// render without failing
render(
<SocketProvider>
<PlaybackControl />
</SocketProvider>
);
// Text labels for times
// substring match, ignore case
expect(screen.getByText(/started/i)).toBeInTheDocument();
expect(screen.getByText(/finish/i)).toBeInTheDocument();
});
@@ -1,8 +1,9 @@
import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ActionButtons from './ActionButtons';
import ActionButtons from '../list/ActionButtons';
import style from './BlockBlock.module.css';
import PropTypes from 'prop-types';
export default function BlockBlock(props) {
const { index, data, actionHandler } = props;
@@ -27,3 +28,10 @@ export default function BlockBlock(props) {
</Draggable>
);
}
BlockBlock.propTypes = {
index: PropTypes.number.isRequired,
data: PropTypes.object.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,11 +1,12 @@
import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi';
import { millisToMinutes } from 'common/dateConfig';
import ActionButtons from './ActionButtons';
import { millisToMinutes } from 'common/utils/dateConfig';
import ActionButtons from '../list/ActionButtons';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ApplyIconBtn from 'common/components/buttons/ApplyIconBtn';
import DelayInput from 'common/input/DelayInput';
import style from './DelayBlock.module.css';
import PropTypes from 'prop-types';
export default function DelayBlock(props) {
const { eventsHandler, data, index, actionHandler } = props;
@@ -14,25 +15,15 @@ export default function DelayBlock(props) {
eventsHandler('applyDelay', { id: data.id, duration: data.duration });
};
let delayValue =
data.duration != null ? millisToMinutes(data.duration) : undefined;
let delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div
className={style.delay}
{...provided.draggableProps}
ref={provided.innerRef}
>
<div className={style.delay} {...provided.draggableProps} ref={provided.innerRef}>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
</span>
<DelayInput
className={style.input}
value={delayValue}
actionHandler={actionHandler}
/>
<DelayInput className={style.input} value={delayValue} actionHandler={actionHandler} />
<div className={style.actionOverlay}>
<ApplyIconBtn clickhandler={applyDelayHandler} />
<DeleteIconBtn actionHandler={actionHandler} />
@@ -43,3 +34,10 @@ export default function DelayBlock(props) {
</Draggable>
);
}
DelayBlock.propTypes = {
eventsHandler: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
+24 -10
View File
@@ -1,9 +1,11 @@
import { lazy, useEffect } from 'react';
import { Box } from '@chakra-ui/layout';
import { useDisclosure } from '@chakra-ui/hooks';
import styles from './Editor.module.css';
import SettingsModal from 'features/modals/SettingsModal';
import styles from './Editor.module.scss';
import MenuBar from 'features/menu/MenuBar';
import ModalManager from 'features/modals/ModalManager';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
import { LoggingProvider } from '../../app/context/LoggingContext';
const EventListWrapper = lazy(() =>
import('features/editors/list/EventListWrapper')
@@ -21,42 +23,54 @@ export default function Editor() {
}, []);
return (
<>
<SettingsModal isOpen={isOpen} onClose={onClose} />
<LoggingProvider>
<ErrorBoundary>
<ModalManager isOpen={isOpen} onClose={onClose} />
</ErrorBoundary>
<div className={styles.mainContainer}>
<Box id='settings' className={styles.settings}>
<MenuBar onOpen={onOpen} />
<ErrorBoundary>
<MenuBar onOpen={onOpen} isOpen={isOpen} />
</ErrorBoundary>
</Box>
<Box className={styles.editor}>
<h1>Event List</h1>
<div className={styles.content}>
<EventListWrapper />
<ErrorBoundary>
<EventListWrapper />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.messages}>
<h1>Display Messages</h1>
<div className={styles.content}>
<MessageControl />
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.playback}>
<h1>Timer Control</h1>
<div className={styles.content}>
<PlaybackControl />
<ErrorBoundary>
<PlaybackControl />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.info}>
<h1>Info</h1>
<div className={styles.content}>
<Info />
<ErrorBoundary>
<Info />
</ErrorBoundary>
</div>
</Box>
</div>
</>
</LoggingProvider>
);
}
@@ -4,23 +4,23 @@
height: 100%;
margin: auto;
color: #fffd;
padding: 2vh;
padding: max(16px, 2vh);
display: grid;
grid-template-rows: 38vh 1fr;
grid-template-columns: 40px 48em 27vw 1fr;
grid-template-rows: auto 1fr;
grid-template-columns: 40px 48em 31em auto;
grid-template-areas:
'sett even play info'
'sett even mess info';
gap: 2vh;
gap: max(16px, 2vh);
}
/* 2/3 window, hide previews */
@media (max-width: 1250px) and (min-height: 700px) {
/* 2/3 window, hide info */
@media (max-width: 1450px) and (min-height: 700px) {
.mainContainer {
height: 100%;
grid-template-rows: 1fr 1fr;
grid-template-columns: 40px 48em 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-columns: 40px 48em auto;
/* grid-template-areas:
'even play sett'
'even mess sett'; */
@@ -31,19 +31,38 @@
}
}
/* 1/2 window, event list only */
@media (max-width: 1100px) {
.mainContainer {
height: 100%;
grid-template-rows: 1fr auto;
grid-template-columns: 40px 48em;
/* grid-template-areas:
'sett even '
'sett play '; */
}
.info,
.messages,
.playback {
visibility: hidden;
}
}
/* 1/3 window, show control only */
@media (max-width: 750px) and (min-height: 500px) {
@media (max-width: 850px) and (min-height: 500px) {
.mainContainer {
grid-template-rows: auto 1fr;
grid-template-columns: 100%;
column-gap: 0;
grid-template-areas:
'mess'
'play';
'play'
'mess';
}
.messages,
.playback {
min-width: 31em;
.playback,
.messages {
visibility: visible;
}
.editor,
@@ -54,13 +73,17 @@
}
/* 1/3 corner window, playback only */
@media (max-width: 900px) and (max-height: 500px) {
@media (max-width: 850px) and (max-height: 500px) {
.mainContainer {
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-template-rows: 100%;
grid-template-columns: 100%;
grid-template-areas: 'play';
}
.playback {
visibility: visible;
}
.editor,
.messages,
.info,
@@ -70,7 +93,7 @@
}
h1 {
font-size: 2.5vh;
font-size: max(1.5em, 16px);
color: rgba(255, 255, 255, 0.63);
padding-bottom: 0.25em;
}
@@ -87,10 +110,23 @@ h1 {
.editor {
grid-area: even;
.content {
height: calc(100% - 3em);
overflow: hidden;
}
}
.info {
grid-area: info;
min-width: 17em;
.content {
display: flex;
flex-direction: column;
height: calc(100% - 3em);
overflow: hidden;
}
}
.messages {
@@ -99,6 +135,13 @@ h1 {
.playback {
grid-area: play;
min-height: 320px;
max-height: 320px;
}
.messages,
.playback {
min-width: 31em;
}
.mainContainer > .settings {
@@ -114,7 +157,7 @@ h1 {
}
.content {
padding-top: 1.5em;
padding-top: 0.5em;
}
.cornerButtonContainer {
@@ -129,25 +172,3 @@ h1 {
display: flex;
justify-content: flex-end;
}
/* width */
::-webkit-scrollbar {
width: 0.5em;
}
/* Track */
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.01);
border-radius: 4px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
@@ -1,23 +1,23 @@
import Icon from '@chakra-ui/icon';
import { FiChevronDown, FiChevronUp, FiMoreVertical } from 'react-icons/fi';
import { FiChevronUp, FiMoreVertical } from 'react-icons/fi';
import { useMemo } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import EventTimes from 'common/components/eventTimes/EventTimes';
import EventTimesVertical from 'common/components/eventTimes/EventTimesVertical';
import EditableText from 'common/input/EditableText';
import ActionButtons from './ActionButtons';
import ActionButtons from '../list/ActionButtons';
import PublicIconBtn from 'common/components/buttons/PublicIconBtn';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import { millisToMinutes } from 'common/dateConfig';
import { millisToMinutes } from 'common/utils/dateConfig';
import style from './EventBlock.module.css';
import { SelectCollapse, HandleCollapse } from 'app/context/collapseAtom';
import { HandleCollapse, SelectCollapse } from 'app/context/collapseAtom';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
const ExpandedBlock = (props) => {
const { provided, data, eventIndex, next, delay, delayValue, actionHandler } =
props;
const { provided, data, eventIndex, next, delay, delayValue, previousEnd, actionHandler } = props;
const oscid = data.id.length > 4 ? '...' : data.id;
const oscid = data?.id || '...';
// if end is before, assume is the day after
const duration =
@@ -28,14 +28,12 @@ const ExpandedBlock = (props) => {
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && (
<span className={style.delayValue}>+ {delayValue}</span>
)}
{delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
</div>
<div className={style.timeExpanded}>
<EventTimesVertical
@@ -44,6 +42,7 @@ const ExpandedBlock = (props) => {
timeEnd={data.timeEnd}
duration={duration}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
</div>
@@ -53,80 +52,71 @@ const ExpandedBlock = (props) => {
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) =>
actionHandler('update', { field: 'title', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
<EditableText
label='Presenter'
defaultValue={data.presenter}
placeholder='Add Presenter name'
submitHandler={(v) =>
actionHandler('update', { field: 'presenter', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'presenter', value: v })}
/>
<EditableText
label='Subtitle'
defaultValue={data.subtitle}
placeholder='Add Subtitle'
submitHandler={(v) =>
actionHandler('update', { field: 'subtitle', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'subtitle', value: v })}
/>
<EditableText
label='Note'
defaultValue={data.note}
placeholder='Add Note'
style={{ color: '#d69e2e' }}
submitHandler={(v) =>
actionHandler('update', { field: 'note', value: v })
}
maxchar={160}
submitHandler={(v) => actionHandler('update', { field: 'note', value: v })}
/>
<span className={style.oscLabel}>
{`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
</span>
</div>
<Icon
className={style.more}
as={FiChevronUp}
marginTop='0.2em'
gridArea='more'
onClick={() => props.setCollapsed(true)}
/>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
<DeleteIconBtn actionHandler={actionHandler} />
</div>
</>
);
};
ExpandedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
eventIndex: PropTypes.number.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.number,
delayValue: PropTypes.number,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
const CollapsedBlock = (props) => {
const { provided, data, next, delay, delayValue, actionHandler } = props;
const { provided, data, next, delay, delayValue, previousEnd, actionHandler } = props;
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && (
<span className={style.delayValue}>+ {delayValue}</span>
)}
{delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
</div>
<EventTimes
actionHandler={actionHandler}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
<div className={style.titleContainer}>
@@ -134,40 +124,32 @@ const CollapsedBlock = (props) => {
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) =>
actionHandler('update', { field: 'title', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
</div>
<Icon
className={style.more}
as={FiChevronDown}
marginTop='0.2em'
gridArea='more'
onClick={() => props.setCollapsed(false)}
/>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
</div>
</>
);
};
CollapsedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.any,
delayValue: PropTypes.any,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
export default function EventBlock(props) {
const { data, selected, delay, index, eventIndex, actionHandler } = props;
const [collapsed] = useAtom(
useMemo(() => SelectCollapse(data.id), [data.id])
);
const { data, selected, delay, index, eventIndex, previousEnd, actionHandler } = props;
const [collapsed] = useAtom(useMemo(() => SelectCollapse(data.id), [data.id]));
const [, setCollapsed] = useAtom(HandleCollapse);
// TODO: should this go inside useEffect()
// Would I then need to add this to state?
const isSelected = selected ? style.active : '';
const isCollapsed = collapsed ? style.collapsed : style.expanded;
const classSelect = `${style.event} ${isCollapsed} ${isSelected}`;
@@ -182,11 +164,12 @@ export default function EventBlock(props) {
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div
className={classSelect}
{...provided.draggableProps}
ref={provided.innerRef}
>
<div className={classSelect} {...provided.draggableProps} ref={provided.innerRef}>
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={() => handleCollapse(!collapsed)}
/>
{collapsed ? (
<CollapsedBlock
provided={provided}
@@ -194,8 +177,8 @@ export default function EventBlock(props) {
next={props.next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
setCollapsed={handleCollapse}
/>
) : (
<ExpandedBlock
@@ -205,8 +188,8 @@ export default function EventBlock(props) {
next={props.next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
setCollapsed={handleCollapse}
/>
)}
</div>
@@ -214,3 +197,13 @@ export default function EventBlock(props) {
</Draggable>
);
}
EventBlock.propTypes = {
data: PropTypes.object.isRequired,
selected: PropTypes.bool.isRequired,
delay: PropTypes.number,
index: PropTypes.number.isRequired,
eventIndex: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -30,6 +30,7 @@
grid-template-columns: 2em 2em auto auto 1fr auto 3.7em;
grid-template-areas: 'drag indi time time text more btns';
justify-content: center;
align-items: center;
}
.expanded {
@@ -59,6 +60,7 @@
align-self: flex-start;
font-size: 0.75em;
overflow: hidden;
min-height: 3em;
min-width: 0;
justify-content: center;
}
@@ -95,6 +97,7 @@
.time {
grid-area: time;
align-self: center;
}
.start {
@@ -113,7 +116,6 @@
grid-area: time;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-start;
justify-items: flex-start;
@@ -137,13 +139,22 @@
.titleContainer {
grid-area: text;
height: 100%;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
width: 100%;
display: block;
white-space: nowrap;
overflow: hidden;
}
.collapsed .titleContasiner {
height: fit-content;
}
.expanded .titleContainer {
height: 100%;
}
.oscLabel {
@@ -157,9 +168,25 @@
/* ================ MORE ================ */
.more {
.moreExpanded,
.moreCollapsed {
cursor: pointer;
color: #fff;
grid-area: more;
margin-top: 0.2em;
align-self: baseline;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform 0.3s;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform 0.3s;
}
/* ============== ACTION ================ */
.actionOverlay {
@@ -1,6 +1,7 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { IconButton } from '@chakra-ui/button';
import { FiPlus, FiMinusCircle, FiClock } from 'react-icons/fi';
import { FiClock, FiMinusCircle, FiPlus } from 'react-icons/fi';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ActionButtons(props) {
const { showAdd, showDelay, showBlock, actionHandler } = props;
@@ -12,23 +13,25 @@ export default function ActionButtons(props) {
return (
<Menu isLazy lazyBehavior='unmount'>
<MenuButton
as={IconButton}
aria-label='Options'
size='xs'
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
<Tooltip label='Add ...' delay={500}>
<MenuButton
as={IconButton}
aria-label='Options'
size='xs'
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
</Tooltip>
<MenuList style={menuStyle}>
<MenuItem
icon={<FiPlus />}
onClick={() => actionHandler('event')}
isDisabled={!showAdd}
>
Event next
Add Event after
</MenuItem>
<MenuItem
@@ -36,14 +39,14 @@ export default function ActionButtons(props) {
onClick={() => actionHandler('delay')}
isDisabled={!showDelay}
>
Delay next
Add Delay after
</MenuItem>
<MenuItem
icon={<FiMinusCircle />}
onClick={() => actionHandler('block')}
isDisabled={!showBlock}
>
Block next
Add Block after
</MenuItem>
</MenuList>
</Menu>
+53 -31
View File
@@ -1,7 +1,6 @@
import style from './List.module.css';
import { createRef, useEffect, useMemo, useState } from 'react';
import style from './List.module.scss';
import { createRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useSocket } from 'app/context/socketContext';
import tinykeys from 'tinykeys';
import Empty from 'common/state/Empty';
import EventListItem from './EventListItem';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
@@ -19,37 +18,56 @@ export default function EventList(props) {
const cursorRef = createRef();
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(e) => {
// handle held key
if (e.repeat) return;
// Check if the alt key is pressed
if (e.altKey && (!e.ctrlKey || !e.shiftKey)) {
// Arrow down
if (e.keyCode === 40) {
if (cursor == null) setCursor(0);
else if (cursor < events.length - 1) setCursor(cursor + 1);
}
// Arrow up
if (e.keyCode === 38) {
if (cursor == null) setCursor(0);
else if (cursor > 0) setCursor(cursor - 1);
}
// E
if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'event', order: cursor + 1 });
}
// D
if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'delay', order: cursor + 1 });
}
// B
if (e.key === 'b' || e.key === 'B') {
e.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'block', order: cursor + 1 });
}
}
},
[cursor, events, eventsHandler]
);
useEffect(() => {
let unsubscribe = tinykeys(window, {
'Alt+ArrowDown': () => {
if (cursor == null) setCursor(0);
else if (cursor < events.length - 1) setCursor(cursor + 1);
},
'Alt+ArrowUp': () => {
if (cursor == null) setCursor(0);
else if (cursor > 0) setCursor(cursor - 1);
},
'Alt+KeyE': (event) => {
event.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'event', order: cursor + 1 });
},
'Alt+KeyD': (event) => {
event.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'delay', order: cursor + 1 });
},
'Alt+KeyB': (event) => {
event.preventDefault();
if (cursor == null) return;
eventsHandler('add', { type: 'block', order: cursor + 1 });
},
});
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
if (cursor > events.length - 1) setCursor(events.length - 1);
// remove the event listener
return () => {
unsubscribe();
document.removeEventListener('keydown', handleKeyPress);
};
}, [cursor, events, eventsHandler]);
}, [handleKeyPress, cursor, events]);
// handle incoming messages
useEffect(() => {
@@ -127,9 +145,10 @@ export default function EventList(props) {
});
};
console.log('EventList: events in event list', events);
let cumulativeDelay = 0;
let eventIndex = -1;
let previousEnd = 0;
let thisEnd = 0;
return (
<div className={style.eventContainer}>
@@ -152,6 +171,8 @@ export default function EventList(props) {
cumulativeDelay = 0;
} else if (e.type === 'event') {
eventIndex++;
previousEnd = thisEnd;
thisEnd = e.timeEnd;
}
return (
@@ -169,6 +190,7 @@ export default function EventList(props) {
next={nextId === e.id}
eventsHandler={eventsHandler}
delay={cumulativeDelay}
previousEnd={previousEnd}
/>
</div>
);
@@ -1,8 +1,8 @@
import DelayBlock from './DelayBlock';
import BlockBlock from './BlockBlock';
import EventBlock from './EventBlock';
import { showErrorToast } from 'common/helpers/toastManager';
import { memo } from 'react';
import DelayBlock from '../DelayBlock/DelayBlock';
import BlockBlock from '../BlockBlock/BlockBlock';
import EventBlock from '../EventBlock/EventBlock';
import { memo, useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
const areEqual = (prevProps, nextProps) => {
return (
@@ -10,7 +10,8 @@ const areEqual = (prevProps, nextProps) => {
prevProps.selected === nextProps.selected &&
prevProps.next === nextProps.next &&
prevProps.index === nextProps.index &&
prevProps.delay === nextProps.delay
prevProps.delay === nextProps.delay &&
prevProps.previousEnd === nextProps.previousEnd
);
};
@@ -24,8 +25,10 @@ const EventListItem = (props) => {
next,
eventsHandler,
delay,
previousEnd,
...rest
} = props;
const { emitError } = useContext(LoggingContext);
// Create / delete new events
const actionHandler = (action, payload) => {
@@ -59,7 +62,7 @@ const EventListItem = (props) => {
// request update in parent
eventsHandler('patch', newData);
} else {
showErrorToast('Field Error: ' + field);
emitError(`Unknown field: ${field}`);
}
break;
default:
@@ -78,6 +81,7 @@ const EventListItem = (props) => {
next={next}
actionHandler={actionHandler}
delay={delay}
previousEnd={previousEnd}
/>
);
case 'block':
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from 'react-query';
import { useCallback, useEffect } from 'react';
import { useCallback, useContext, useEffect, useState } from 'react';
import {
fetchAllEvents,
requestPatch,
@@ -12,20 +12,22 @@ import {
} from 'app/api/eventsApi.js';
import EventList from './EventList';
import EventListMenu from 'features/menu/EventListMenu.jsx';
import { showErrorToast } from 'common/helpers/toastManager';
import { useFetch } from 'app/hooks/useFetch.js';
import Empty from 'common/state/Empty';
import { EVENTS_TABLE } from 'app/api/apiConstants';
import { BatchOperation } from 'app/context/collapseAtom';
import { useAtom } from 'jotai';
import { LoggingContext } from '../../../app/context/LoggingContext';
export default function EventListWrapper() {
const [, setCollapsed] = useAtom(BatchOperation);
const queryClient = useQueryClient();
const { emitError } = useContext(LoggingContext);
const { data, status, isError, refetch } = useFetch(
EVENTS_TABLE,
fetchAllEvents
);
const [events, setEvents] = useState(null);
const addEvent = useMutation(requestPost, {
// we optimistically update here
@@ -35,12 +37,10 @@ export default function EventListWrapper() {
// Snapshot the previous value
let previousEvents = queryClient.getQueryData(EVENTS_TABLE);
console.log('debug', previousEvents);
if (previousEvents == null) {
refetch();
previousEvents = queryClient.getQueryData(EVENTS_TABLE);
}
console.log('debug 2', previousEvents);
// optimistically update object, temp ID until refetch
let optimistic = [...previousEvents];
@@ -231,9 +231,9 @@ export default function EventListWrapper() {
// Show toasts on errors
useEffect(() => {
if (isError) {
showErrorToast('Error fetching data');
emitError('Error fetching data');
}
}, [isError]);
}, [emitError, isError]);
// Events API
const eventsHandler = useCallback(
@@ -241,52 +241,40 @@ export default function EventListWrapper() {
switch (action) {
case 'add':
try {
let t = Date.now();
await addEvent.mutateAsync(payload);
console.log('debug m add', Date.now() - t);
} catch (error) {
showErrorToast('Error creating event', error.message);
emitError(`Error fetching data: ${error.message}`);
}
break;
case 'update':
try {
let t = Date.now();
await updateEvent.mutateAsync(payload);
console.log('debug m update', Date.now() - t);
} catch (error) {
showErrorToast('Error updating event', error.message);
emitError(`Error updating event: ${error.message}`);
}
break;
case 'patch':
try {
let t = Date.now();
await patchEvent.mutateAsync(payload);
console.log('debug m patch', Date.now() - t);
} catch (error) {
showErrorToast('Error updating event', error.message);
emitError(`Error updating event: ${error.message}`);
}
break;
case 'delete':
try {
let t = Date.now();
await deleteEvent.mutateAsync(payload);
console.log('debug m delete', Date.now() - t);
} catch (error) {
showErrorToast('Error deleting event', error.message);
emitError(`Error deleting event: ${error.message}`);
}
break;
case 'reorder':
try {
let t = Date.now();
await reorderEvent.mutateAsync(payload);
console.log('debug m reorder', Date.now() - t);
} catch (error) {
showErrorToast('Error reordering event', error.message);
emitError(`Error re-ordering event: ${error.message}`);
}
break;
case 'applyDelay':
let t = Date.now();
// if delay <= 0 delete delay and next block
if (payload.duration <= 0) {
try {
@@ -306,17 +294,15 @@ export default function EventListWrapper() {
// delete block after, if any
if (blockAfter) await deleteEvent.mutateAsync(blockAfter);
} catch (error) {
showErrorToast('Error applying delay', error.message);
emitError(`Error applying delay: ${error.message}`);
}
} else {
console.log('debug applydelay', payload.id);
try {
await applyDelay.mutateAsync(payload.id);
} catch (error) {
showErrorToast('Error applying delay', error.message);
emitError(`Error applying delay: ${error.message}`);
}
}
console.log('debug m apply', Date.now() - t);
break;
case 'collapseall':
@@ -330,26 +316,30 @@ export default function EventListWrapper() {
case 'deleteall':
try {
let t = Date.now();
await deleteAllEvents.mutateAsync();
console.log('debug m deleteall', Date.now() - t);
} catch (error) {
showErrorToast('Error deleting events', error.message);
emitError(`Error deleting events: ${error.message}`);
}
break;
default:
showErrorToast('Unrecognised request', action);
emitError(`Unhandled request: ${action}`);
break;
}
},
[data]
);
// Front end should handle bad arguments
useEffect(() => {
if (data == null) return;
setEvents(data.filter((d) => Object.keys(d).length > 0));
}, [data]);
return (
<>
<EventListMenu eventsHandler={eventsHandler} />
{status === 'success' ? (
<EventList events={data} eventsHandler={eventsHandler} />
{status === 'success' && events != null ? (
<EventList events={events} eventsHandler={eventsHandler} />
) : (
<Empty text='Connecting to server' />
)}
@@ -7,7 +7,7 @@
border-radius: 4px;
padding: 8px;
overflow-y: scroll;
height: 73vh;
height: 100%;
}
.list {
+15 -11
View File
@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useSocket } from 'app/context/socketContext';
import style from './Info.module.css';
import style from './Info.module.scss';
import InfoTitle from './InfoTitle';
import InfoLogger from './InfoLogger';
import InfoNif from './InfoNif';
import { useState } from 'react';
export default function Info() {
const socket = useSocket();
@@ -16,10 +15,10 @@ export default function Info() {
titleNext: '',
subtitleNext: '',
presenterNext: '',
noteNext: '',
noteNext: ''
});
const [selected, setSelected] = useState('No events');
const logData = [];
const [playback, setPlayback] = useState(null);
// handle incoming messages
useEffect(() => {
@@ -33,6 +32,11 @@ export default function Info() {
setTitles(data);
});
// Handle playstate
socket.on('playstate', (data) => {
setPlayback(data);
});
// Ask for selection data
socket.emit('get-selected');
@@ -52,23 +56,23 @@ export default function Info() {
return () => {
socket.off('titles');
socket.off('selected');
socket.off('playstate');
};
}, [socket]);
// TODO: Put this in use effect
// prepare data
const titlesNow = {
title: titles.titleNow,
subtitle: titles.subtitleNow,
presenter: titles.presenterNow,
note: titles.noteNow,
note: titles.noteNow
};
const titlesNext = {
title: titles.titleNext,
subtitle: titles.subtitleNext,
presenter: titles.presenterNext,
note: titles.noteNext,
note: titles.noteNext
};
return (
@@ -77,10 +81,10 @@ export default function Info() {
<span>{`Running on port 4001`}</span>
<span>{selected}</span>
</div>
{/* <InfoLogger logData={logData} /> */}
<InfoNif />
<InfoTitle title={'Now'} data={titlesNow} />
<InfoTitle title={'Next'} data={titlesNext} />
<InfoTitle title={'Now'} data={titlesNow} roll={playback === 'roll'} />
<InfoTitle title={'Next'} data={titlesNext} roll={playback === 'roll'} />
<InfoLogger />
</>
);
}
@@ -1,4 +1,7 @@
.container {
@use '../../styles/main' as *;
@use '../../styles/mixins' as *;
@mixin container {
margin-top: 1em;
display: flex;
flex-direction: column;
@@ -8,20 +11,48 @@
padding: 8px;
}
.container {
@include container;
}
.main {
font-size: 0.9em;
color: #ff7597;
color: $ontime-pink;
display: flex;
justify-content: space-between;
}
.header,
.headerRoll {
padding: 0;
margin: 0;
font-size: 0.9em;
color: $header-gray;
display: flex;
justify-content: space-between;
}
.header {
padding: 0;
margin: 0;
font-size: 0.9em;
color: #ccc;
display: flex;
justify-content: space-between;
color: $header-gray;
}
.headerRoll {
color: $ontime-roll;
}
.collapsedTitle {
font-size: inherit;
padding-left: 1em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.labelContainer {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label,
@@ -33,7 +64,7 @@
.label {
font-size: 0.9em;
color: #aaa;
color: $label-gray;
}
.label::after {
@@ -46,16 +77,15 @@
}
.notes {
color: #d69e2e;
color: $notes-color;
overflow: hidden;
text-overflow: ellipsis;
}
.if {
font-size: 0.8em;
color: #4bffabcc;
background-color: rgba(0, 0, 0, 0.13);
border-radius: 2px;
padding: 0 0.5em;
margin: 0 0.5em;
color: $ontime-accent;
@include container-bg;
}
ul > li {
@@ -63,23 +93,6 @@ ul > li {
color: #fff;
}
.log {
overflow-y: scroll;
height: 30vh;
}
.info {
color: #fff;
}
.error {
color: red;
}
.client {
color: lightblue;
}
.moreExpanded,
.moreCollapsed {
cursor: pointer;
+108 -25
View File
@@ -1,34 +1,117 @@
import { Icon } from '@chakra-ui/react';
import { useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import style from './Info.module.css';
import { useContext, useEffect, useState } from 'react';
import style from './InfoLogger.module.scss';
import CollapseBar from "../../common/components/collapseBar/CollapseBar";
import { LoggingContext } from '../../app/context/LoggingContext';
export default function InfoLogger(props) {
export default function InfoLogger() {
const { logData, clearLog } = useContext(LoggingContext);
const [data, setData] = useState([]);
const [collapsed, setCollapsed] = useState(false);
// Todo: save in local storage
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
const { logData } = props;
useEffect(() => {
const matchers = [];
if (showUser) {
matchers.push('USER');
}
if (showClient) {
matchers.push('CLIENT');
}
if (showServer) {
matchers.push('SERVER');
}
if (showRx) {
matchers.push('RX');
}
if (showTx) {
matchers.push('TX');
}
if (showPlayback) {
matchers.push('PLAYBACK');
}
const d = logData.filter((d) => (
matchers.some((m) => d.origin === m)
))
setData(d);
},[logData, showUser, showClient, showServer, showPlayback, showRx, showTx])
const disableOthers = (toEnable) => {
toEnable === 'USER' ? setShowUser(true) : setShowUser(false);
toEnable === 'CLIENT' ? setShowClient(true) : setShowClient(false);
toEnable === 'SERVER' ? setShowServer(true) : setShowServer(false);
toEnable === 'RX' ? setShowRx(true) : setShowRx(false);
toEnable === 'TX' ? setShowTx(true) : setShowTx(false);
toEnable === 'PLAYBACK' ? setShowPlayback(true) : setShowPlayback(false);
}
return (
<div className={style.container}>
<div className={style.header}>
Log
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={() => setCollapsed((c) => !c)}
/>
</div>
<div className={collapsed ? style.container : style.container__expanded}>
<CollapseBar title={'Log'} isCollapsed={collapsed} onClick={() => setCollapsed((c) => !c)}/>
{!collapsed && (
<ul className={style.log}>
<li className={style.info}>10:35:23 [PLAYBACK] Next</li>
<li className={style.client}>
10:32:10 [CLIENT] New socket client (total: 3)
</li>
<li className={style.info}>10:28:23 [PLAYBACK] Next</li>
<li className={style.info}>10:25:23 [PLAYBACK] Play</li>
<li className={style.info}>10:23:13 [SERVER] Server Reconnected</li>
<li className={style.error}>10:23:10 [SERVER] Server Disconnected</li>
</ul>
<>
<div className={style.toggleBar}>
<div
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers('USER')}
className={(showUser) ? style.active : null}>
USER
</div>
<div
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers('CLIENT')}
className={(showClient) ? style.active : null}>
CLIENT
</div>
<div
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers('SERVER')}
className={(showServer) ? style.active : null}>
SERVER
</div>
<div
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers('PLAYBACK')}
className={(showPlayback) ? style.active : null}>
Playback
</div>
<div
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers('RX')}
className={(showRx) ? style.active : null}>
RX
</div>
<div
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers('TX')}
className={(showTx) ? style.active : null}>
TX
</div>
<div
onClick={clearLog}
className={style.clear}>
Clear
</div>
</div>
<ul className={style.log}>
{data.map((d) => (
<li key={d.id} className={d.level === 'INFO' ? style.info : d.level === 'WARN' ? style.warn : d.level === 'ERROR' ? style.error : ''}>
<div
className={style.time}
>{d.time}</div>
<div className={style.origin}>{d.origin}</div>
<div className={style.msg}>{d.text}</div>
</li>
))}
</ul>
</>
)}
</div>
);
@@ -0,0 +1,90 @@
@use 'Info.module' as *;
@use '../../styles/main' as *;
@use '../../styles/mixins' as *;
.container,
.container__expanded{
@include container;
max-height: 80%;
}
.container__expanded {
min-height: 50%;
height: 100%
}
.log {
height: 100%;
overflow-y: scroll;
font-size: 0.8em;
user-select:text;
@include container-bg;
li {
display: flex;
margin-bottom: 2px;
.time {
width: 13%;
}
.origin {
width: 18%;
}
.msg {
width: 70%;
}
}
li.info {
color: #aaa;
}
li.warn {
color: #dd6b20;
}
li.error {
color: #f00;
}
.entry:hover {
color: #ddd;
}
}
.info {
color: #fff;
}
.error {
color: red;
}
.client {
color: lightblue;
}
.toggleBar {
display: flex;
font-size: 0.7em;
justify-content: flex-start;
gap: 1em;
padding: 0.5em 0;
font-weight: 600;
div {
padding: 2px 8px;
background: #0002;
border: 1px solid #fff1;
border-radius: 2px;
cursor: pointer;
}
div.active {
background: $ontime-accent;
color: darken($ontime-accent, 70%);
}
.clear {
border: 1px solid rgba($ontime-pink, 0.5);
}
}
+6 -21
View File
@@ -1,36 +1,21 @@
import { Icon } from '@chakra-ui/react';
import { useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import { APP_TABLE } from 'app/api/apiConstants';
import { getInfo, ontimePlaceholderInfo } from 'app/api/ontimeApi';
import { useFetch } from 'app/hooks/useFetch';
import style from './Info.module.css';
import style from './Info.module.scss';
import CollapseBar from '../../common/components/collapseBar/CollapseBar';
import handleLink from '../../common/utils/handleLink';
export default function InfoNif() {
const { data, status } = useFetch(APP_TABLE, getInfo, {
placeholderData: ontimePlaceholderInfo,
});
const [collapsed, setCollapsed] = useState(false);
const baseURL = `http://__IP__:${4001}`;
const handleLink = (url) => {
if (window.process.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
}
};
const baseURL = 'http://__IP__:4001';
return (
<div className={style.container}>
<div className={style.header}>
Network Info
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={() => setCollapsed((c) => !c)}
/>
</div>
<div className={style.container}>
<CollapseBar title={'Network Info'} isCollapsed={collapsed} onClick={() => setCollapsed((c) => !c)}/>
{!collapsed && (
<div>
{status === 'success' && (

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