Compare commits

...

100 Commits

Author SHA1 Message Date
Carlos Valente e2c9ee31ad bump version to 3.16.1 2025-08-15 19:54:38 +02:00
Carlos Valente bfd85855d4 fix: prevent storage key conflicts between stages 2025-08-15 19:54:38 +02:00
Carlos Valente 20859abda9 chore: add demo images to github 2025-08-09 07:01:01 +02:00
Alex Christoffer Rasmussen 1025347c08 refactor: add version to session endpoint (#1659) 2025-06-23 20:42:53 +02:00
Carlos Valente 43988ea12b fix: stale ids in cuesheet (#1657) 2025-06-23 19:56:53 +02:00
Alex Christoffer Rasmussen 722e045b20 Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint"

This reverts commit cd40a6e55e.

* Revert "Remove public event feature (#1645)"

This reverts commit 08d9e24871.

* Revert "Fix: rearrange playing event (#1640)"

This reverts commit 0649678dca.

* Revert "refactor: style tweaks to edit css modal"

This reverts commit a00ec2d02a.

* Revert "refactor: remove usages of framer-motion"

This reverts commit 54a74ccc2a.

* Revert "Upgrade expressjs (#1633)"

This reverts commit 6f3ab274bd.

* Revert "Refactor: require trigger in all events objects (#1636)"

This reverts commit 90870ecfb6.

* Revert "Fix: Correct boundary condition in applyDelay"

This reverts commit b640e0e181.

* Revert "let vite be the proxy to the dev server (#1630)"

This reverts commit c41fe824cf.

* Revert "refactor: migrate custom fields to transactions"

This reverts commit 62c8319d70.

* Revert "refactor: simplify validations"

This reverts commit b1d23467a2.

* Revert "refactor: create transaction system and apply to adding entry (#1620)"

This reverts commit 9a62daf047.

* Revert "Refactor: better rounding (#1594)"

This reverts commit b9ab1c6fd7.

* Revert "refactor: improve reorder logic"

This reverts commit c1054711b0.

* Revert "chore: simplify URLs"

This reverts commit a4d4f29a37.

* Revert "refactor: order is single source of truth"

This reverts commit 2793aadea0.

* Revert "refactor: refetch targets is enum"

This reverts commit e7cfb7d9d9.

* Revert "refactor: small ux improvements"

This reverts commit 256a851c9b.

* Revert "refactor: remove trivially inferred numEvents"

This reverts commit 4ab9c81cb8.

* Revert "feat: duplicate groups"

This reverts commit 2f13d6c89e.

* Revert "feat: create group from entry selection"

This reverts commit f1f7bad25e.

* Revert "feat: create block from rundown empty"

This reverts commit a8b52a48f7.

* Revert "fix: collapsed blocks dont render children"

This reverts commit cd0999b2ab.

* Revert "refactor: type cleanup and test improvements"

This reverts commit b4c60f3f04.

* Revert "feat: allow dissolving a block"

This reverts commit 5cefad3666.

* Revert "fix: uncontrolled prop on controlled component"

This reverts commit 7a6ecd8c34.

* Revert "refactor: improve return of reorder"

This reverts commit ba96ecfd91.

* Revert "refactor: mutations on batch elements must have IDs"

This reverts commit 7bed3757f2.

* Revert "chore: upgrade dependencies"

This reverts commit e2e755b1d2.

* Revert "refactor: extract utility to merge two arrays"

This reverts commit a77d23109d.

* Revert "refactor: change network mode defaults"

This reverts commit 0eb3b8d382.

* Revert "fix: delete nested events"

This reverts commit 0021185288.

* Revert "fix: add event at end of block"

This reverts commit 94c72ff4f6.

* Revert "refactor: make finder available in exported rundown"

This reverts commit e4c08dc9b2.

* Revert "assert non null and update test (#1604)"

This reverts commit ec74af0d62.

* Revert "Fix project renumber (#1597)"

This reverts commit b6d72dd082.

* Revert "Refactor: WebSocket from flush queue to one patch (#1595)"

This reverts commit 31c311daf0.

* Revert "Refactor: ms for api calls (#1593)"

This reverts commit e9b3cc6090.

* Revert "fix test (#1601)"

This reverts commit 543b04a097.

* Revert "fix: rebase master"

This reverts commit d39b85b6e6.

* Revert "chore: correct test path"

This reverts commit bbe107bb2b.

* Revert "refactor: extract rundown parsing"

This reverts commit c616240db1.

* Revert "chore: improve convention entry <> event"

This reverts commit 166be66ce3.

* Revert "refactor: maintain flat orders"

This reverts commit 4180d0a337.

* Revert "refactor: implement operations on nested events"

This reverts commit 78108e316c.

* Revert "refactor: process events in rundown"

This reverts commit 3bb8b70915.

* Revert "chore: improve convention entry <> event"

This reverts commit 1c4f13a0ed.

* Revert "chore: rename currentBlock > parent"

This reverts commit 3ca0abad53.

* Revert "refactor: fix delay positioning in gaps"

This reverts commit 030c8f897f.

* Revert "refactor(e2e): skip flaky test"

This reverts commit 68175cfa3b.

* Revert "refactor: improve project loading"

This reverts commit fd8f757851.

* Revert "refactor: gather group metadata"

This reverts commit 876d111c61.

* Revert "refactor: swap maintains schedule"

This reverts commit 730cb95c04.

* Revert "chore: rename files"

This reverts commit 3c388d4fb5.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 2e23718d73.

* Revert "refactor: clearer relationship on rundown elements"

This reverts commit 89ea8c470b.

* Revert "refactor: use strict typing"

This reverts commit 178640bfc4.

* Revert "refactor: remove stop as a possible end action"

This reverts commit 4ed38340e0.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 69eb9a5eff.

* Revert "chore: remove IDE files"

This reverts commit 351425127a.

* Revert "refactor: remove unused and legacy code"

This reverts commit 95f2ba37cc.
2025-06-20 12:28:20 +02:00
Carlos Valente ad6804019b bump version to 3.16.0 2025-06-20 07:41:39 +02:00
Carlos Valente cd40a6e55e refactor: add version to session endpoint 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 08d9e24871 Remove public event feature (#1645) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 0649678dca Fix: rearrange playing event (#1640)
* creat failing test

* find and pass index of eventNow in case it moved

* fix cache invalidate edits should also recalculate timer

* use coluor as test indicator

* fixed empty cue guard

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] a00ec2d02a refactor: style tweaks to edit css modal 2025-06-19 18:53:28 +02:00
google-labs-jules[bot] 54a74ccc2a refactor: remove usages of framer-motion 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 6f3ab274bd Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 90870ecfb6 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] b640e0e181 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen c41fe824cf let vite be the proxy to the dev server (#1630) 2025-06-19 18:53:28 +02:00
Carlos Valente 62c8319d70 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-19 18:53:28 +02:00
Carlos Valente b1d23467a2 refactor: simplify validations 2025-06-19 18:53:28 +02:00
Carlos Valente 9a62daf047 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b9ab1c6fd7 Refactor: better rounding (#1594) 2025-06-19 18:53:28 +02:00
Carlos Valente c1054711b0 refactor: improve reorder logic 2025-06-19 18:53:28 +02:00
Carlos Valente a4d4f29a37 chore: simplify URLs 2025-06-19 18:53:28 +02:00
Carlos Valente 2793aadea0 refactor: order is single source of truth 2025-06-19 18:53:28 +02:00
Carlos Valente e7cfb7d9d9 refactor: refetch targets is enum 2025-06-19 18:53:28 +02:00
Carlos Valente 256a851c9b refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-19 18:53:28 +02:00
Carlos Valente 4ab9c81cb8 refactor: remove trivially inferred numEvents 2025-06-19 18:53:28 +02:00
Carlos Valente 2f13d6c89e feat: duplicate groups 2025-06-19 18:53:28 +02:00
Carlos Valente f1f7bad25e feat: create group from entry selection 2025-06-19 18:53:28 +02:00
Carlos Valente a8b52a48f7 feat: create block from rundown empty 2025-06-19 18:53:28 +02:00
Carlos Valente cd0999b2ab fix: collapsed blocks dont render children 2025-06-19 18:53:28 +02:00
Carlos Valente b4c60f3f04 refactor: type cleanup and test improvements 2025-06-19 18:53:28 +02:00
Carlos Valente 5cefad3666 feat: allow dissolving a block 2025-06-19 18:53:28 +02:00
Carlos Valente 7a6ecd8c34 fix: uncontrolled prop on controlled component 2025-06-19 18:53:28 +02:00
Carlos Valente ba96ecfd91 refactor: improve return of reorder 2025-06-19 18:53:28 +02:00
Carlos Valente 7bed3757f2 refactor: mutations on batch elements must have IDs 2025-06-19 18:53:28 +02:00
Carlos Valente e2e755b1d2 chore: upgrade dependencies 2025-06-19 18:53:28 +02:00
Carlos Valente a77d23109d refactor: extract utility to merge two arrays 2025-06-19 18:53:28 +02:00
Carlos Valente 0eb3b8d382 refactor: change network mode defaults 2025-06-19 18:53:28 +02:00
Carlos Valente 0021185288 fix: delete nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 94c72ff4f6 fix: add event at end of block 2025-06-19 18:53:28 +02:00
Carlos Valente e4c08dc9b2 refactor: make finder available in exported rundown 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen ec74af0d62 assert non null and update test (#1604) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b6d72dd082 Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 31c311daf0 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen e9b3cc6090 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 543b04a097 fix test (#1601)
* fix test

* add comment
2025-06-19 18:53:28 +02:00
Carlos Valente d39b85b6e6 fix: rebase master 2025-06-19 18:53:28 +02:00
Carlos Valente bbe107bb2b chore: correct test path 2025-06-19 18:53:28 +02:00
Carlos Valente c616240db1 refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-19 18:53:28 +02:00
Carlos Valente 166be66ce3 chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 4180d0a337 refactor: maintain flat orders 2025-06-19 18:53:28 +02:00
Carlos Valente 78108e316c refactor: implement operations on nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 3bb8b70915 refactor: process events in rundown 2025-06-19 18:53:28 +02:00
Carlos Valente 1c4f13a0ed chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 3ca0abad53 chore: rename currentBlock > parent 2025-06-19 18:53:28 +02:00
Carlos Valente 030c8f897f refactor: fix delay positioning in gaps 2025-06-19 18:53:28 +02:00
Carlos Valente 68175cfa3b refactor(e2e): skip flaky test 2025-06-19 18:53:28 +02:00
Carlos Valente fd8f757851 refactor: improve project loading 2025-06-19 18:53:28 +02:00
Carlos Valente 876d111c61 refactor: gather group metadata 2025-06-19 18:53:28 +02:00
Carlos Valente 730cb95c04 refactor: swap maintains schedule 2025-06-19 18:53:28 +02:00
Carlos Valente 3c388d4fb5 chore: rename files 2025-06-19 18:53:28 +02:00
Carlos Valente 2e23718d73 refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 89ea8c470b refactor: clearer relationship on rundown elements 2025-06-19 18:53:28 +02:00
Carlos Valente 178640bfc4 refactor: use strict typing 2025-06-19 18:53:28 +02:00
Carlos Valente 4ed38340e0 refactor: remove stop as a possible end action 2025-06-19 18:53:28 +02:00
Carlos Valente 69eb9a5eff refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 351425127a chore: remove IDE files 2025-06-19 18:53:28 +02:00
Carlos Valente 95f2ba37cc refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-19 18:53:28 +02:00
jwetzell fe8bbd5003 add electron and esbuild to allow list for postinstall builds 2025-06-06 20:25:46 +02:00
jwetzell c6e38a2b4a upgrade vite to v6 (#1626) 2025-06-05 06:27:59 +02:00
jwetzell 56f18fe71c update node and pnpm versions in README 2025-06-05 06:13:04 +02:00
jwetzell 0b9306de82 update pnpm to latest v10 2025-06-05 06:13:04 +02:00
jwetzell 3b1e6cf33d update node version in workflows 2025-06-05 06:13:04 +02:00
jwetzell dda219ebbe update NodeJS to v22 2025-06-05 06:13:04 +02:00
jwetzell 8bb771e5c6 upgrade electron and electron-builder 2025-06-05 06:13:04 +02:00
Shobhit Nagpal 8723bcbd33 refactor: block event xlsx import (#1623) 2025-06-02 06:56:46 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
Shobhit-Nagpal ac61f257c0 fix: import preview enable after error 2025-05-13 21:24:14 +02:00
Carlos Valente 8d8625aa77 bump version to 3.15.2 2025-05-13 21:24:14 +02:00
Alex Christoffer Rasmussen 1e1aa4bca5 Fix: sheet export (#1606)
* add util to get keys from labels

* look for existing keys first then create new

* save ontime key to rundown metadata

* update test
2025-05-12 13:56:25 +02:00
Alex Christoffer Rasmussen 1f0cde6d0d fix: relative offset with count to end (#1602)
* refactor merge absolute and relative offset calculation

* update test
2025-05-07 22:34:25 +02:00
Carlos Valente c4b66a9ed3 bump version to 3.15.1 2025-04-30 20:28:42 +02:00
Carlos Valente 133b3aa819 refactor: create hash of table state 2025-04-30 20:23:49 +02:00
Carlos Valente 9566fadbbb chore: upgrade table dependencies 2025-04-30 20:23:49 +02:00
Carlos Valente 62570899e8 fix: issue when user hides all columns 2025-04-30 20:23:49 +02:00
Carlos Valente c1f25f8dba bump version to 3.15.0 2025-04-20 13:52:43 +02:00
Alex Christoffer Rasmussen 37a91026d8 FIX: editing event data clears delay value (#1591)
* leave data in place in case it is not regenerated

* update comments
2025-04-20 13:51:25 +02:00
Bastian Widmer c62ce213f5 fix - fix studio clock sizing when timer seconds are hidden !1585 2025-04-20 13:28:22 +02:00
Alex Christoffer Rasmussen 529751578d refactor: lower third (#1507)
* refactor

* separate in and out time

* keep element on screen

* animate on play

* spelling

* explain animation decisions

* guard numbers from url params

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename safeParseNumber

* use view data

* refactor getOptionsFromParams

* rebalance animation timing

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-04-20 12:50:16 +02:00
Alex Christoffer Rasmussen dce87c3a81 Relative run mode (#1512)
* inti relative mode

* send imediat update on mode change

* add relative to test

* don't persist offset mode

* add test relative and update calc function

* pass data from ssocket

* add dev guard

* spelling and comments

* show relative in offset overview

* remove comments

* refactor: move totalDelay to _rundown

* update  clear naming and comments

* remove old testing values
2025-04-19 21:36:54 +02:00
Alex Christoffer Rasmussen a94b10cb0f Event trigger (#1557)
* crude UI

* add server functionalety

* better event trigger list

* fix default value

* remove log

* add trigger to recalculate whitelist

* use DTO type

* fix rebase

* one callback for delete and submit

* refactor

* add invalid description

* clean up commits

* cleanup

* refactor: find event triggers in `triggerAutomations`

* refactor: switch to flat array for event triggers

* prevent deleting a automation that is in use

* refactor

* refactor: extract EventEditorCustom

* move trigger options to separate file

* move trigger edit down a file level

* cleanup

* spelling

* refactor: ui review proposal

* change default value

* fix delete filter

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-04-19 21:27:07 +02:00
Shobhit Nagpal 6817263123 feat: css editor (#1542)
* feat: allow editing CSS from settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

refactor: style tweaks
2025-04-19 20:40:18 +02:00
Alex Christoffer Rasmussen ddd629d7db in cloud force port 4001 (#1575)
* force 4001

* only log if there is anything to change

* skip port retry in cloud
2025-04-15 21:36:59 +02:00
Carlos Valente 9d2a3d5a12 bump version to 3.14.5 2025-04-15 21:25:46 +02:00
Carlos Valente 4bc271208e refactor: improve synchronisation of external text 2025-04-15 21:25:33 +02:00
Carlos Valente dbd535dd6c refactor: timeout for offline status 2025-04-15 21:25:33 +02:00
Carlos Valente 57b7fe214a refactor: avoid old language features 2025-04-15 21:25:33 +02:00
cv b136e2856a refactor: simplify logic 2025-04-15 21:25:33 +02:00
131 changed files with 3964 additions and 1312 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v20.15.1 v22.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~20) - __node__ (~22)
- __pnpm__ (~9) - __pnpm__ (~10)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:20-bullseye AS builder FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0 RUN npm install -g pnpm@10.11.0
COPY . /app COPY . /app
WORKDIR /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 --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:20-alpine FROM node:22-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.14.4", "version": "3.16.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+10 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.14.4", "version": "3.16.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -17,12 +17,13 @@
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -31,6 +32,7 @@
"react-icons": "5.4.0", "react-icons": "5.4.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@@ -65,11 +67,12 @@
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "4.5.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
@@ -83,10 +86,10 @@
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "6.3.1",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
@@ -3,8 +3,9 @@
font-size: $text-body-size; font-size: $text-body-size;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
color: $blue-400;
&:hover { &:hover {
color: $blue-400; color: $blue-500;
} }
} }
@@ -1,6 +1,7 @@
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { baseURI } from '../../../externals';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
@@ -17,7 +18,7 @@ interface ViewParamsSectionProps {
export default function ViewParamsSection(props: ViewParamsSectionProps) { export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props; const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `${baseURI}params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
if (collapsible) { if (collapsible) {
@@ -1,5 +1,6 @@
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { baseURI } from '../../externals';
import useSettings from '../hooks-query/useSettings'; import useSettings from '../hooks-query/useSettings';
interface AppContextType { interface AppContextType {
@@ -26,7 +27,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => { useEffect(() => {
if (status === 'pending') return; if (status === 'pending') return;
const previousEditor = sessionStorage.getItem(storageKeys.editor); const previousEditor = sessionStorage.getItem(`${baseURI}${storageKeys.editor}`);
if (previousEditor && previousEditor === data.editorKey) { if (previousEditor && previousEditor === data.editorKey) {
setEditorAuth(true); setEditorAuth(true);
@@ -34,7 +35,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
setEditorAuth(data.editorKey == null || data.editorKey === ''); setEditorAuth(data.editorKey == null || data.editorKey === '');
} }
const previousOperator = sessionStorage.getItem(storageKeys.operator); const previousOperator = sessionStorage.getItem(`${baseURI}${storageKeys.operator}`);
if (previousOperator && previousOperator === data.operatorKey) { if (previousOperator && previousOperator === data.operatorKey) {
setOperatorAuth(true); setOperatorAuth(true);
} else { } else {
@@ -55,14 +56,14 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
if (permission === 'editor') { if (permission === 'editor') {
const correct = isValid(pin, data.editorKey); const correct = isValid(pin, data.editorKey);
if (correct) { if (correct) {
sessionStorage.setItem(storageKeys.editor, pin); sessionStorage.setItem(`${baseURI}${storageKeys.editor}`, pin);
} }
setEditorAuth(correct); setEditorAuth(correct);
return correct; return correct;
} else if (permission === 'operator') { } else if (permission === 'operator') {
const correct = isValid(pin, data.operatorKey); const correct = isValid(pin, data.operatorKey);
if (correct) { if (correct) {
sessionStorage.setItem(storageKeys.operator, pin); sessionStorage.setItem(`${baseURI}${storageKeys.operator}`, pin);
} }
setOperatorAuth(correct); setOperatorAuth(correct);
return correct; return correct;
@@ -41,7 +41,7 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown); setFlatRunDown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
+12 -3
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
currentBlock: state.currentBlock, currentBlock: state.currentBlock,
})); }));
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
})); }));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0, isOnline: state.ping > 0,
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
@@ -1,12 +1,14 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
export enum AppMode { export enum AppMode {
Run = 'run', Run = 'run',
Edit = 'edit', Edit = 'edit',
Freeze = 'freeze', Freeze = 'freeze',
} }
const appModeKey = 'ontime-app-mode'; const appModeKey = `${baseURI}ontime-app-mode`;
function getModeFromSession() { function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit; return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
+3 -1
View File
@@ -1,6 +1,8 @@
import { ClientList } from 'ontime-types'; import { ClientList } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
interface ClientStore { interface ClientStore {
name?: string; name?: string;
setName: (newValue: string) => void; setName: (newValue: string) => void;
@@ -15,7 +17,7 @@ interface ClientStore {
setClients: (clients: ClientList) => void; setClients: (clients: ClientList) => void;
} }
const clientNameKey = 'ontime-client-name'; const clientNameKey = `${baseURI}ontime-client-name`;
function persistNameInStorage(newValue: string) { function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue); localStorage.setItem(clientNameKey, newValue);
+4 -2
View File
@@ -1,6 +1,8 @@
// eslint-disable-next-line simple-import-sort/imports
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
import { baseURI } from '../../externals';
enum LocalEventKeys { enum LocalEventKeys {
Mirror = 'ontime-view-mirror', Mirror = 'ontime-view-mirror',
@@ -12,11 +14,11 @@ type ViewOptionsStore = {
}; };
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({ export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false), mirror: booleanFromLocalStorage(`${baseURI}${LocalEventKeys.Mirror}`, false),
toggleMirror: (newValue?: boolean) => toggleMirror: (newValue?: boolean) =>
set((state) => { set((state) => {
const val = typeof newValue === 'undefined' ? !state.mirror : newValue; const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
localStorage.setItem(LocalEventKeys.Mirror, String(val)); localStorage.setItem(`${baseURI}${LocalEventKeys.Mirror}`, String(val));
return { mirror: val }; return { mirror: val };
}), }),
})); }));
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`; exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
@@ -1,3 +1,6 @@
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time'; import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
}); });
describe('calculateTimeUntilStart()', () => { describe('calculateTimeUntilStart()', () => {
test('ontime', () => { describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = { const test = {
timeStart: 100,
dayOffset: 0, dayOffset: 0,
delay: 0, delay: 0,
currentDay: 0, currentDay: 0,
totalGap: 0, clock: 100,
clock: 90, actualStart: 100,
plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
}; };
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10); // the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10); expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
}); });
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with to little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
//TODO: more indepth testing,
// including day offset handling
// and more?
}); });
@@ -0,0 +1,8 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+3 -1
View File
@@ -48,10 +48,12 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
+30 -12
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
}, },
): number { ): number {
const { offset, clock, currentDay } = useTimeUntilData(); const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset }); return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
} }
/** /**
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
clock: number; clock: number;
offset: number; offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
}, },
): number { ): number {
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data; const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one //How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay; const relativeDayOffset = dayOffset - currentDay;
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
//The normalised start time of this event relative to the currently running event //The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs; const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
const offsetTimestart = normalisedTimeStart - offset; let relativeStartOffset = 0;
const offsetTimeUntil = offsetTimestart - clock;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) { if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset //if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil; return offsetTimeUntil;
} }
const scheduledTimeUntil = normalisedTimeStart - clock; const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
const isAheadOfSchedule = offset >= 0;
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time // if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil; return scheduledTimeUntil;
} }
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
&.end { &.end {
justify-content: flex-end; justify-content: flex-end;
} }
&.apart {
justify-content: space-between;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children }: { children: ReactNode }) { export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return <div className={style.fieldError}>{children}</div>; return <div className={cx([style.fieldError, className])}>{children}</div>;
} }
export function Divider() { export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = { type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end'; align?: 'start' | 'end' | 'apart';
className?: string; className?: string;
}; };
@@ -0,0 +1,4 @@
.wrapper {
max-height: 500px;
overflow-y: auto;
}
@@ -0,0 +1,72 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism-tomorrow.min.css';
import style from './StyleEditor.module.scss';
interface CodeEditorProps {
language: string;
initialValue: string;
isDirty: boolean;
setIsDirty: (value: boolean) => void;
}
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
const { language, initialValue, isDirty, setIsDirty } = props;
const [code, setCode] = useState(initialValue);
const highlight = (code: string) => {
const grammar = Prism.languages[language];
return grammar ? Prism.highlight(code, grammar, language) : code;
};
const handleChange = (newCode: string) => {
setCode(newCode);
};
useImperativeHandle(cssRef, () => {
return {
getCss: () => code,
};
});
// add contents to editor on mount and any change in initialValue
useEffect(() => {
setCode(initialValue);
}, [initialValue]);
// handle dirty state on change
useEffect(() => {
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
setIsDirty(true);
}
if (initialValue.trim() === code.trim() && isDirty) {
setIsDirty(false);
}
}, [initialValue, code, isDirty, setIsDirty]);
return (
<div className={style.wrapper}>
<Editor
value={code}
padding={15}
onValueChange={handleChange}
highlight={highlight}
style={{
fontFamily: 'monospace',
fontSize: 12,
minHeight: 500,
background: '#2d2d2d', // Background of tomorrow theme
}}
/>
</div>
);
});
CodeEditor.displayName = 'StyleEditor';
export default memo(CodeEditor);
@@ -0,0 +1,14 @@
.editorActions {
width: 100%;
}
.right {
align-self: end;
text-align: right
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -0,0 +1,129 @@
import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss';
const CodeEditor = lazy(() => import('./StyleEditor'));
interface CodeEditorModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
setResetLoading(false);
}
};
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
} catch (_error) {
/** no error handling for now */
} finally {
setSaveLoading(false);
}
};
const clear = () => setCSS('');
useEffect(() => {
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
const css = await getCSSContents();
setCSS(css);
} catch (_error) {
setError('Failed to load CSS from server');
/** no error handling for now */
}
}
}
fetchServerCSS();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
<ModalOverlay />
<ModalContent maxWidth='max(800px, 40vw)'>
<ModalHeader>Edit CSS override</ModalHeader>
<ModalCloseButton />
<ModalBody>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</ModalBody>
<ModalFooter className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ontime-ghosted' onClick={clear}>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button
variant='ontime-filled'
onClick={handleSave}
isDisabled={saveLoading || resetLoading || !isDirty}
isLoading={saveLoading}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react'; import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings(); const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo(); const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const { const {
control, control,
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title='Override CSS styles'
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} /> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)} )}
/> />
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
width='fit-content'
>
Edit CSS override
</Button>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
@@ -0,0 +1,5 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
@@ -25,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -32,6 +33,12 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -77,6 +84,10 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -231,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -57,3 +57,18 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') { if (importFlow === 'excel') {
try { try {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onChange',
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => { const handleExport = async (values: NamedImportMap) => {
setLoading('export'); setLoading('export');
const importMap = convertToImportMap(values); const importMap = convertToImportMap(values);
@@ -1,5 +1,7 @@
import { ImportCustom, ImportMap } from 'ontime-utils'; import { ImportCustom, ImportMap } from 'ontime-utils';
import { baseURI } from '../../../../../externals';
export type NamedImportMap = typeof namedImportMap; export type NamedImportMap = typeof namedImportMap;
// Record of label and import name // Record of label and import name
@@ -64,11 +66,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
} }
export function persistImportMap(options: NamedImportMap) { export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options)); localStorage.setItem(`${baseURI}ontime-import-options`, JSON.stringify(options));
} }
function getPersistImportMap(): unknown { function getPersistImportMap(): unknown {
const options = localStorage.getItem('ontime-import-options'); const options = localStorage.getItem(`${baseURI}ontime-import-options`);
if (!options) { if (!options) {
throw new Error('no import options found'); throw new Error('no import options found');
} }
@@ -1,9 +1,7 @@
import { useEffect, useRef } from 'react'; import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss'; import style from './InputRow.module.scss';
@@ -12,50 +10,53 @@ interface InputRowProps {
placeholder: string; placeholder: string;
text: string; text: string;
visible: boolean; visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
} }
export default function InputRow(props: InputRowProps) { export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props; const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
// sync cursor position with text // sync cursor position with text
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current && inputRef.current !== document.activeElement) {
inputRef.current.selectionStart = cursorPositionRef.current; inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current; inputRef.current.selectionEnd = cursorPositionRef.current;
} }
}, [text]); }, [text]);
// synchronise external text
useEffect(() => {
if (inputRef.current !== document.activeElement) {
setValue(text);
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0; cursorPositionRef.current = event.target.selectionStart ?? 0;
setValue(event.target.value);
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
return ( return (
<div className={style.inputRow}> <div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label> <label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</label>
<div className={style.inputItems}> <div className={style.inputItems}>
<Input <Input
id={label}
ref={inputRef} ref={inputRef}
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
value={text} value={value}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
/> />
<TooltipActionBtn {children}
clickHandler={actionHandler}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,8 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import TimerControlsPreview from './TimerViewControl';
@@ -23,8 +27,17 @@ function TimerMessageInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)} changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!visible)} >
/> <TooltipActionBtn
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -46,7 +59,16 @@ function ExternalInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)} changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal} >
/> <TooltipActionBtn
clickHandler={toggleExternal}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle external message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -6,6 +6,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
import { baseURI } from '../../../../externals';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton'; import TapButton from '../tap-button/TapButton';
@@ -17,7 +18,7 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) { export default function AddTime(props: AddTimeProps) {
const { playback } = props; const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes const [time, setTime] = useLocalStorage({ key: `${baseURI}add-time`, defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => { const handleTimeChange = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTime = parseUserTime(value);
@@ -45,6 +45,10 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,14 +164,14 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview(); const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead; const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return ( return (
<> <>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} /> <TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} /> <TimeColumn label='Time now' value={formatedTime(clock)} />
</> </>
); );
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
muted?: boolean; muted?: boolean;
daySpan?: number; daySpan?: number;
className?: string; className?: string;
testId?: string;
} }
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) { export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
return ( return (
<div className={style.column}> <div className={style.column}>
<span className={style.label}>{label}</span> <span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span> <span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
{value}
</span>
</div> </div>
); );
} }
@@ -1,17 +1,15 @@
import { CSSProperties, useCallback } from 'react'; import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventEditorImage from './composite/EventEditorImage'; import EventCustom from './composite/EventEditorCustom';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea'; import EventEditorTriggers from './composite/EventEditorTriggers';
import EventTextInput from './composite/EventTextInput';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>} {isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
{Object.keys(customFields).map((fieldKey) => { </div>
const key = `${event.id}-${fieldKey}`; <div className={style.column}>
const fieldName = `custom-${fieldKey}`; <Editor.Title>
const initialValue = event.custom[fieldKey] ?? ''; Automations
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); {isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
const labelText = customFields[fieldKey].label; </Editor.Title>
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,68 @@
import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorCustomProps {
fields: CustomFields;
event: OntimeEvent;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
export default function EventEditorCustom(props: EventEditorCustomProps) {
const { fields: customFields, handleSubmit, event } = props;
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</Fragment>
);
}
@@ -0,0 +1,30 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
align-items: center;
}
.trigger {
padding: 0.25rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
&:nth-child(even) {
background-color: $white-1;
}
& > span {
width: fit-content;
}
}
.errorLabel {
color: $red-500;
}
.success {
color: $green-500;
}
@@ -0,0 +1,173 @@
import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Tag from '../../../../common/components/tag/Tag';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps {
eventId: string;
triggers?: Trigger[];
}
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
const { triggers, eventId } = props;
const showTriggers = triggers !== undefined && triggers.length > 0;
return (
<>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
<EventTriggerForm triggers={triggers} eventId={eventId} />
</>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
}
function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings();
const { updateEvent } = useEventAction();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEvent({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automationSettings.automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
return (
<div className={style.triggerForm}>
<Select
size='sm'
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
defaultValue={TimerLifeCycle.onStart}
>
<option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => (
<option key={cycle} value={cycle}>
{cycle}
</option>
))}
</Select>
<Select
size='sm'
variant='ontime'
value={automationId}
defaultValue='«invalid»'
onChange={(e) => setAutomationId(e.target.value)}
>
<option disabled value='«invalid»'>
Automation
</option>
{Object.values(automationSettings.automations).map(({ id, title }) => (
<option key={id} value={id}>
{title}
</option>
))}
</Select>
<Button
variant='ontime-subtle'
size='sm'
isDisabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
</Button>
{validationError !== undefined ? (
<Tooltip label={validationError} shouldWrapChildren>
<IoAlertCircle className={style.errorLabel} />
</Tooltip>
) : (
<IoCheckmarkCircle className={style.success} />
)}
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
}
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props;
const { updateEvent } = useEventAction();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEvent({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEvent],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(id)}
/>
</div>
);
})}
</Fragment>
))}
</div>
);
}
@@ -0,0 +1,10 @@
import { TimerLifeCycle } from 'ontime-types';
export const eventTriggerOptions: TimerLifeCycle[] = [
TimerLifeCycle.onLoad,
TimerLifeCycle.onStart,
TimerLifeCycle.onPause,
TimerLifeCycle.onFinish,
TimerLifeCycle.onWarning,
TimerLifeCycle.onDanger,
];
@@ -1,5 +1,7 @@
import { Button, ButtonGroup } from '@chakra-ui/react'; import { Button, ButtonGroup } from '@chakra-ui/react';
import { OffsetMode } from 'ontime-types';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -12,6 +14,8 @@ export default function RundownHeader() {
const setRunMode = () => setAppMode(AppMode.Run); const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit); const setEditMode = () => setAppMode(AppMode.Edit);
const { offsetMode } = useOffsetMode();
return ( return (
<div className={style.header}> <div className={style.header}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
@@ -22,6 +26,22 @@ export default function RundownHeader() {
Edit Edit
</Button> </Button>
</ButtonGroup> </ButtonGroup>
<ButtonGroup isAttached>
<Button
size='sm'
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Absolute)}
>
Absolute
</Button>
<Button
size='sm'
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Relative)}
>
Relative
</Button>
</ButtonGroup>
<RundownMenu /> <RundownMenu />
</div> </div>
); );
@@ -9,57 +9,9 @@
top: 75vh; top: 75vh;
line-height: normal; line-height: normal;
animation-fill-mode: both;
animation-timing-function: ease-out;
&--pre {
animation-name: in;
animation-play-state: paused;
.data-top {
animation-play-state: paused;
animation-name: top-in;
}
.data-lower {
animation-play-state: paused;
animation-name: bottom-in;
}
}
&--in {
animation-name: in;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-in;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-in;
}
}
&--out {
animation-name: out;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-out;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-out;
}
}
.line { .line {
width: 100%; width: 100%;
height: var(--bottomThird-line-height-override, 0.5vh); height: var(--lowerThird-line-height-override, 0.5vh);
} }
.clip { .clip {
@@ -70,10 +22,10 @@
.data-top { .data-top {
padding: 0 3vw; padding: 0 3vw;
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif; font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
text-align: var(--lowerThird-text-align-override, left); text-align: var(--lowerThird-text-align-override, right);
white-space: nowrap; white-space: nowrap;
@include ellipsis-text(); @include ellipsis-text();
&::after { &::after {
content: '\200b'; content: '\200b';
} }
@@ -85,72 +37,24 @@
} }
.data-bottom { .data-bottom {
font-weight: var(--lowerThird-bottom-font-weight-override, normal); font-weight: var(--lowerThird-bottom-font-weight-override, 540);
font-style: var(--lowerThird-bottom-font-style-override, normal); font-style: var(--lowerThird-bottom-font-style-override, normal);
} }
&--in {
transition-timing-function: cubic-bezier(0.25, 0.5, 0.5, 1);
}
&--out {
transition-timing-function: cubic-bezier(0.5, 0, 0.75, 0.5);
transform: translateX(-100%);
opacity: 0;
.data-top {
transform: translateY(100%);
}
.data-bottom {
transform: translateY(-100%);
}
}
} }
} }
@keyframes in {
0% {
transform: translateX(-100%);
opacity: 0%;
}
50%,
100% {
transform: translateX(0%);
opacity: 100%;
}
}
@keyframes out {
0%,
30% {
transform: translateX(0%);
opacity: 100%;
}
100% {
transform: translateX(-100%);
opacity: 0%;
}
}
@keyframes top-in {
0%,
50% {
transform: translateY(100%);
}
100% {
transform: translateY(0%);
}
}
@keyframes top-out {
0% {
transform: translateY(0%);
}
50%,
100% {
transform: translateY(100%);
}
}
@keyframes bottom-in {
0%,
50% {
transform: translateY(-100%);
}
100% {
transform: translateY(0%);
}
}
@keyframes bottom-out {
0% {
transform: translateY(0%);
}
50%,
100% {
transform: translateY(-100%);
}
}
@@ -1,204 +1,123 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types'; import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions } from './lowerThird.options'; import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options';
import './LowerThird.scss'; import './LowerThird.scss';
type LowerOptions = {
width: number;
topSrc: string;
bottomSrc: string;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: string;
bottomSize: string;
transition: number;
delay: number;
key: string;
lineColour: string;
lineHeight: string;
};
interface LowerProps { interface LowerProps {
customFields: CustomFields; customFields: CustomFields;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
viewSettings: ViewSettings; viewSettings: ViewSettings;
time: ViewExtendedTimer;
} }
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000',
bottomColour: '000000',
topBg: 'FFF0',
bottomBg: 'FFF0',
topSize: '65px',
bottomSize: '40px',
transition: 3,
delay: 3,
key: 'FFF0',
lineColour: 'FF0000',
lineHeight: '0.4em',
};
export default function LowerThird(props: LowerProps) { export default function LowerThird(props: LowerProps) {
const { customFields, eventNow, viewSettings } = props; const { customFields, eventNow, viewSettings, time } = props;
const [searchParams] = useSearchParams();
const previousId = useRef<string>(); const previousId = useRef<string>();
const animationTimeout = useRef<NodeJS.Timeout>(); const animationTimeout = useRef<NodeJS.Timeout>();
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre'); const [playState, setPlayState] = useState<boolean>(false);
const [textValue, setTextValue] = useState<{ top: string; bottom: string }>({ top: '', bottom: '' });
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const options = useLowerOptions();
const { playback } = time;
useWindowTitle('Lower Third'); useWindowTitle('Lower Third');
const options = useMemo(() => {
const newOptions = { ...defaultOptions };
const width = searchParams.get('width');
if (width !== null) {
newOptions.width = Number(width);
}
const topSrc = searchParams.get('top-src');
if (topSrc) {
newOptions.topSrc = topSrc;
}
const bottomSrc = searchParams.get('bottom-src');
if (bottomSrc) {
newOptions.bottomSrc = bottomSrc;
}
const topColour = searchParams.get('top-colour');
if (topColour !== null) {
newOptions.topColour = topColour;
}
const bottomColour = searchParams.get('bottom-colour');
if (bottomColour !== null) {
newOptions.bottomColour = bottomColour;
}
const topBg = searchParams.get('top-bg');
if (topBg !== null) {
newOptions.topBg = topBg;
}
const bottomBg = searchParams.get('bottom-bg');
if (bottomBg !== null) {
newOptions.bottomBg = bottomBg;
}
const topSize = searchParams.get('top-size');
if (topSize !== null) {
newOptions.topSize = topSize;
}
const bottomSize = searchParams.get('bottom-size');
if (bottomSize && bottomSize != newOptions.bottomSize) {
newOptions.bottomSize = bottomSize;
}
const transition = searchParams.get('transition');
if (transition !== null) {
newOptions.transition = Number(transition);
}
const delay = searchParams.get('delay');
if (delay !== null) {
newOptions.delay = Number(delay);
}
const key = searchParams.get('key');
if (key !== null) {
newOptions.key = key;
}
const lineColour = searchParams.get('line-colour');
if (lineColour !== null) {
newOptions.lineColour = lineColour;
}
return newOptions;
}, [searchParams]);
// on unmount, cancel any ongoing animations // on unmount, cancel any ongoing animations
useEffect(() => { useEffect(() => {
return () => { // if hold is negative then force animate in
// eslint-disable-next-line react-hooks/exhaustive-deps if (options.hold < 0) {
clearTimeout(animationTimeout.current); clearTimeout(animationTimeout.current);
}; setTextValue({
}, []); top: getPropertyValue(eventNow, options.topSrc) ?? '',
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
});
setPlayState(true);
return;
}
}, [eventNow, options.bottomSrc, options.hold, options.topSrc]);
const animateIn = useCallback(() => {
// if hold skip
if (options.hold < 0) return;
//clear any pending timeouts
clearTimeout(animationTimeout.current);
// set the values
setTextValue({
top: getPropertyValue(eventNow, options.topSrc) ?? '',
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
});
// start animation
setPlayState(true);
// reschedule out animation, should animate out after the in animation time + hold time
setTimeout(() => setPlayState(false), (options.hold + options.transitionIn) * MILLIS_PER_SECOND);
}, [eventNow, options.bottomSrc, options.hold, options.topSrc, options.transitionIn]);
const animateOut = useCallback(() => {
if (options.hold < 0) return; // if hold is negative then we never animate out
//clear any pending timeouts
clearTimeout(animationTimeout.current);
// start animation
setPlayState(false);
}, [options.hold]);
// check if playback has changed and schedule animations
useEffect(() => {
if (isPlaybackActive(playback)) {
animateIn();
} else {
animateOut();
}
}, [animateIn, animateOut, playback]);
// check if data has changed and schedule animations // check if data has changed and schedule animations
useEffect(() => { useEffect(() => {
const hasChanged = eventNow?.id !== previousId.current; const hasChanged = eventNow?.id !== previousId.current;
if (!hasChanged) { if (hasChanged) {
return; previousId.current = eventNow?.id;
if (eventNow?.id) animateIn();
} }
}, [animateIn, eventNow?.id]);
previousId.current = eventNow?.id; const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
const animateOutInMs = options.delay * 1000 + options.transition * 1000; const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`;
const reschedule = (newState: 'pre' | 'in' | 'out') => { const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
clearTimeout(animationTimeout.current); const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s';
animationTimeout.current = setTimeout(() => setPlayState(newState), animateOutInMs);
};
if (eventNow?.id == null) {
setPlayState('out');
reschedule('pre');
return;
}
if (eventNow.id && !previousId.current) {
setPlayState('in');
reschedule('out');
return;
}
if (playState === 'in') {
// event has changed, we just reschedule the timeout
reschedule('out');
return;
}
setPlayState('in');
reschedule('out');
}, [eventNow?.id, options.delay, options.transition, playState, previousId]);
const topText = getPropertyValue(eventNow, options.topSrc) ?? '';
const bottomText = getPropertyValue(eventNow, options.bottomSrc) ?? '';
const transition = `${options.transition}s`;
return ( return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}> <div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} /> <ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div <div
className={`container container--${playState}`} className={`container ${playState ? 'container--in' : 'container--out'}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }} style={{
minWidth: `${options.width}vw`,
transitionDuration: boxDuration,
transitionDelay: boxDelay,
}}
> >
<div className='clip'> <div className='clip'>
<div <div
className='data-top' className='data-top'
style={{ style={{
animationDuration: transition, transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.topColour}`, color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`, backgroundColor: `#${options.topBg}`,
fontSize: options.topSize, fontSize: `${options.topSize}em`,
}} }}
> >
{topText} {textValue.top}
</div> </div>
</div> </div>
<div <div
@@ -211,13 +130,14 @@ export default function LowerThird(props: LowerProps) {
<div <div
className='data-bottom' className='data-bottom'
style={{ style={{
animationDuration: transition, transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.bottomColour}`, color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`, backgroundColor: `#${options.bottomBg}`,
fontSize: options.bottomSize, fontSize: `${options.bottomSize}em`,
}} }}
> >
{bottomText} {textValue.bottom}
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants'; import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types'; import { ViewOption } from '../../../common/components/view-params-editor/types';
import safeParseNumber from '../../../common/utils/safeParseNumber';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => { export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, { const topSourceOptions = makeOptionsFromCustomFields(customFields, {
@@ -44,18 +47,32 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
collapsible: true, collapsible: true,
options: [ options: [
{ {
id: 'transition', id: 'transition-in',
title: 'Transition', title: 'Transition In',
description: 'Transition in time in seconds (default 3)', description: 'Transition in time (default 3 seconds)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'transition-out',
title: 'Transition Out',
description: 'Transition out time (default 3 seconds)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'hold',
title: 'Hold',
description: 'Time on screen before transition out. Set to -1 to stop transition (default 3 seconds) ',
type: 'number', type: 'number',
placeholder: '3 (default)', placeholder: '3 (default)',
}, },
{ {
id: 'delay', id: 'delay',
title: 'Delay', title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)', description: 'Delay between trigger and transition in (default 0 seconds)',
type: 'number', type: 'number',
placeholder: '3 (default)', placeholder: '0 (default)',
}, },
], ],
}, },
@@ -69,14 +86,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
title: 'Top Text Size', title: 'Top Text Size',
description: 'Font size of the top text', description: 'Font size of the top text',
type: 'string', type: 'string',
placeholder: '65px', placeholder: '5em',
}, },
{ {
id: 'bottom-size', id: 'bottom-size',
title: 'Bottom Text Size', title: 'Bottom Text Size',
description: 'Font size of the bottom text', description: 'Font size of the bottom text',
type: 'string', type: 'string',
placeholder: '64px', placeholder: '4em',
}, },
{ {
id: 'width', id: 'width',
@@ -132,3 +149,73 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
}, },
]; ];
}; };
type LowerOptions = {
width: number;
topSrc: string;
bottomSrc: string;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: number;
bottomSize: number;
transitionIn: number;
transitionOut: number;
hold: number;
delay: number;
key: string;
lineColour: string;
};
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000',
bottomColour: '000000',
topBg: 'FFF0',
bottomBg: 'FFF0',
topSize: 5,
bottomSize: 4,
transitionIn: 3,
transitionOut: 3,
hold: 3,
delay: 0,
key: 'FFF0',
lineColour: 'FF0000',
};
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with defaultOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): LowerOptions {
// we manually make an object that matches the key above
return {
width: safeParseNumber(searchParams.get('width'), defaultOptions.width),
topSrc: searchParams.get('top-src') ?? defaultOptions.topSrc,
bottomSrc: searchParams.get('bottom-src') ?? defaultOptions.bottomSrc,
topColour: searchParams.get('top-colour') ?? defaultOptions.topColour,
bottomColour: searchParams.get('bottom-colour') ?? defaultOptions.bottomColour,
topBg: searchParams.get('top-bg') ?? defaultOptions.topBg,
bottomBg: searchParams.get('bottom-bg') ?? defaultOptions.bottomBg,
topSize: safeParseNumber(searchParams.get('top-size'), defaultOptions.topSize),
bottomSize: safeParseNumber(searchParams.get('bottom-size'), defaultOptions.bottomSize),
transitionIn: safeParseNumber(searchParams.get('transition-in'), defaultOptions.transitionIn),
transitionOut: safeParseNumber(searchParams.get('transition-out'), defaultOptions.transitionOut),
hold: safeParseNumber(searchParams.get('hold'), defaultOptions.hold),
delay: safeParseNumber(searchParams.get('hold'), defaultOptions.delay),
key: searchParams.get('key') ?? defaultOptions.key,
lineColour: searchParams.get('line-colour') ?? defaultOptions.lineColour,
};
}
/**
* Hook exposes the timer view options
*/
export function useLowerOptions(): LowerOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -93,7 +93,7 @@ $orange-active: #f60;
} }
.studio-timer { .studio-timer {
font-size: calc(var(--clock-size) / 5); font-size: calc(var(--clock-size) / 6);
line-height: 1em; line-height: 1em;
color: var(--studio-active, $red-active); color: var(--studio-active, $red-active);
@@ -140,7 +140,7 @@ export const ScheduleProvider = ({
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId); let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current // we want to show the event after the current
const viewEvents = events.toSpliced(0, selectedEventIndex + 1); const viewEvents = events.slice(selectedEventIndex + 1);
selectedEventIndex = 0; selectedEventIndex = 0;
return ( return (
@@ -96,11 +96,12 @@ describe('makeTable()', () => {
"Is Public? (x)", "Is Public? (x)",
"Skip?", "Skip?",
"lighting", "lighting",
"Type",
], ],
[ [
"00:00:00", "00:00:00",
"00:00:00", "00:00:00",
"...", "",
"", "",
"", "",
"", "",
@@ -109,6 +110,7 @@ describe('makeTable()', () => {
"x", "x",
"", "",
"", "",
"",
], ],
] ]
`); `);
@@ -104,7 +104,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
<div ref={tableContainerRef} className={style.cuesheetContainer}> <div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet} id='cuesheet' {...listeners}> <table className={style.cuesheet} id='cuesheet' {...listeners}>
<CuesheetHeader headerGroups={headerGroups} /> <CuesheetHeader headerGroups={headerGroups} />
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} /> <CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
</table> </table>
</div> </div>
<CuesheetTableMenu showModal={showModal} /> <CuesheetTableMenu showModal={showModal} />
@@ -19,7 +19,10 @@ function BlockRow(props: BlockRowProps) {
return null; return null;
} }
const paddingRows = new Array(columnCount - 1).fill(null); // guard the use case where user has hidden all columns
const fillColumns = Math.min(columnCount, 1);
const paddingRows = new Array(fillColumns).fill(null);
return ( return (
<tr className={style.blockRow}> <tr className={style.blockRow}>
@@ -16,16 +16,24 @@ interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>; rowModel: RowModel<OntimeRundownEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>; selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>; table: Table<OntimeRundownEntry>;
columnSizing: Record<string, number>;
} }
export default function CuesheetBody(props: CuesheetBodyProps) { export default function CuesheetBody(props: CuesheetBodyProps) {
const { rowModel, selectedRef, table, columnSizing } = props; const { rowModel, selectedRef, table } = props;
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const { hideDelays, hidePast } = useCuesheetOptions(); const { hideDelays, hidePast } = useCuesheetOptions();
const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length); const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
const getColumnHash = lazyEvaluate(() => {
let columnHash = '';
const columns = getVisibleColumns();
for (let i = 0; i < columns.length; i++) {
columnHash += `${columns[i].getIndex()}-${columns[i].getSize()} `;
}
return columnHash;
});
let eventIndex = 0; let eventIndex = 0;
// for the first event, it will be past if there is something selected // for the first event, it will be past if there is something selected
@@ -41,7 +49,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
} }
if (isOntimeBlock(entry)) { if (isOntimeBlock(entry)) {
const columnCount = getColumnCount(); const columnCount = getVisibleColumns().length;
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />; return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
} }
if (isOntimeDelay(entry)) { if (isOntimeDelay(entry)) {
@@ -58,6 +66,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedEventId; const isSelected = key === selectedEventId;
const columnHash = getColumnHash();
if (isPast && hidePast) { if (isPast && hidePast) {
return null; return null;
@@ -87,7 +96,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour} rowBgColour={rowBgColour}
table={table} table={table}
columnSizing={columnSizing} columnHash={columnHash}
/> />
); );
} }
@@ -23,20 +23,20 @@ interface EventRowProps {
rowBgColour?: string; rowBgColour?: string;
table: Table<OntimeRundownEntry>; table: Table<OntimeRundownEntry>;
/** hack to force re-rendering of the row when the column sizes change */ /** hack to force re-rendering of the row when the column sizes change */
columnSizing: Record<string, number>; columnHash: string;
} }
export default memo(EventRow, (prevProps, nextProps) => { export default memo(EventRow, (prevProps, nextProps) => {
return ( return (
prevProps.rowId === nextProps.rowId && prevProps.rowId === nextProps.rowId &&
prevProps.event.id === nextProps.event.id &&
prevProps.event.revision === nextProps.event.revision && prevProps.event.revision === nextProps.event.revision &&
prevProps.eventIndex === nextProps.eventIndex && prevProps.eventIndex === nextProps.eventIndex &&
prevProps.rowIndex === nextProps.rowIndex && prevProps.rowIndex === nextProps.rowIndex &&
prevProps.isPast === nextProps.isPast && prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef && prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour && prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.table === nextProps.table && prevProps.columnHash === nextProps.columnHash
prevProps.columnSizing === nextProps.columnSizing
); );
}); });
@@ -3,13 +3,18 @@ import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { baseURI } from '../../../externals';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) { export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: `${baseURI}table-hidden`, defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order', key: `${baseURI}table-order`,
defaultValue: columns.map((col) => col.id as string), defaultValue: columns.map((col) => col.id as string),
}); });
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); const [columnSizing, setColumnSizing] = useLocalStorage({
key: `${baseURI}table-sizes`,
defaultValue: {},
});
// if the columns change, we update the dataset // if the columns change, we update the dataset
useEffect(() => { useEffect(() => {
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => { export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') { if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber); return millisToString(data as MaybeNumber, { fallback: '' });
} }
if (field === 'isPublic' || field === 'skip') { if (field === 'isPublic' || field === 'skip') {
@@ -59,6 +59,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
'isPublic', 'isPublic',
'skip', 'skip',
...customFieldKeys, ...customFieldKeys,
'type',
]; ];
const fieldTitles = [ const fieldTitles = [
@@ -73,6 +74,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
'Is Public? (x)', 'Is Public? (x)',
'Skip?', 'Skip?',
...customFieldLabels, ...customFieldLabels,
'Type',
]; ];
// add header row to data // add header row to data
@@ -25,7 +25,9 @@
flex: 1; flex: 1;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 800px); width: min(calc(100vw - 4rem), 960px);
padding-bottom: 10vh;
} }
.info__label { .info__label {
@@ -33,11 +35,15 @@
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
margin-top: $view-element-gap; margin-top: $view-element-gap;
white-space: pre; }
.info__value {
white-space: break-spaces;
} }
a.info__value { a.info__value {
color: $action-text-color; color: $action-text-color;
&:hover { &:hover {
color: $ontime-color; color: $ontime-color;
} }
@@ -8,6 +8,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo'; import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo'; import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options'; import { projectInfoOptions } from './projectInfo.options';
@@ -66,6 +67,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)} )}
<BackstageInfo general={general} /> <BackstageInfo general={general} />
<PublicInfo general={general} /> <PublicInfo general={general} />
<CustomInfo general={general} />
</div> </div>
</div> </div>
); );
@@ -0,0 +1,36 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
{ {
id: 'showBackstage', id: 'showBackstage',
title: 'Show backstage Data', title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views', description: 'Whether to show fields related to the backstage views',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{ {
id: 'showPublic', id: 'showPublic',
title: 'Show Public Data', title: 'Show Public Data',
description: 'Weather to show fields related to the public views', description: 'Whether to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
+3 -3
View File
@@ -70,9 +70,9 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: ` additionalData: `
@use './src/theme/ontimeColours' as *; @use '@/theme/ontimeColours' as *;
@use './src/theme/ontimeStyles' as *; @use '@/theme/ontimeStyles' as *;
@use './src/theme/mixins' as *; @use '@/theme/mixins' as *;
`, `,
}, },
}, },
+4 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "3.14.4", "version": "3.16.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -12,8 +12,8 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "^31.2.0", "electron": "36.3.1",
"electron-builder": "^24.13.3", "electron-builder": "26.0.12",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
@@ -38,9 +38,7 @@
"icon": "icon.icns" "icon": "icon.icns"
}, },
"mac": { "mac": {
"notarize": { "notarize": true,
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "./entitlements.plist", "entitlements": "./entitlements.plist",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.14.4", "version": "3.16.1",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -52,6 +52,7 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "tsx ./scripts/bundleCss.ts",
"build": "node esbuild.electron.js", "build": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js", "build:local": "node esbuild.dev.js",
+24
View File
@@ -0,0 +1,24 @@
import { existsSync } from 'fs';
import { writeFile } from 'node:fs/promises';
import { defaultCss } from '../src/user/styles/bundledCss';
import path from 'path';
/**
* Script to write contents of bundledCss to override.css
*/
async function bundleCss() {
try {
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
const cssFile = path.resolve(stylesDir, 'override.css');
if (!existsSync(cssFile)) {
throw new Error('File does not exist');
}
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
} catch (error) {
console.error('Failed writing to CSS file: ', error);
}
}
bundleCss();
+1 -1
View File
@@ -5,7 +5,7 @@ import * as dgram from 'node:dgram';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { isOntimeCloud } from '../externals.js'; import { isOntimeCloud } from '../setup/environment.js';
import { integrationPayloadFromPath } from './utils/parse.js'; import { integrationPayloadFromPath } from './utils/parse.js';
import type { IAdapter } from './IAdapter.js'; import type { IAdapter } from './IAdapter.js';
@@ -0,0 +1,41 @@
import { defaultCss } from '../../user/styles/bundledCss.js';
import type { Request, Response } from 'express';
import { readCssFile, writeCssFile } from './assets.service.js';
/**
* Exposes the contents of the cssOverride.css file
*/
export async function getCssOverride(_req: Request, res: Response) {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Allows modifying the cssOverride.css file
*/
export async function postCssOverride(req: Request, res: Response) {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Restores the default cssOverride.css file
*/
export async function restoreCss(_req: Request, res: Response) {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
res.status(500).send({ message: error });
}
}
@@ -0,0 +1,10 @@
import express from 'express';
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
import { validatePostCss } from './assets.validation.js';
export const router = express.Router();
router.get('/css', getCssOverride);
router.post('/css', validatePostCss, postCssOverride);
router.post('/css/restore', restoreCss);
@@ -0,0 +1,33 @@
import { publicFiles } from '../../setup/index.js';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { defaultCss } from '../../user/styles/bundledCss.js';
/**
* Reads the user's css file
* @returns css contents in the file
*/
export async function readCssFile(): Promise<string> {
const path = publicFiles.cssOverride;
if (!existsSync(path)) {
await writeFile(path, defaultCss, { encoding: 'utf8' });
}
const css = await readFile(path, { encoding: 'utf8' });
return css;
}
/**
* Writes the user's css file
* @param css the updated css to write to file
*/
export async function writeCssFile(css: string) {
const path = publicFiles.cssOverride;
if (!existsSync(path)) {
await writeFile(path, css, { encoding: 'utf8' });
return;
}
await writeFile(path, css, { encoding: 'utf8' });
}
@@ -0,0 +1,12 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const validatePostCss = [
body('css').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -9,12 +9,13 @@ import type {
import { deleteAtIndex, generateId } from 'ontime-utils'; import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
/** /**
* Gets a copy of the stored automation settings * Gets a copy of the stored automation settings
*/ */
export function getAutomationSettings(): AutomationSettings { export function getAutomationSettings(): AutomationSettings {
return structuredClone(getDataProvider().getAutomation()); return getDataProvider().getAutomation();
} }
/** /**
@@ -138,14 +139,25 @@ export async function deleteAutomation(id: string): Promise<void> {
if (!Object.hasOwn(automations, id)) { if (!Object.hasOwn(automations, id)) {
return; return;
} }
// prevent deleting a automation that is in use
const triggers = getAutomationTriggers(); // prevent deleting a automation that is in use in triggers
for (let i = 0; i < triggers.length; i++) { const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id);
const trigger = triggers[i]; if (triggers.length) {
if (trigger.automationId === id) { throw new Error(
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`); `Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
} );
} }
// prevent deleting a automation that is in use in events
const events = getTimedEvents().filter(
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
);
if (events.length) {
throw new Error(
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
);
}
delete automations[id]; delete automations[id];
await saveChanges({ automations }); await saveChanges({ automations });
} }
@@ -3,16 +3,16 @@ import {
isOntimeAction, isOntimeAction,
isOSCOutput, isOSCOutput,
LogOrigin, LogOrigin,
TimerLifeCycle,
type AutomationFilter, type AutomationFilter,
type AutomationOutput, type AutomationOutput,
type FilterRule, type FilterRule,
type TimerLifeCycle,
} from 'ontime-types'; } from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils'; import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js'; import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../externals.js'; import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js'; import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js'; import { emitHTTP } from './clients/http.client.js';
@@ -23,14 +23,21 @@ import { toOntimeAction } from './clients/ontime.client.js';
/** /**
* Exposes a method for triggering actions based on a TimerLifeCycle event * Exposes a method for triggering actions based on a TimerLifeCycle event
*/ */
export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) { export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
if (!getAutomationsEnabled()) { if (!getAutomationsEnabled()) {
return; return;
} }
const triggers = getAutomationTriggers(); let triggers = getAutomationTriggers();
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
if (triggerAutomations.length === 0) { // get triggers from event
if (state.eventNow?.triggers) {
triggers = triggers.concat(state.eventNow.triggers);
}
// note: there are no onStop triggers in event
const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
if (filteredTrigger.length === 0) {
return; return;
} }
@@ -39,7 +46,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
return; return;
} }
triggerAutomations.forEach((trigger) => { filteredTrigger.forEach((trigger) => {
const automation = automations[trigger.automationId]; const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) { if (!automation || automation.outputs.length === 0) {
return; return;
+2 -1
View File
@@ -58,6 +58,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '', backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '', backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null, projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
}, },
}); });
@@ -203,7 +204,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/** /**
* Loads the demo project * Loads the demo project
*/ */
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const projectName = await projectService.loadDemoProject(); const projectName = await projectService.loadDemoProject();
@@ -16,6 +16,7 @@ export const validateNewProject = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(), body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
+2
View File
@@ -12,6 +12,7 @@ import { router as excelRouter } from './excel/excel.router.js';
import { router as sessionRouter } from './session/session.router.js'; import { router as sessionRouter } from './session/session.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js'; import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
import { router as reportRouter } from './report/report.router.js'; import { router as reportRouter } from './report/report.router.js';
import { router as assetsRouter } from './assets/assets.router.js';
export const appRouter = express.Router(); export const appRouter = express.Router();
@@ -27,6 +28,7 @@ appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/session', sessionRouter); appRouter.use('/session', sessionRouter);
appRouter.use('/view-settings', viewSettingsRouter); appRouter.use('/view-settings', viewSettingsRouter);
appRouter.use('/report', reportRouter); appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes //we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => { appRouter.all('/*', (_req, res) => {
@@ -5,11 +5,11 @@ import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js'; import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js'; import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
import * as projectDao from './project.dao.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) { export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(getDataProvider().getProjectData()); res.json(projectDao.getProjectData());
} }
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) { export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -27,6 +27,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo, backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage, endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo, projectLogo: req.body?.projectLogo,
custom: req.body?.custom,
}); });
const updatedData = await editCurrentProjectData(newData); const updatedData = await editCurrentProjectData(newData);
@@ -0,0 +1,9 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
}
@@ -10,6 +10,9 @@ export const projectSanitiser = [
body('backstageInfo').optional().isString().trim(), body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(), body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(), body('projectLogo').optional({ nullable: true }).isString().trim(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -10,6 +10,7 @@ import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js'; import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js'; import { password, routerPrefix } from '../../externals.js';
import { hashPassword } from '../../utils/hash.js'; import { hashPassword } from '../../utils/hash.js';
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
const startedAt = new Date(); const startedAt = new Date();
@@ -28,6 +29,7 @@ export async function getSessionStats(): Promise<SessionStats> {
projectName, projectName,
playback, playback,
timezone: getTimezoneLabel(startedAt), timezone: getTimezoneLabel(startedAt),
version: ONTIME_VERSION,
}; };
} }
@@ -3,7 +3,7 @@ import { getErrorMessage, obfuscate } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { isDocker } from '../../externals.js'; import { isDocker } from '../../setup/environment.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js'; import * as appState from '../../services/app-state-service/AppStateService.js';
@@ -1,4 +1,4 @@
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials'; import { DeepPartial } from 'ts-essentials';
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js'; import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js'; import { handleLegacyMessageConversion } from './integration.legacy.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20); const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null; let lastRequest: Date | null = null;
@@ -286,6 +287,11 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('No matching method provided'); throw new Error('No matching method provided');
}, },
offsetmode: (payload) => {
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
}; };
/** /**
+2 -1
View File
@@ -9,7 +9,8 @@ import cookieParser from 'cookie-parser';
// import utils // import utils
import { publicDir, srcDir } from './setup/index.js'; import { publicDir, srcDir } from './setup/index.js';
import { environment, isProduction, updateRouterPrefix } from './externals.js'; import { environment, isProduction } from './setup/environment.js';
import { updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
+1 -1
View File
@@ -4,7 +4,7 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js'; import { clock } from '../services/Clock.js';
import { socket } from '../adapters/WebsocketAdapter.js'; import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleError } from '../utils/console.js'; import { consoleSubdued, consoleError } from '../utils/console.js';
import { isProduction } from '../externals.js'; import { isProduction } from '../setup/environment.js';
class Logger { class Logger {
private queue: Log[]; private queue: Log[];
@@ -14,7 +14,7 @@ import { JSONFilePreset } from 'lowdb/node';
import { isPath } from '../../utils/fileManagement.js'; import { isPath } from '../../utils/fileManagement.js';
import { shouldCrashDev } from '../../utils/development.js'; import { shouldCrashDev } from '../../utils/development.js';
import { isTest } from '../../externals.js'; import { isTest } from '../../setup/environment.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
@@ -63,7 +63,7 @@ function getData(): Readonly<DatabaseModel> {
} }
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> { async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData }; db.data.project = { ...structuredClone(db.data.project), ...structuredClone(newData) }; // Performing deep copy as we're updating / merging data
await persist(); await persist();
return db.data.project; return db.data.project;
} }
@@ -4,24 +4,27 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
const { const {
rundown = existing.rundown, rundown = deepExisting.rundown,
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
urlPresets = existing.urlPresets, urlPresets = deepExisting.urlPresets,
customFields = existing.customFields, customFields = deepExisting.customFields,
automation = existing.automation, automation = deepExisting.automation,
} = newData; } = deepNewData;
return { return {
...existing, ...deepExisting,
rundown, rundown,
project: { ...existing.project, ...project }, project: { ...deepExisting.project, ...project },
settings: { ...existing.settings, ...settings }, settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings }, viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
urlPresets: urlPresets ?? existing.urlPresets, urlPresets: urlPresets ?? deepExisting.urlPresets,
customFields: customFields ?? existing.customFields, customFields: customFields ?? deepExisting.customFields,
automation: { ...existing.automation, ...automation }, automation: { ...deepExisting.automation, ...automation },
}; };
} }
@@ -12,6 +12,12 @@ describe('safeMerge', () => {
publicInfo: 'existing backstageInfo', publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'existing custom title',
value: 'existing custom value',
},
],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -62,6 +68,12 @@ describe('safeMerge', () => {
project: { project: {
title: 'new title', title: 'new title',
publicInfo: 'new public info', publicInfo: 'new public info',
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}, },
}; };
// @ts-expect-error -- just testing // @ts-expect-error -- just testing
@@ -74,6 +86,12 @@ describe('safeMerge', () => {
backstageUrl: 'existing backstageUrl', backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo', backstageInfo: 'existing backstageInfo',
projectLogo: null, projectLogo: null,
custom: [
{
title: 'new custom title',
value: 'new custom value',
},
],
}); });
}); });
@@ -107,6 +125,7 @@ describe('safeMerge', () => {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
-8
View File
@@ -7,14 +7,6 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { srcFiles } from './setup/index.js'; import { srcFiles } from './setup/index.js';
// ================================================= // =================================================
// resolve running environment
const env = process.env.NODE_ENV || 'production';
export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD; export const password = process.env.SESSION_PASSWORD;
export const routerPrefix = process.env.ROUTER_PREFIX; export const routerPrefix = process.env.ROUTER_PREFIX;
+1
View File
@@ -11,6 +11,7 @@ export const dbModel: DatabaseModel = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+1
View File
@@ -413,6 +413,7 @@ export const demoDb: DatabaseModel = {
backstageUrl: 'www.github.com/cpvalente/ontime', backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal', backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null, projectLogo: null,
custom: [],
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
@@ -740,8 +740,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(-50); expect(absoluteOffset).toBe(-50);
}); });
it('added time subtracts time offset (positive offset)', () => { it('added time subtracts time offset (positive offset)', () => {
@@ -763,8 +763,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(-60); expect(absoluteOffset).toBe(-60);
}); });
it('considers running overtime (negative offset)', () => { it('considers running overtime (negative offset)', () => {
@@ -787,8 +787,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(-10); expect(absoluteOffset).toBe(-10);
}); });
it('paused time is delayed time (negative offset)', () => { it('paused time is delayed time (negative offset)', () => {
@@ -812,8 +812,8 @@ describe('getRuntimeOffset()', () => {
}, },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(-25); expect(absoluteOffset).toBe(-25);
}); });
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
@@ -850,8 +850,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(0); expect(absoluteOffset).toBe(0);
}); });
it('handles loaded event', () => { it('handles loaded event', () => {
@@ -891,8 +891,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(81000000 - 79521653); // clock - timestart expect(absoluteOffset).toBe(81000000 - 79521653); // clock - timestart
}); });
it('with time-to-end, offsets dont exist if we are not in overtime', () => { it('with time-to-end, offsets dont exist if we are not in overtime', () => {
@@ -944,8 +944,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(0); expect(absoluteOffset).toBe(0);
}); });
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset is the overtime', () => {
@@ -997,8 +997,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(offset).toBe(-400000); // <--- offset is always the overtime expect(absoluteOffset).toBe(-400000); // <--- offset is always the overtime
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
@@ -1040,9 +1040,84 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent; state.timer.current = updateCurrent;
const offset = getRuntimeOffset(state); const { absoluteOffset } = getRuntimeOffset(state);
expect(millisToString(offset)).toBe('-00:16:40'); expect(millisToString(absoluteOffset)).toBe('-00:16:40');
expect(offset).toBe(81000000 - 82000000); // <-- planned end - now expect(absoluteOffset).toBe(81000000 - 82000000); // <-- planned end - now
});
});
describe('getRelativeOffset()', () => {
it('relative offset is 0 when starting at the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 150,
},
timer: {
startedAt: 150,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 150,
plannedStart: 150,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
expect(relativeOffset).toBe(0);
});
it('relative offset is 0 when starting after the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 100,
},
timer: {
startedAt: 150,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 150,
plannedStart: 100,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50);
expect(relativeOffset).toBe(0);
});
it('relative offset is 0 when starting before the planed time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 150,
},
timer: {
startedAt: 100,
addedTime: 0,
current: 0,
},
_timer: {
pausedAt: null,
},
runtime: {
actualStart: 100,
plannedStart: 150,
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(50);
expect(relativeOffset).toBe(0);
}); });
}); });
@@ -1169,9 +1244,11 @@ describe('getTimerPhase()', () => {
}, },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
totalDelay: 0,
pausedAt: null, pausedAt: null,
}, },
_rundown: {
totalDelay: 0,
},
} as RuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
@@ -1208,9 +1285,11 @@ describe('getTimerPhase()', () => {
}, },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
totalDelay: 0,
pausedAt: null, pausedAt: null,
}, },
_rundown: {
totalDelay: 0,
},
} as RuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
@@ -2,7 +2,7 @@ import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node'; import { JSONFile } from 'lowdb/node';
import { publicFiles } from '../../setup/index.js'; import { publicFiles } from '../../setup/index.js';
import { isTest } from '../../externals.js'; import { isTest } from '../../setup/environment.js';
import { isPath } from '../../utils/fileManagement.js'; import { isPath } from '../../utils/fileManagement.js';
import { shouldCrashDev } from '../../utils/development.js'; import { shouldCrashDev } from '../../utils/development.js';
@@ -314,17 +314,13 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
} }
/** /**
* Changes the title of a project * Patches the current project data
* it handles invalidating the necessary data * Handles deleting the local logo if the logo has been removed
*/ */
export async function editCurrentProjectData(newData: Partial<ProjectData>) { export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData(); const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData); const updatedProjectData = await getDataProvider().setProjectData(newData);
if (currentProjectData.title !== updatedProjectData.title) {
// something
}
// Delete the old logo if the logo has been removed // Delete the old logo if the logo has been removed
if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) { if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) {
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo); const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
@@ -108,7 +108,7 @@ export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove); const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventIds }); const { didMutate } = await scopedMutation({ eventIds });
if (didMutate === false) { if (!didMutate) {
return; return;
} }
@@ -146,7 +146,7 @@ export async function editEvent(patch: PatchWithId) {
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id }); const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
// short circuit if nothing changed // short circuit if nothing changed
if (didMutate === false) { if (!didMutate) {
return newEvent; return newEvent;
} }

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