Compare commits

..

145 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen f5e9a2549e Improve spreadsheet import flow (#839)
* feat: add dropdown to worksheet selection

* feat: move revoke to separate button

* refactor: extract excel flow to separate controller and service

* chore: show spinner when uploading sheet and disable the other option

* chore: don't show "success" when canceling the import

* chore: export boolean types properly to sheets
2024-03-22 21:33:11 +01:00
Carlos Valente f4692db021 refactor: allow transition from roll to playback (#847) 2024-03-22 13:32:36 +01:00
Carlos Valente dddb11ff40 refactor: granular updates to rundown (#848) 2024-03-22 13:21:49 +01:00
Carlos Valente 6178ed1e4e Apply project (#843) 2024-03-22 12:52:55 +01:00
Carlos Valente 8c5aaa0901 fix: account for midnight rollover (#846) 2024-03-22 08:04:23 +01:00
Carlos Valente b06ab15190 refactor: save object on restore (#842) 2024-03-21 22:10:54 +01:00
Ary a48be0f017 Fix to lastLoadedProject being null (#830) 2024-03-21 21:08:45 +01:00
Alex Christoffer Rasmussen c2280da61f fix: uncaught error in http integration (#837) 2024-03-21 21:07:39 +01:00
Carlos Valente fe30752130 refactor: follow in run mode (#840) 2024-03-21 21:07:15 +01:00
Alex Christoffer Rasmussen a20d63451b fix: use cross-env to allow windows to build local docker (#838) 2024-03-21 00:12:17 +01:00
Carlos Valente 93622e9aeb fix: prevent calculation with no timer (#835) 2024-03-20 12:52:32 +01:00
Carlos Valente 1568af97fc chore: bump node version in docker (#834) 2024-03-19 21:55:37 +01:00
Carlos Valente 4bb836b6f2 End pause (#832)
* refactor: trigger ahead

* refactor: refresh often

* fix: handle empty rundown updates

* fix: handle falsy values

* style: avoid overlap of components

* refactor: freeze end option
2024-03-19 18:04:05 +01:00
Carlos Valente 8156da03d5 Quick options (#814)
* refactor: link start is quick option

* refactor: add default duration

* chore: use last time by default
2024-03-19 17:23:32 +01:00
Carlos Valente dfade25a7c feat: url navigation for settings (#821) 2024-03-18 20:36:02 +01:00
Carlos Valente 4b21745fc7 refactor: download files from interface (#831) 2024-03-18 20:27:58 +01:00
Carlos Valente a91a8a6358 refactor: simplify time-to-end (#828)
* refactor: simplify time-to-end

* refactor: rundown metadata

* refactor: handle overflow in UI

* refactor: show gaps between days

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2024-03-18 17:24:11 +01:00
Carlos Valente 4e6b833e10 refactor: obfuscated pincode in transit 2024-03-18 13:40:59 +01:00
Alex Christoffer Rasmussen 3fdb1bd1c8 partial ws update for demo (#813)
* partial ws update

* extract more of the timer formatting
2024-03-18 12:01:36 +01:00
Carlos Valente 4148a3835b style: clarify state of external message 2024-03-17 19:46:48 +01:00
Carlos Valente 4fb9c42aef refactor: skip expected end if not playing 2024-03-17 19:46:25 +01:00
Carlos Valente dd0dcfc2a2 fix: reset focus when deleting selected 2024-03-17 19:46:10 +01:00
Carlos Valente d41d1b054d fix: reset focus when deleting all 2024-03-17 19:46:10 +01:00
Carlos Valente 8b6e06150a fix: update cache on delete-all (#827) 2024-03-17 19:45:34 +01:00
Carlos Valente a9e0e2b091 refactor: tweaks to chakra theme config (#824) 2024-03-16 09:42:06 +01:00
Carlos Valente 0a8155b6e3 Param nav (#822)
* refactor: remove unused

* refactor: simplify logic
2024-03-15 16:52:33 +01:00
Carlos Valente 118c29e5c2 refactor: filter sentry errors (#816) 2024-03-15 16:13:26 +01:00
Carlos Valente 105f395e6e refactor: migrate fullscreen to mantine (#819) 2024-03-14 18:32:07 +01:00
Carlos Valente 12d9e5922c Alpha revision (#811)
* style: less space for direction button

* chore: cleanup unused

* refactor: prevent CSS pollution

* refactor: force render on swatch

* refactor: force render on swatch

* refactor: improve import of blocks and skip import

* chore: remove unused

* chore: clarify effect

* chore: updata dataset

* fix: integrations not triggering

* chore: remove unused

* fix: onair derives from playback

* fix: gap day after
2024-03-12 21:30:16 +01:00
Carlos Valente 63983fc39a Alpha release (#810)
* fix: path to resources

* docs: rename aliases > url presets
2024-03-09 22:44:35 +01:00
Carlos Valente 41af3b7466 Sheets revision (#809)
* refactor: poll for authentication

* refactor: show success card
2024-03-09 21:39:11 +01:00
Carlos Valente 78b386742e Settings style (#808) 2024-03-09 10:56:14 +01:00
Carlos Valente ad71793cf3 Url presets (#807) 2024-03-08 23:10:43 +01:00
Carlos Valente 10d7273ec0 Skip fixes (#805)
* refactor: resolve current in time-to-end

* refactor: recalculate on skip

* Alpha tweaks (#806)

* style: small tweaks to interface

* refactor: simplify quick add
2024-03-08 19:54:18 +01:00
Carlos Valente 6dc2bebeee Time to end (#804) 2024-03-07 22:10:21 +01:00
Carlos Valente d59885fc20 fix: allow renaming custom field (#803)
* fix: allow renaming custom field

* style: change time-to-end icon
2024-03-07 19:59:36 +01:00
Carlos Valente d22cc48565 refactor: small tweaks and fixes 2024-03-06 22:42:20 +01:00
Carlos Valente 8196ea584d UI tweaks (#800)
* style: placeholder for progress value

* style: rundown menu

* style: extracted rundown takes whole screen

* style: tweaks to rundown indicators

* feat: allow exporting info

* style: indicate action
2024-03-03 20:42:36 +01:00
Carlos Valente 0c31438e44 chore: migrate remaining settings (#799)
* chore: migrate remaining settings

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
2024-03-03 19:16:25 +01:00
Bianca Procopio 10d3986832 Move app settings (#765)
* refactor: migrate app settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-03-03 19:06:21 +01:00
Carlos Valente 088927bbbb refactor: organise API around resources (#798) 2024-03-02 23:08:08 +01:00
Carlos Valente d87ba12b2e chore: cleanup linter (#797) 2024-03-01 23:19:05 +01:00
Carlos Valente 5b01329c37 refactor: migrate project upload (#796)
* refactor: migrate project upload
2024-03-01 22:59:00 +01:00
Carlos Valente 8df419d835 refactor: deprecate presenter and subtitle (#795)
* refactor: deprecate presenter and subtitle

* style: reduce spacing between related sections

* refactor: optional secondary field

* refactor: remove secondary field

* refactor: dynamic data source
2024-02-29 18:19:58 +01:00
Carlos Valente d7392b93d2 refactor: remove userFields (#791)
* refactor: process custom fields on cache generate

* refactor: remove userFields
2024-02-27 13:23:12 +01:00
Carlos Valente 429df21557 Custom fields caching (#790)
* refactor: process custom fields on cache generate
2024-02-25 17:21:59 +01:00
Carlos Valente da7ef59216 Custom fields views (#789)
* style: show field colour in editor

* feat: custom fields in cuesheet

* feat: custom fields in operator

* refactor: update CSV export
2024-02-24 22:04:20 +01:00
Alex Christoffer Rasmussen 474f1e2177 Sheet use limited input device auth flow (#782)
* refactor: limited-input-device auth

* refactor: resolve sheet directory from setup

* refactor: extract sheet logic in backend

* refactor: simplify sheet integration

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-02-24 11:58:50 +01:00
Carlos Valente fc5338903b feat: generate crash report (#787) 2024-02-23 08:52:08 +01:00
cv 11d06de133 Merge branch 'v3' of https://github.com/cpvalente/ontime into v3 2024-02-19 21:43:00 +01:00
cv cbc2ae5116 chore: cleanup merge 2024-02-19 21:42:38 +01:00
cv a6ff874a2a Merge remote-tracking branch 'origin/master' into v3 2024-02-19 21:40:56 +01:00
Carlos Valente 7ff75835ff refactor: partial state updates (#780)
* refactor: partial state updates

* refactor: less calls to restore

* refactor: add error stack trace to log
2024-02-19 21:31:27 +01:00
Carlos Valente c890251dad refactor: delays account for gaps (#784) 2024-02-19 21:30:52 +01:00
Carlos Valente c1e2700c38 style: tweaks to lower thirds (#785) 2024-02-19 21:29:55 +01:00
Carlos Valente 5a397c6da7 Sheets settings (#774)
* wip: migrate sheet settings

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-02-17 22:05:38 +01:00
Alex Christoffer Rasmussen c1377544a0 custom fields (#744)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-02-17 21:55:46 +01:00
Carlos Valente 1a420a1ddd Over under (#771)
* feat: schedule offset
2024-02-16 21:04:58 +01:00
Fabian Posenau 436a34aaee upgraded pipeline actions (#777) 2024-02-15 14:54:05 +01:00
Fabian Posenau 4848fdbee3 Fix build local (#778)
* adjust node version

* fix build:local
2024-02-15 14:51:19 +01:00
Carlos Valente 71dace669b fix: recover cursor logic (#773) 2024-02-13 18:43:41 +01:00
Carlos Valente 67c448572c style: tweaks to settings (#772) 2024-02-12 21:24:49 +01:00
Carlos Valente 53963a9ad7 Integrations data (#770)
* feat: OSC settings

* feat: HTTP settings
2024-02-11 21:25:23 +01:00
Carlos Valente 5355e45b80 refactor: UI for linking events (#763) 2024-02-11 15:18:04 +01:00
Ary 12f81c63ce Project Manager: Quick Start (#762) 2024-02-10 14:30:33 +01:00
Carlos Valente 7f78fac162 wip: UI for integrations (#769) 2024-02-09 13:56:04 +01:00
Carlos Valente e8b5cb6750 wip: add UI to manage custom fields (#767) 2024-02-09 13:51:40 +01:00
Carlos Valente 212e5f9e68 refactor: small type improvements (#759) 2024-02-03 23:17:36 +01:00
Carlos Valente 1890fc49d7 refactor: normalise data (#756)
* chore: remove legal from bundle

* refactor: create normalised dataset

* refactor: cuesheet uses flat rundown

* refactor: multi-selection

* refactor: prevent stale data on server restart

* refactor: increase ID size

* chore: instrument operation

* chore: update csv tests

* fix: resolve directory to test-db (#758)
2024-02-03 21:43:17 +01:00
Ary 47a519bac1 Merge pull request #753 from biancahpp/feat/download-and-export-options
Project Manager: Export
2024-02-01 16:03:41 -07:00
Ary 083b376113 Merge pull request #754 from cpvalente/fix/project_manager_init
Fix: Project manager list is blank on the first app load
2024-02-01 16:03:13 -07:00
Ary 5b20a47ff6 fix: fixed issue where there were no projects on the project list on the first load 2024-01-31 20:28:39 -07:00
Bianca Procopio c3880cd5a7 update downloadCSV and downloadRundown to accept a file name and add two new options to ProjectListItem 2024-01-31 19:11:00 -07:00
Carlos Valente 91fd275564 refactor: rundown service fetches data from data provider (#752) 2024-01-31 19:30:44 +01:00
Carlos Valente cf08bfc796 refactor: type improvements (#751) 2024-01-31 14:42:33 +01:00
Carlos Valente 7eb9ee8d03 refactor: flatten state mutations (#750) 2024-01-31 14:02:27 +01:00
Carlos Valente ec8952be56 chore: organise delay and rundown utils (#749) 2024-01-30 21:26:37 +01:00
Carlos Valente 6a5020424b refactor: improve typing in backend (#748) 2024-01-30 21:12:19 +01:00
Carlos Valente 39711c1edd refactor: time-to-end always updates (#739) 2024-01-30 12:35:54 +01:00
Ary 3c83f4d15b Project manager (#697)
* Project manager: Load, Rename, Duplicate (#712)

* Project Manager: Create, Delete (#723)
2024-01-29 14:06:33 +01:00
Carlos Valente 4c77d26a71 style: tweaks to editor (#738) 2024-01-28 22:42:19 +01:00
Carlos Valente 5de40e8679 fix: update timer on hot reload (#741) 2024-01-27 22:54:03 +01:00
Carlos Valente 32d70b3cd5 chore: split vendor bundle (#740) 2024-01-27 22:30:23 +01:00
Carlos Valente 86cec56a3d refactor: allow import of blocks and skip import (#735) 2024-01-27 15:08:01 +01:00
Carlos Valente f6557a9814 style: editor tweaks (#736)
* style: menu tweaks

* style: editor tweaks

* style: freeze
2024-01-26 14:47:27 +01:00
Carlos Valente c146954a2f refactor: excel cleanup (#734)
* refactor: remove import of project data from excel
2024-01-26 12:55:35 +01:00
Carlos Valente 2df3d376ad feat: many timers (#706)
* wip: create simple timer class

* refactor: service to manage extra timers

* wip:ui for controlling the extra timer

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-25 16:51:20 +01:00
Carlos Valente 74e1ae609c refactor: update timers (#729)
* refactor: remove duplication

* refactor: previous times

* refactor: event patching
2024-01-25 13:36:45 +01:00
Carlos Valente e748bff0dd chore: upload screenshots to artifacts (#730) 2024-01-24 21:56:01 +01:00
Carlos Valente b4ad533e6a refactor: cleanup store (#727)
* refactor: isolate clock

* refactor: isolate private

* refactor: remove duplicate

* chore: rename loaded > runtime

* fix: handle no events loaded
2024-01-24 00:20:10 +01:00
Alex Christoffer Rasmussen 0cd2ca4191 update API - part 1 (#709)
* api: `test-ontime` -> `version`

* api: `ontime-poll` -> `poll`

* api: `start-next` -> `startnext`

* api: `start-next` ->  `startnext`

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-23 08:43:00 +01:00
Carlos Valente cb8ba776e2 Event editor continue (#722)
* refactor: remove info panel

* feat: editor panel

* wip: event info in drawer

* chore: update tests

* chore: add scss to processor
2024-01-22 16:23:11 +01:00
Carlos Valente c7328a33f6 chore: fail tests that contain unhandled errors (#724) 2024-01-22 14:15:23 +01:00
Carlos Valente fc5800bc21 remove info panel (#721)
* refactor: remove info panel

* chore: update tests
2024-01-19 14:08:32 +01:00
Carlos Valente 3c1be1fc10 fix: issue with loosing cursor position on message (#719) 2024-01-17 23:16:22 +01:00
Carlos Valente a2950442d8 refactor: runtime service (#715)
* refactor: rename PlaybackService > RuntimeService

* refactor: stateful runtime service

* refactor: merge event loading

* refactor: roll is part of state mutation
2024-01-16 19:54:40 +01:00
Alex Christoffer Rasmussen ee121bcfcb change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase (#716)
2024-01-16 19:35:16 +01:00
Alex Christoffer Rasmussen 4234334fbb fix: test - go to Edit mode befor tying to click Event options button (#708) 2024-01-13 12:45:23 +01:00
Carlos Valente 7f63cde7a5 feat: multiple selection (#703)
feat: multiple event selection

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>
2024-01-12 13:07:56 +01:00
Alex Rohleder f965db148c refactor: setup global timer state (#629)
* refactor: setup global timer state

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-12 13:03:58 +01:00
cv e6bb52ad9c chore: cleanup merge 2024-01-12 10:17:12 +01:00
cv 6d067f3d3a chore: cleanup merge 2024-01-12 10:13:49 +01:00
cv 506f9a868b chore: cleanup merge 2024-01-12 10:13:40 +01:00
cv adb8981191 chore: cleanup merge 2024-01-12 10:12:01 +01:00
cv a0b3e56896 chore: cleanup merge 2024-01-12 10:05:51 +01:00
cv 229fff05a4 Merge remote-tracking branch 'origin/master' into v3 2024-01-12 10:05:10 +01:00
Alex Christoffer Rasmussen 5b7045e6a5 chore: remove unused deepmerge (#705) 2024-01-10 19:30:34 +01:00
Alex Christoffer Rasmussen 7cc4364a63 chore: upgrade nanoid to v5 (#704) 2024-01-10 19:28:34 +01:00
Carlos Valente b4e73ff6b5 Refactor/time formatting (#696)
* chore: upgrade related dependencies

* fix: skip sentry on dev

* refactor: unify time formatting

* refactor: display default format for page
2024-01-09 15:30:11 +01:00
Carlos Valente 9031051465 Revert "style: align delay to clock"
This reverts commit 3fb862628c.
2024-01-08 22:55:57 +01:00
Carlos Valente a15a9671d2 Revert "chore: cleanup utils"
This reverts commit ecd13fdc5a.
2024-01-08 22:55:47 +01:00
Carlos Valente ecd13fdc5a chore: cleanup utils 2024-01-08 22:51:38 +01:00
Carlos Valente 3fb862628c style: align delay to clock 2024-01-08 22:48:25 +01:00
Carlos Valente 1f3133d4f3 chore: upgrade deps (#698) 2024-01-08 17:31:04 +01:00
Alex Christoffer Rasmussen 64573aff8a refactor: update lower third (#665)
* init

* auto in out

* fix in out animation and other options

* move some constants to css override

* dont show block if text source is empty

* select trigger

* adjust the animation

* remove the old one and replace it in the router

* return null from useEffect when there is no timer to clear

* typo: manuel -> manual

* fix: delay is excluding transition in time

* rename to top and bottom

* change font size to px and increese the default

* sharper animation timning

* fix: dont need state for options

* fix: e2e test for lower third

* deafault to ffffff00 key color

* typo: scss naming

* style: all white default bg key

* style: add default values to src options

* style: `white-space: nowrap;`

* style: start out animation a bit early

* style: browser tab title

* fix: checking the correct default values in ViewParamsEditor

* fix: defaultValues save improved by @asharonbaltazar

* lint: capitalize enum names

* fix: memoize options

* fix: searchParams test

* fix: readability of `topText` and `bottomText` and memoize them

* fix: enum naming upper case

* refactor: remove cx import

* refactor: try to simplify is else flow
2024-01-07 23:23:25 +01:00
Carlos Valente 655e9e8fb5 refactor: migrate logs (#673) 2024-01-06 21:48:20 +01:00
Carlos Valente bac2dc6b87 feat: create project list (#671) 2024-01-06 21:38:16 +01:00
Ary 65a5aaa665 Keep several project files in user folder - Steps 5A-E (#684) 2024-01-06 21:24:48 +01:00
Carlos Valente c889d00e6b fix: focus cursor (#695)
* fix: prevent cursor from removing focus
2024-01-06 09:10:42 +01:00
Carlos Valente 3753d51d85 chore: cleanup 2024-01-05 21:57:47 +01:00
Carlos Valente cb23b83b9d Merge remote-tracking branch 'origin/master' into v3 2024-01-05 21:55:16 +01:00
Alex Christoffer Rasmussen a567fdc446 build: upgrade lowdb (#689) 2024-01-02 20:27:10 +01:00
Carlos Valente 3d9a9a2226 wip: overview (#688)
* wip: overview
2024-01-01 22:06:23 +01:00
Carlos Valente 9a4d659b04 improvement: hide seconds (#675)
* feat: optional timer seconds
2023-12-31 12:13:05 +01:00
Fabian Posenau a5d8b91f34 add fields to import modal (#685)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 22:11:06 +01:00
Carlos Valente 5409c0ac6e refactor: stabilise actionHandler (#683) 2023-12-30 21:13:51 +01:00
Fabian Posenau 3998b8927d Warning and danger per event (#677)
* feat: warning and danger per event

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 20:56:57 +01:00
Ary 7b9f64a630 Project files tweaks (#679)
* chore: fixed issue when loading first time; deleting excel file after upload; only json files from project list

* chore: added last loaded project to endpoint response

* chore: incremental file name

---------

Co-authored-by: Name <Email>
2023-12-30 19:32:17 +01:00
Alex Christoffer Rasmussen c5c0401ac4 refactor: switch out steno for lowdb (#682)
* refactor: switch out steno for lowdb
2023-12-30 10:03:02 +01:00
Alex Christoffer Rasmussen 96280e5932 Feat: reorder events with alt+ctrl + arrow up/down (#645)
* fix: key guard

* feat: reorder events

* refactor: return index from the getEvent functions
2023-12-28 13:41:43 +01:00
Alex Christoffer Rasmussen b2c01a141a Studio clock (#663)
* refactor: trim rundown shows only upcoming

* style: small tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-28 13:25:45 +01:00
Ary dc4ba4012c feat: Several project files user folder (Steps 3-5) (#664)
* feat: steps 3-5 for several project files user folder

* feat: common types, better listing function

* feat: error response type
2023-12-28 13:23:51 +01:00
Carlos Valente d1e201ebb5 refactor: remove deprecated event data (#674) 2023-12-27 20:00:09 +01:00
Carlos Valente a463cb491b feat: app settings (#658)
* feat: app settings

* refactor: cleanup routes

* style: smaller base font

* chore: migrate about modal

* fix: import links
2023-12-25 21:25:09 +01:00
Carlos Valente 6ffc314513 chore: automated screenshots (#667)
* chore: take screenshots on routes

* chore: update test data

* chore: update test

* chore: upgrade node in CI
2023-12-25 19:41:45 +01:00
Ary f2632412aa Several project files user folder (#617)
* feat: save loaded file across sessions
2023-12-21 13:52:09 +01:00
Carlos Valente 062dc0c914 refactor: pass HTTP to integration controller (#652)
* refactor: pass HTTP to integration controller

* refactor: deprecate onair control

* refactor: remove playback router
2023-12-19 21:44:43 +01:00
Carlos Valente fe9f12a14b chore: lock node version to electron 2023-12-19 10:23:27 +01:00
Carlos Valente 3144888eb1 chore: add italian to languages (#656) 2023-12-19 10:05:09 +01:00
Carlos Valente 820eacde91 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-19 10:05:09 +01:00
Matteo Gheza 6886a3236f Add italian client translation (#655)
* Add Italian client translation
2023-12-19 10:05:09 +01:00
Carlos Valente bb541c551f chore: upgrade build deps (#661)
* chore: upgrade build dependencies

* chore: add bundle analysis script

* chore: upgrade sentry deps

* chore: upgrade node types

* docs: declare new engine versions
2023-12-19 10:03:59 +01:00
Carlos Valente 3196bd7802 chore: version bump 2023-12-17 20:57:49 +01:00
Carlos Valente ce0657cc6d style: smaller base font 2023-12-17 20:56:58 +01:00
Carlos Valente 44c2b1b5e3 refactor: cleanup routes 2023-12-17 20:56:42 +01:00
513 changed files with 15844 additions and 24125 deletions
-9
View File
@@ -11,13 +11,4 @@
# Ignore git and cache folders
.git
.gitignore
.cache
# Ignore build folders
node_modules
dist
# Ignore default volumes created by running docker compose up
ontime-db
ontime-styles
-1
View File
@@ -19,7 +19,6 @@
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
-2
View File
@@ -1,2 +0,0 @@
github: cpvalente
buy_me_a_coffee: cpvalente
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

-53
View File
@@ -1,53 +0,0 @@
name: Ontime CLI build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_cli:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
-48
View File
@@ -1,48 +0,0 @@
name: Docker Image CI Ontime
on:
release:
types: [published]
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+68
View File
@@ -0,0 +1,68 @@
name: Docker Image CI Ontime V2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
with:
version: 16.16.0
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+108
View File
@@ -0,0 +1,108 @@
name: Ontime build v2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,8 +1,9 @@
name: Ontime build
name: Ontime build v3
on:
push:
tags: [ "*" ]
# Only trigger manually for now for testing
# push:
# tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -14,12 +15,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -30,14 +31,7 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac
timeout-minutes: 60
- name: Release
uses: softprops/action-gh-release@v1
@@ -56,12 +50,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -89,12 +83,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+106
View File
@@ -0,0 +1,106 @@
name: Ontime test v2
on:
pull_request:
branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
@@ -1,8 +1,9 @@
name: Ontime test
name: Ontime test v3
on:
pull_request:
branches: '*'
# Only trigger manually for now for testing
# pull_request:
# branches: '*'
workflow_dispatch:
jobs:
@@ -17,12 +18,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,12 +74,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+1 -1
View File
@@ -1 +1 @@
v20.15.1
v18.18.2
+4 -5
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~20)
- __pnpm__ (~9)
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -81,9 +81,6 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
@@ -91,6 +88,8 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
+6 -14
View File
@@ -1,28 +1,20 @@
FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:20-alpine
FROM node:18.18-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
ENV ONTIME_DATA=/external/
WORKDIR /app/
# Prepare UI
COPY --from=builder /app/apps/client/build ./client/
COPY /apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY /apps/server/dist/ ./server/
COPY /demo-db/ ./preloaded-db/
COPY /apps/server/src/external/ ./external/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
+139 -114
View File
@@ -1,180 +1,205 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
## Download the latest releases here
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
</div>
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
Ontime is an application for creating and managing event running order and timers.
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
Ontime is made by entertainment and broadcast engineers and used by
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
- Theatres and opera houses
- Houses of worship
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
This guides the application into being flexible and efficiently integrating into different workflows.
### For teams
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
signage.
### Simple infrastructure
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
your team and clients.
Ontime is made by video engineers and entertainment technicians.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/editor.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/ontime-overview.webp)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
[Read the docs to learn more](https://docs.getontime.no)
## Using Ontime
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
Once installed and running, any device in the network has access to Ontime.
Any device with a browser in the same network can choose one of the supported views to render the available data.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
You can then use the menu in the top left corner to select the desired view.
The menu will be initially hidden until there is mouse interaction.
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL presets feature
```
For the backstage views
For the presentation views
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/minimal > Simple timer view
IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
For the public views
-------------------------------------------------------------
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
```
For production views
For management views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More information is available [in our docs](https://docs.getontime.no)
More documentation is available [in our docs](https://docs.getontime.no)
## Feature List (in no specific order)
- [x] Distribute data over network and render it in the browser
- [x] Different screen types
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- OSC (Open Sound Control)
- HTTP
- WebSockets
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We want Ontime to be unique by targeting freelancers instead of roles.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
many environments and workflows.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
work.
## Rich APIs for workflow integrations
The app is currently being developed for a broad user base, from broadcast to entertainment and
conference halls.
Taking advantage of the integrations, we currently use Ontime with:
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
webmodule
- `OBS`: **render views** using the Browser Module
- `QLab`: trigger Ontime using **OSC API**
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
### Make your own viewer
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
<br />
We have prepared a few resources to help here:
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
how to get you started
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
<br />
More information [in the docs](https://docs.getontime.no/features/custom-views/)
### Headless run
You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
## Roadmap
### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users.
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
We use Github's issue tracking for bug reporting and feature requests. \
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or
`Ontime can't be opened because it is from an unidentified developer`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
You can circumvent this by allowing the execution of the app manually.
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
- In Windows: click more and select "Run Anyway"
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
If you have tips on how to improve this or would like to sponsor the code signing,
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
would mean a recurrent expense and is not a priority. This is unlikely to change in future. If you
have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Contributing
#### Safari
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
There is no plan for any further work on this.
# Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
# Help
## License
Help is underway! ... and can be found [here](https://docs.getontime.no)
# License
This project is licensed under the terms of the GNU GPL v3
## Sponsor
You can help the development of this project or say thank you with a one time donation. \
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
<p align="center">
<br>
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
</p>
-19
View File
@@ -1,19 +0,0 @@
# Terms of sponsorship
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
## Why sponsor
Ontime is and will remain free, regardless of your decision to contribute.
Regardless, you may choose to provide a monetary contribution:
### Improve user experience
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
### Cool stuff
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
### Priorities
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
Additional funding will be considered according to the needs at the time.
-16
View File
@@ -1,16 +0,0 @@
{
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"plugins": [],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
-3
View File
@@ -1,3 +0,0 @@
server
external
client
-9
View File
@@ -1,9 +0,0 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
-44
View File
@@ -1,44 +0,0 @@
# Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers.
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
## Getting started
### Prerequisites
You will need to have installed the correct version of node.js
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system
```bash
npm install -g @getontime/cli
```
... and run using the installed script
```bash
ontime
```
Or install and run ontime (the installation here is temporary for the duration of the session)
```bash
npx install @getontime/cli
```
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env node
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
const ontimeServer = require('./server/index.cjs');
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
async function startOntime() {
await initAssets();
await startServer();
await startIntegrations();
}
startOntime();
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
shutdown();
});
-27
View File
@@ -1,27 +0,0 @@
{
"name": "@getontime/cli",
"version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
],
"devDependencies": {
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
}
}
+18 -17
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.6.1",
"version": "3.0.0-alpha",
"private": true,
"type": "module",
"dependencies": {
@@ -10,10 +10,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
@@ -22,15 +21,16 @@
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.3.1",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.5.2"
"zustand": "^4.5.0"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -42,6 +42,7 @@
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -60,7 +61,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -69,13 +70,13 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -83,13 +84,13 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "^5.2.11",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.6.0"
"vitest": "^1.2.2"
}
}
+37 -7
View File
@@ -1,20 +1,52 @@
import { useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
connectSocket();
// Load Open Sans typeface
// @ts-expect-error no types from font import
import('typeface-open-sans');
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
function App() {
const { isElectron, sendToElectron } = useElectronEvent();
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the alt key is pressed
if (event.altKey) {
if (event.code === 'KeyT') {
// ask to see debug
sendToElectron('set-window', 'show-dev');
}
}
};
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
@@ -23,15 +55,13 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<AppRouter />
<ContextMenu>
<AppRouter />
</ContextMenu>
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
+30 -61
View File
@@ -1,36 +1,23 @@
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -40,56 +27,38 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<React.Suspense fallback={null}>
<SentryRoutes>
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} />
<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/timeline' element={<STimeline />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -126,7 +95,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRoutes>
</React.Suspense>
</Routes>
</Suspense>
);
}
-2
View File
@@ -11,14 +11,12 @@ export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
+11 -5
View File
@@ -1,5 +1,11 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import {
DatabaseModel,
GetInfo,
MessageResponse,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -11,15 +17,15 @@ const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
return axios.get(`${dbPath}/download`);
}
/**
* Request download of the current project file
* @param fileName
*/
export async function downloadProject(fileName: string) {
export async function downloadProject(fileName: string = 'ontime-project') {
try {
const { data, name } = await fileDownload(fileName);
@@ -152,7 +158,7 @@ export async function getInfo(): Promise<GetInfo> {
* @returns
*/
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb(fileName);
const response = await getDb();
const headerLine = response.headers['Content-Disposition'];
+2 -2
View File
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
}
/**
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
disabled?: boolean;
}
const quitBtnStyle = {
color: '#D20300', // $red-700
borderColor: '#D20300', // $red-700
_focus: { boxShadow: 'none' },
_hover: {
background: '#D20300', // $red-700
color: 'white',
_disabled: {
color: '#D20300', // $red-700
background: 'none',
},
},
_active: {
background: '#9A0000', // $red-1000
color: 'white',
},
variant: 'outline',
isRound: true,
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
const cancelRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (window.process?.type === 'renderer') {
window.ipcRenderer.on('user-request-shutdown', () => {
emitInfo('Shutdown request');
setIsOpen(true);
});
}
}, [emitInfo]);
const handleShutdown = useCallback(() => {
onClose();
clickHandler();
}, [clickHandler]);
return (
<>
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size='lg'
icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
{...rest}
/>
</Tooltip>
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
</Button>
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
Shutdown
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}
@@ -1,6 +0,0 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -1,69 +0,0 @@
import { useState } from 'react';
import {
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name?: string;
path?: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath);
const { setRedirect } = setClientRemote;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
}
onClose();
};
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,68 +0,0 @@
import { useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
isOpen: boolean;
onClose: () => void;
}
export function RenameClientModal(props: RenameClientModalProps) {
const { id, name: currentName = '', isOpen, onClose } = props;
const [name, setName] = useState(currentName);
const { setClientName } = setClientRemote;
const handleRename = () => {
if (name !== currentName && name !== '') {
setClientName({ target: id, rename: name });
}
onClose();
};
const canSubmit = name !== currentName && name !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename: {currentName}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
variant='ontime-filled'
size='md'
placeholder='new name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -75,7 +75,6 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
@@ -28,11 +28,9 @@ class ErrorBoundary extends React.Component {
});
Sentry.withScope((scope) => {
scope.setExtras({
error,
store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
@@ -1,63 +0,0 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -1,34 +0,0 @@
.overlay {
position: absolute;
top: 0;
left: 0;
margin: 0;
width: 100vw;
height: 100vh;
padding: 0;
box-sizing: border-box;
border: 10px solid $ui-white;
background-color: $ui-black;
color: $ui-white;
display: grid;
place-content: center;
text-align: center;
z-index: 100;
cursor: pointer;
}
.name {
font-weight: 600;
margin-top: -10vh;
line-height: 1em;
font-size: 12vw;
text-transform: uppercase;
}
.message {
color: $label-gray;
line-height: 2em;
font-size: 3vw;
}
@@ -1,63 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { setClientRemote } from '../../hooks/useSocket';
import { useClientStore } from '../../stores/clientStore';
import style from './IdentifyOverlay.module.scss';
export default function IdentifyOverlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const showOverlay = clients[id]?.identify;
if (!showOverlay) {
return null;
}
const portalRoot = document.getElementById('identify-portal');
if (!portalRoot) {
return null;
}
return createPortal(<Overlay />, portalRoot);
}
function Overlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const { setIdentify } = setClientRemote;
const showOverlay = clients[id]?.identify;
const handleClose = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setIdentify({ target: id, identify: false });
}, [id, setIdentify]);
// start a timer that will close the overlay after some time
useEffect(() => {
if (showOverlay) {
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [showOverlay, id, setIdentify, handleClose]);
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
<div className={style.message}>Click to close</div>
</div>
);
}
@@ -1,25 +1,27 @@
import { RefObject, useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
const node = ref.current;
autosize(ref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
}, [value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
ref={ref}
transition='height none'
variant='ontime-transparent'
{...props}
@@ -1,8 +1,9 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
@@ -39,7 +40,7 @@ export default function DelayInput(props: DelayInputProps) {
}
const isNegative = newValue.startsWith('-');
let newMillis = parseUserTime(newValue);
let newMillis = forgivingStringToMillis(newValue);
if (isNegative) {
newMillis = newMillis * -1;
@@ -0,0 +1,58 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
interface BaseProps {
isTextArea?: boolean;
isFullHeight?: boolean;
size?: Size;
field: EventEditorSubmitActions;
initialText?: string;
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
}
interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: ResizeOptions;
}
type InputProps = TextInputProps | TextAreaProps;
export default function TextInput(props: InputProps) {
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
const inputRef = useRef(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize: ResizeOptions = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
return isTextArea ? (
<Textarea
ref={inputRef}
size={size}
resize={resize}
variant='ontime-filled'
{...textAreaProps}
style={{ height: isFullHeight ? '100%' : undefined }}
data-testid='input-textarea'
/>
) : (
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
);
}
@@ -1,20 +1,17 @@
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
onKeyDown: (event: KeyboardEvent) => void;
}
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -47,45 +44,43 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit !== initialText) {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
if (valueToSubmit === initialText) {
return;
}
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, ref, submitCallback],
[initialText, submitCallback],
);
/**
* @description Handles escape events
* @param {string} valueToSubmit
* @description Handles common keys for submit and cancel
* @param {string} key
*/
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
const keyHandler = useCallback(
(key: string) => {
switch (key) {
case 'Escape':
setText(initialText);
break;
case 'Enter':
if (options?.submitOnEnter) {
handleSubmit(text);
}
break;
}
},
[initialText, options?.submitOnEnter, handleSubmit, text],
);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
onKeyDown: keyHandler,
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
};
}
@@ -1,21 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -56,7 +58,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
submitHandler(name, newValue);
}
const valueInMillis = parseUserTime(newValue);
const valueInMillis = forgivingStringToMillis(newValue);
if (valueInMillis === time) {
return false;
}
@@ -115,13 +117,15 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={className}
className={timeInputClasses}
fontSize='1rem'
type='text'
placeholder={placeholder}
@@ -132,10 +136,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off'
textAlign={align}
/>
);
}
@@ -6,3 +6,7 @@ $input-delayed-border-color: $ontime-delay-text;
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
align='left'
className={style.inputField}
disabled={disabled}
/>
{children}
@@ -7,6 +7,10 @@ $progress-bar-br: 3px;
position: relative;
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -14,42 +18,31 @@ $progress-bar-br: 3px;
}
}
.multiprogress-bar__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--progress-bar-br, $progress-bar-br);
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator {
position: absolute;
inset: 0;
margin: -1px;
margin-left: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
height: inherit;
background-color: black;
opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
}
right: 0;
}
.multiprogress-bar__bg-normal {
flex: 1;
position: absolute;
height: inherit;
right: 0;
width: 100%;
}
.multiprogress-bar__bg-warning {
position: absolute;
height: inherit;
right: 0;
}
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
position: absolute;
height: inherit;
right: 0;
}
@@ -1,62 +1,43 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
complete: number;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const {
now,
complete,
normalColor,
warning,
warningColor,
danger,
dangerColor,
hidden,
ignoreCssOverride,
className = '',
} = props;
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
return (
<div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
>
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
{now !== null && (
<>
<div className='multiprogress-bar__bg'>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
</div>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
</div>
@@ -1,60 +0,0 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,11 +1,12 @@
@use '../../../theme/mixins' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
$button-size: 48px;
.buttonContainer {
display: flex;
@@ -20,7 +21,7 @@ $button-size: 3rem;
position: fixed;
left: 0;
top: 0;
z-index: 12;
z-index: 2;
&.hidden {
opacity: 0;
@@ -28,7 +29,7 @@ $button-size: 3rem;
}
.button {
font-size: 1.5rem;
font-size: 24px;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
@@ -43,12 +44,31 @@ $button-size: 3rem;
z-index: 3;
}
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link {
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
justify-content: space-between;
padding: 0.5rem 1rem;
&:hover {
background-color: $menu-hover-bg;
@@ -71,7 +91,7 @@ $button-size: 3rem;
}
.linkIcon {
margin-left: auto;
display: inline-block;
transform: rotate(45deg);
}
@@ -1,73 +1,87 @@
import { memo, PropsWithChildren, useRef } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
interface NavigationMenuProps {
isOpen: boolean;
onClose: () => void;
}
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
function NavigationMenu() {
const location = useLocation();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const handleFullscreen = () => toggle();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={toggle}
onClick={handleFullscreen}
onKeyDown={(event) => {
isKeyEnter(event) && toggle();
isKeyEnter(event) && handleFullscreen();
}}
>
Toggle Fullscreen
@@ -77,9 +91,9 @@ function NavigationMenu(props: NavigationMenuProps) {
className={style.link}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
isKeyEnter(event) && handleMirror();
}}
>
Flip Screen
@@ -89,73 +103,42 @@ function NavigationMenu(props: NavigationMenuProps) {
className={style.link}
tabIndex={0}
role='button'
onClick={onRenameOpen}
onClick={onOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onRenameOpen();
isKeyEnter(event) && onOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link
to='/editor'
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline />
<Link to='/cuesheet' className={style.link} tabIndex={0}>
Cuesheet
<IoArrowUp className={style.linkIcon} />
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</ClientLink>
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</ClientLink>
</Link>
))}
</DrawerBody>
</DrawerContent>
</Drawer>
</div>
)}
</div>
</div>,
document.body,
);
}
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu);
@@ -1,16 +0,0 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -1,27 +0,0 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
export default memo(ViewNavigationMenu);
@@ -0,0 +1,5 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
const { name: clientName, persistName } = useSocketClientName();
const [newName, setNewName] = useState(clientName);
useEffect(() => {
setNewName(clientName);
}, [isOpen, clientName]);
const handleRename = async () => {
if (newName) {
setClientName(newName);
persistName(newName);
onClose();
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='sm'
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light'
/>
<Button
isDisabled={newName === clientName || !newName}
onClick={handleRename}
width='100%'
variant='ontime-filled'
>
Save
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -1,14 +1,13 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
$progress-bar-br: 6px;
.progress-bar__bg {
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
@@ -19,5 +18,6 @@ $progress-bar-br: 3px;
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
border-radius: $progress-bar-br 0 0 $progress-bar-br;
transition-property: width;
}
@@ -1,23 +1,22 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { clamp } from '../../utils/math';
import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
now?: number;
complete?: number;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
const { now = 0, complete = 100, hidden, className = '' } = props;
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return (
<div className={style.container}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
@@ -57,15 +57,8 @@ export default function PinPage(props: PinPageProps) {
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
</div>
);
}
@@ -1,31 +1,27 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
background: $bg-container-l1;
color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200;
font-size: 3rem;
text-align: center;
font-size: 3vw;
}
.pin,
.pin__failed {
display: flex;
gap: 0.125em;
padding-block: 0.5em;
padding: 20px;
input {
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
border-radius: 50%;
}
button {
margin-left: 1em;
margin-left: 20px;
}
}
@@ -37,9 +33,9 @@
@keyframes colourFade {
from {
background: $red-500;
background: $action-blue;
}
to {
background: rgba($red-500, 0);
background: rgba($action-blue, 0);
}
}
}
@@ -24,8 +24,6 @@ interface ScheduleProviderProps {
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
@@ -39,7 +37,7 @@ export const ScheduleProvider = ({
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
@@ -1,37 +1,25 @@
@use '../../../theme/viewerDefs' as *;
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.5rem;
}
.title-card__title,
.title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.inline {
display: flex;
}
.title-card__title {
font-weight: 600;
font-size: clamp(1.5rem, 3vw, 3rem);
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
line-height: 1.1em;
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
@@ -40,3 +28,13 @@
color: var(--accent-color-override, $accent-color);
}
}
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -1,32 +1,28 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss';
interface TitleCardProps {
label: 'now' | 'next';
title: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
}
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary, className = '' } = props;
export default function TitleCard(props: TitleCardProps) {
const { label, title, secondary } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
return (
<div className={`title-card ${className}`} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card'>
<div className='inline'>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{getLocalizedString(`common.${label}`)}
</span>
</div>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
});
TitleCard.displayName = 'TitleCard';
export default TitleCard;
}
@@ -1,18 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -27,10 +14,6 @@ export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
@@ -51,10 +34,6 @@ export default function ParamInput(props: EditFormInputProps) {
);
}
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
@@ -91,47 +70,3 @@ export default function ParamInput(props: EditFormInputProps) {
</InputGroup>
);
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title}
</MenuButton>
<MenuList>
<MenuOptionGroup
type='checkbox'
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
>
{Object.values(paramField.values).map((option) => {
const { value, label } = option;
return (
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
</>
);
}
@@ -1,9 +1,24 @@
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
justify-content: end;
justify-content: start;
gap: $section-spacing;
button {
button[type='reset'] {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
padding: 0 2em;
}
}
@@ -11,26 +26,13 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
.columnSection {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
@@ -13,7 +13,7 @@ import {
} from '@chakra-ui/react';
import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
@@ -22,37 +22,25 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const newSearchParams = new URLSearchParams();
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
acc[id] = String(defaultValue);
return acc;
}, {});
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
}
});
return newSearchParams;
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
viewOptions: ViewOption[];
paramFields: ParamField[];
}
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
@@ -64,68 +52,54 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
const handleClose = () => {
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
onClose();
};
const resetParams = () => {
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setSearchParams(newSearchParams);
onClose();
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
Customise
</DrawerHeader>
<DrawerBody>
<DrawerBody className={style.drawerContent}>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => {
if (isSection(option)) {
return (
<div key={option.section} className={style.section}>
{option.section}
</div>
);
}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
<label className={style.label}>
<span className={style.title}>{field.title}</span>
<span className={style.description}>{field.description}</span>
<ParamInput key={field.title} paramField={field} />
</label>
</div>
))}
</form>
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset to default
Reset
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
@@ -1,18 +1,17 @@
import { CustomFields } from 'ontime-types';
import type { ParamField } from './types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
) => {
return Object.entries(customFields).reduce((options, [key, value]) => {
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
import { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
}, additionalOptions ?? {});
return customFieldOptions;
};
export const getTimeOption = (timeFormat: string): ParamField => {
const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
@@ -23,7 +22,7 @@ export const getTimeOption = (timeFormat: string): ParamField => {
};
};
export const hideTimerSeconds: ParamField = {
const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
@@ -31,10 +30,475 @@ export const hideTimerSeconds: ParamField = {
defaultValue: false,
};
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'lowerMsg',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
}, {});
return [
getTimeOption(timeFormat),
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'option',
values: customFieldSelect,
defaultValue: '',
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,7 +1,3 @@
type ParamSection = {
section: string;
};
type BaseField = {
id: string;
title: string;
@@ -13,25 +9,8 @@ type OptionsField = {
values: Record<string, string>;
defaultValue?: string;
};
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
@@ -11,10 +11,10 @@ export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
@@ -11,13 +11,14 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
@@ -10,7 +10,7 @@ export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: ontimePlaceholderInfo,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,7 +9,7 @@ export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -14,7 +14,7 @@ export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: placeholderProjectList,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
@@ -13,10 +13,10 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
@@ -10,7 +10,7 @@ export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: ontimePlaceholderSettings,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,12 +9,12 @@ export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: viewsSettingsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
return { data, status, isError, refetch, isFetching };
}
@@ -1,32 +0,0 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect);
const setRedirect = useClientStore((store) => store.setRedirect);
// notify of client path changes
useEffect(() => {
socketSendJson('set-client-path', pathname + search);
}, [pathname, search]);
// navigate to new path when received from server
useEffect(() => {
if (redirect === '') {
return;
}
// clear redirect
setRedirect('');
// navigate if there is a path change
if (redirect !== pathname + search) {
navigate(redirect);
}
}, [navigate, pathname, redirect, search, setRedirect]);
};
+15 -67
View File
@@ -1,17 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import {
dayInMs,
getLinkedTimes,
getPreviousEventNormal,
MILLIS_PER_SECOND,
parseUserTime,
reorderArray,
swapEventData,
validateEndAction,
validateTimerType,
} from 'ontime-utils';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
import {
@@ -28,21 +18,14 @@ import {
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
* @description Set of utilities for events
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const {
defaultPublic,
linkPrevious,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
/**
* Calls mutation to add new event
@@ -56,12 +39,10 @@ export const useEventAction = () => {
networkMode: 'always',
});
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
@@ -78,7 +59,6 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
@@ -98,29 +78,12 @@ export const useEventAction = () => {
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = parseUserTime(defaultDuration);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEvent.timerType === undefined) {
newEvent.timerType = validateTimerType(defaultTimerType);
}
if (newEvent.endAction === undefined) {
newEvent.endAction = validateEndAction(defaultEndAction);
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
}
@@ -135,17 +98,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultWarnTime,
linkPrevious,
queryClient,
],
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
);
/**
@@ -202,7 +155,7 @@ export const useEventAction = () => {
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: value } });
updateEvent({ id: eventId, custom: { [field]: { value } } });
},
[updateEvent],
);
@@ -245,17 +198,14 @@ export const useEventAction = () => {
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
} else {
newValMillis = parseUserTime(value);
newValMillis = forgivingStringToMillis(value);
}
// dont allow timer values over 23:59:59
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
const newEvent = {
id: eventId,
[field]: cappedMillis,
[field]: newValMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
@@ -365,7 +315,7 @@ export const useEventAction = () => {
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventIds: string[]) => {
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -374,11 +324,9 @@ export const useEventAction = () => {
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
delete newRundown[eventId];
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
@@ -407,9 +355,9 @@ export const useEventAction = () => {
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventIds: string[]) => {
async (eventId: string) => {
try {
await _deleteEventMutation.mutateAsync(eventIds);
await _deleteEventMutation.mutateAsync(eventId);
} catch (error) {
logAxiosError('Error deleting event', error);
}
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
@@ -0,0 +1,46 @@
import { useSyncExternalStore } from 'react';
const STORAGE_EVENT = 'ontime-storage';
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
/**
* @description Set value to local storage
* @param value
*/
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
};
return [parsedLocalStorageValue, setLocalStorageValue] as const;
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timeout>();
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
+26 -73
View File
@@ -1,14 +1,8 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
};
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
@@ -28,43 +22,13 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector);
};
export const useTimerViewControl = () => {
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
timer: state.message.timer,
public: state.message.public,
lower: state.message.lower,
external: state.message.external,
onAir: state.onAir,
});
return useRuntimeStore(featureSelector);
@@ -73,11 +37,12 @@ export const useMessagePreview = () => {
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
};
export const usePlaybackControl = () => {
@@ -85,7 +50,6 @@ export const usePlaybackControl = () => {
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
@@ -95,12 +59,12 @@ export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start', 'next'),
startNext: () => socketSendJson('start', { next: true }),
previous: () => {
socketSendJson('load', 'previous');
socketSendJson('load', { previous: true });
},
next: () => {
socketSendJson('load', 'next');
socketSendJson('load', { next: true });
},
stop: () => {
socketSendJson('stop');
@@ -125,33 +89,32 @@ export const useInfoPanel = () => {
return useRuntimeStore(featureSelector);
};
export const useAuxTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.timer1.current;
return useRuntimeStore(featureSelector);
};
export const useAuxTimerControl = () => {
export const useExtraTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
playback: state.timer1.playback,
direction: state.timer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setAuxTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
export const setExtraTimer = {
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
};
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
@@ -186,6 +149,7 @@ export const useClock = () => {
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
@@ -215,17 +179,6 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
@@ -1,11 +0,0 @@
import { useEffect } from 'react';
/**
* Sets tab title
* @param title
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
}, []);
}
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
@@ -8,11 +8,12 @@ export type ViewExtendedTimer = {
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType;
};
@@ -19,10 +19,13 @@ function persistModeToSession(mode: AppMode) {
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => {
persistModeToSession(mode);
@@ -30,4 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
}));
@@ -1,82 +0,0 @@
import { ClientList } from 'ontime-types';
import { create } from 'zustand';
interface ClientStore {
name?: string;
setName: (newValue: string) => void;
id: string;
setId: (newValue: string) => void;
redirect: string;
setRedirect: (newValue: string) => void;
clients: ClientList;
setClients: (clients: ClientList) => void;
}
const clientNameKey = 'ontime-client-name';
function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const useClientStore = create<ClientStore>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (name: string) =>
set(() => {
persistNameInStorage(name);
return { name };
}),
id: '',
setId: (id) => set({ id }),
redirect: '',
setRedirect: (redirect: string) => set({ redirect }),
clients: {},
setClients: (clients: ClientList) => set({ clients }),
}));
/**
* Allows getting client name (outside react)
*/
export function getClientName(): string | undefined {
return useClientStore.getState().name;
}
/**
* Allows updating current client name (outside react)
*/
export function setClientName(name: string): void {
useClientStore.getState().setName(name);
}
/**
* Allows updating current client name (outside react)
*/
export function setClientId(id: string): void {
useClientStore.getState().setId(id);
}
/**
* Allows getting client name (outside react)
*/
export function getClientId(): string | undefined {
return useClientStore.getState().id;
}
/**
* Allows updating redirect (outside react)
*/
export function setClientRedirect(path: string): void {
useClientStore.getState().setRedirect(path);
}
/**
* Allows setting clients (outside react)
*/
export function setClients(clients: ClientList): void {
return useClientStore.getState().setClients(clients);
}
@@ -0,0 +1,26 @@
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
interface SocketClientNameState {
name?: string;
setName: (newValue: string) => void;
persistName: (newValue: string) => void;
}
const clientNameKey = 'ontime-client-name';
function persistKeyToStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const socketClientName = createStore<SocketClientNameState>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (newValue: string) => set(() => ({ name: newValue })),
persistName: (newValue: string) =>
set(() => {
persistKeyToStorage(newValue);
return { name: newValue };
}),
}));
export const useSocketClientName = () => useStore(socketClientName);
+50 -80
View File
@@ -1,97 +1,67 @@
import { EndAction, TimerType } from 'ontime-types';
import { validateEndAction, validateTimerType } from 'ontime-utils';
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettingsStore = {
defaultDuration: string;
type EditorSettings = {
showQuickEntry: boolean;
linkPrevious: boolean;
defaultWarnTime: string;
defaultDangerTime: string;
defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
defaultDuration: string;
};
export const editorSettingsDefaults = {
duration: '00:10:00',
linkPrevious: true,
warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend
isPublic: true,
timerType: TimerType.CountDown,
endAction: EndAction.None,
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultDuration: (defaultDuration: string) => void;
};
enum EditorSettingsKeys {
DefaultDuration = 'ontime-default-duration',
ShowQuickEntry = 'ontime-show-quick-entry',
LinkPrevious = 'ontime-link-previous',
DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
DefaultDuration = 'ontime-default-duration',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
return {
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
},
setDefaultDuration: (defaultDuration) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { defaultDuration };
}),
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setLinkPrevious: (linkPrevious) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { linkPrevious };
}),
setWarnTime: (defaultWarnTime) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
return { defaultWarnTime };
}),
setDangerTime: (defaultDangerTime) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
return { defaultDangerTime };
}),
setDefaultPublic: (defaultPublic) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic };
}),
setDefaultTimerType: (defaultTimerType) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
return { defaultTimerType };
}),
setDefaultEndAction: (defaultEndAction) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
};
});
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setLinkPrevious: (linkPrevious) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { eventSettings: { ...state.eventSettings, linkPrevious } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setDefaultDuration: (defaultDuration) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { eventSettings: { ...state.eventSettings, defaultDuration } };
}),
}));
@@ -1,11 +0,0 @@
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
}));
+14 -10
View File
@@ -1,5 +1,5 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
@@ -11,7 +11,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
@@ -23,9 +22,19 @@ export const runtimeStorePlaceholder: RuntimeStore = {
visible: false,
blink: false,
blackout: false,
secondarySource: null,
},
external: '',
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
selectedEventIndex: null,
@@ -36,21 +45,16 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null,
expectedEnd: null,
},
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
auxtimer1: {
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
frozen: false,
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
@@ -1,4 +1,263 @@
import { millisToDelayString } from '../dateConfig';
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
const testData = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: 1000 },
{ value: '0h1m0s', expect: 1000 * 60 },
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '2m', expect: 2 * 60 * 1000 },
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
];
for (const s of testData) {
it(`handles ${s.value} to left`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
{ value: '10m', expect: 10 * 60 * 1000 },
{ value: '10s', expect: 10 * 1000 },
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
{ value: '120m', expect: 120 * 60 * 1000 },
{ value: '120s', expect: 120 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles am/pm', () => {
const ampm = [
{ value: '9:10:11am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '12am', expect: 0 },
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
];
for (const s of ampm) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: 1000 * 60 }, // 00:01:00
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 12:34:56
];
for (const s of testCases) {
it(`handles basic strings digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const sixDigits = [
{ value: '000000', expect: 0 },
{ value: '000001', expect: 1000 },
{ value: '000100', expect: 1000 * 60 },
{ value: '010000', expect: 1000 * 60 * 60 },
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
];
for (const s of sixDigits) {
it(`handles string with 6 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: 1000 }, // 00:00:01
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:01
];
for (const s of fiveDigits) {
it(`handles string with 5 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: 1000 * 60 }, // 00:01:00
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:00
];
for (const s of fourDigits) {
it(`handles string with 4 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: 1000 * 60 }, // 00:01:00
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 01:21:00
];
for (const s of threeDigits) {
it(`handles string with 3 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: 1000 * 60 }, // 00:01:00
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '23', expect: 1000 * 60 * 23 }, // 00:23:00
];
for (const s of twoDigits) {
it(`handles string with 2 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const singleDigit = [...Array(10).keys()];
for (const s of singleDigit) {
it(`handles string with a single digits ${s}`, () => {
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '2.0.0', expect: 1000 * 60 * 120 },
{ value: '99', expect: 1000 * 60 * 99 },
{ value: '1.39.0', expect: 1000 * 60 * 99 },
// seconds overflow
{ value: '0.0.120', expect: 120 * 1000 },
{ value: '0.2.0', expect: 120 * 1000 },
{ value: '0.0.99', expect: 99 * 1000 },
{ value: '0.1.39', expect: 99 * 1000 },
// hours overflow
{ value: '25.0.0', expect: 1000 * 60 * 60 * 25 },
// hours overflow
{ value: '50.0.0', expect: 1000 * 60 * 60 * 50 },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: 3723000 },
{ value: '2,10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
{ value: '2.10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
{ value: '2 10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
{ value: '1.70', expect: 60 * 60 * 1000 + 70 * 60 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * 60 * 1000 },
{ value: '0.99', expect: 99 * 60 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
});
});
describe('millisToDelayString()', () => {
it('returns null for null values', () => {

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