Compare commits

...

119 Commits

Author SHA1 Message Date
Carlos Valente ad8f6dfcc7 bump version to 3.5.1 2024-09-07 08:49:39 +02:00
Carlos Valente 4706ed39f6 refactor: scope rundown to selected 2024-09-07 08:46:32 +02:00
Carlos Valente a8182cac8c refactor: simplify logic 2024-09-07 08:46:32 +02:00
Carlos Valente f9a5c55c07 refactor: reactive options 2024-09-07 08:46:32 +02:00
Carlos Valente 980ae7a7c5 refactor: show upcoming events 2024-09-07 08:46:32 +02:00
Carlos Valente db8ed93d41 fix: handle multiple days 2024-09-07 08:46:32 +02:00
Carlos Valente f8ca5b9cef fix: clear internal state on stop 2024-09-07 08:45:43 +02:00
Carlos Valente b9db08089f fix: ignore 0 duration events 2024-09-07 08:45:43 +02:00
Carlos Valente a3020ef3b2 fix: issue with roll evaluating multiple days 2024-09-07 08:45:43 +02:00
Carlos Valente 2492972097 fix: incorrect falsy check 2024-09-04 09:28:15 +02:00
Alex Christoffer Rasmussen aa4ee546ec Merge import (#1164)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-09-01 20:38:59 +02:00
Carlos Valente 0abaf7724d refactor: refetch on change 2024-09-01 17:18:48 +02:00
Carlos Valente 7b932ef0f7 refactor: add options to hide schedule 2024-09-01 10:41:54 +02:00
Carlos Valente 8bd7645c95 ui: rename mode to match UI 2024-08-31 10:55:32 +02:00
Carlos Valente 81f0e61953 refactor: add extra data to sentry logs 2024-08-29 15:10:42 +02:00
Carlos Valente 3877e207d0 chore: remove incorrect label 2024-08-27 14:21:35 +02:00
Carlos Valente d78e8838c3 bump version to 3.5.0 2024-08-27 13:16:01 +02:00
Carlos Valente 16c7107069 chore: mark timeline as beta 2024-08-27 13:16:01 +02:00
Alex Christoffer Rasmussen e20cd87673 fix: stop log incorrectly (#1185)
* fix: stop log incorrectly

* prevent start if already playing
2024-08-27 13:09:39 +02:00
Alex Christoffer Rasmussen db5fbd8019 add time in block to restore point (#1192) 2024-08-27 13:08:16 +02:00
Gyuris Gellért 25aefe322b Hungarian translation (#1187)
* Add Hungarian translation
2024-08-27 13:06:23 +02:00
Carlos Valente 73db14c383 refactor: show running gap in UI 2024-08-27 12:58:18 +02:00
Carlos Valente f5936e5254 refactor: unify logic 2024-08-27 12:58:18 +02:00
Carlos Valente 25c7915cf7 refactor: account for overlapping in rundown 2024-08-27 12:58:18 +02:00
Carlos Valente c43b6f37f9 refactor: accumulate times 2024-08-27 12:58:18 +02:00
Carlos Valente 6e5034ef72 refactor: show progress on top 2024-08-27 12:58:18 +02:00
Carlos Valente 66774321a5 refactor: calculate rundown duration 2024-08-27 12:58:18 +02:00
Carlos Valente d71df886f2 style: clarify function scope 2024-08-27 12:58:18 +02:00
Carlos Valente abcee741e2 refactor: prevent index collision 2024-08-27 12:58:18 +02:00
Alex Christoffer Rasmussen ee1b5b7fdd Keep offset when taking over playback with roll v2 (#1184)
* pass on offset

* account for offset in roll

* prevent roll from overtime

* add test
2024-08-26 18:09:03 +02:00
Alex Christoffer Rasmussen 1b1823e0fe Fix: change api endpoint (#1183)
* remove async from parseProperty

* swap shouldThrottle check

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-20 11:22:02 +02:00
Alex Christoffer Rasmussen 340f5478fb add shortcut to jump between blocks (#1181)
* add shortcut

* chore: rename getRelevantBlock to getPreviousBlock
2024-08-19 17:20:58 +02:00
Alex Christoffer Rasmussen f708dc0bc4 Fix current block (#1170)
* pass full rundown to loadNow

* remove prevBlock from state

* refactor: simplify load logic (#1174)

* send clock to client on block start to avoid it showing -1 in the client

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-15 16:25:29 +02:00
Carlos Valente 49467c66b7 fix: check worksheet errors before submit 2024-08-15 11:12:21 +02:00
Carlos Valente b539b2ecd5 refactor: review styles and implement BEM for user override 2024-08-13 21:54:43 +02:00
Alex Christoffer Rasmussen dc06a9afc3 fix TimerPahse difference from load and reload (#1179)
* use loadEvent function to reload

* rename reload to updateLoaded
2024-08-12 20:14:06 +02:00
Carlos Valente d3620b52f4 refactor: only broadcast public 2024-08-11 18:18:53 +02:00
Carlos Valente 66ed92e215 fix: stop playback when event is deleted 2024-08-10 12:50:30 +02:00
Carlos Valente 347f333b3e refactor: unify progress bar logic 2024-08-09 22:51:50 +02:00
Carlos Valente c3fb3061f6 fix: use unfiltered events in timeline 2024-08-09 20:01:30 +02:00
Carlos Valente a26aa9e01c bump version to 3.5.0-beta.2 2024-08-04 21:37:01 +02:00
Carlos Valente da11ef64c2 refactor: roll mode
* chore: escalate stack trace to console

* refactor: event finding in roll

* docs: initial roll specification

* refactor: call to roll

* refactor: normalise index

* refactor: roll into next event

* refactor: hot reload on waiting to roll

* refactor: wait to roll next
2024-08-04 21:34:01 +02:00
Carlos Valente 3012d73285 feat: timeline view 2024-08-04 21:32:25 +02:00
Alex Christoffer Rasmussen bad491063d fix: getShouldClockUpdate test (#1165)
* useFakeTimers

* now can never be negative
2024-07-31 16:58:02 +02:00
Alex Christoffer Rasmussen a103f8b347 show time in block (#1158) 2024-07-31 14:00:40 +02:00
Alex Christoffer Rasmussen dffccd258f prevent rare case of old customFields (#1163)
as part of subscriptions in operator
2024-07-30 15:42:37 +02:00
Alex Christoffer Rasmussen ce8b0d9850 FIX: remove '/' from host in client-control(#1156) 2024-07-30 12:36:31 +02:00
Alex Christoffer Rasmussen e500fa6b5e about in electron help menu (#1155) 2024-07-29 18:06:10 +02:00
Alex Christoffer Rasmussen 67be4a5631 restore rundownGetAll (#1162) 2024-07-29 13:29:56 +02:00
Alex Christoffer Rasmussen c7893938eb remove old l3 options (#1160) 2024-07-27 09:44:41 +02:00
Alex Christoffer Rasmussen 158ef05ff0 add relevantBlock function (#1100)
* feat: current block

* pass full rundown to functions and use filter utils
2024-07-23 12:22:47 +02:00
Carlos Valente 20838c038a chore: evaluate react 19 migration 2024-07-23 09:02:44 +02:00
Carlos Valente 1ddcec993a chore: upgrade sentry to react version 2024-07-22 22:27:10 +02:00
Alex Christoffer Rasmussen 91d6adf8e0 useFakeTimers for initRundown in tests (#1150) 2024-07-22 14:15:09 +02:00
Carlos Valente a7fe5eceef chore: upgrade sentry 2024-07-20 16:25:46 +02:00
jwetzell e6e00c0d4a add freeze feature to rundown (#1106)
* add frozen flag to runtime store

* add middleware for when frozen

* prevent rundown editing when frozen

* add endpoint to router to set frozen state

* add frozen property to rundown placeholder in client
2024-07-19 14:14:59 +02:00
Carlos Valente edc7f20d7a refactor: timer update 2024-07-19 14:11:12 +02:00
Carlos Valente cb773ded9f refactor: improve pending detection 2024-07-19 14:11:12 +02:00
Carlos Valente 2f65711078 refactor: dev label 2024-07-19 14:11:12 +02:00
jwetzell 3056b75960 clean out unused demo and test db.json files (#1148)
* remove demo and test db JSON

* remove demo-db reference in Dockerfile

* remove preloaded-db folder in server

* cleanup package.json scripts

* remove db.json references from server
2024-07-18 22:27:24 +02:00
jwetzell dd9423abf4 remove unused dependency 2024-07-18 22:26:58 +02:00
jwetzell dee6a67018 Merge pull request #1146 from cpvalente/chore/pnpm-catalogs
convert shared dependencies to pnpm catalog
2024-07-17 22:23:47 -05:00
jwetzell a79fb4ecbb convert shared dependencies to pnpm catalog 2024-07-17 07:53:43 -05:00
jwetzell a53a1f87be Merge pull request #1145 from cpvalente/chore/remove-husky
remove husky and pre-commit scripts
2024-07-17 07:41:17 -05:00
jwetzell ef9afcbf3e remove husky and pre-commit scripts 2024-07-16 17:57:20 -05:00
Carlos Valente 33b04e01c9 chore: cleanup unused 2024-07-16 21:34:21 +02:00
Carlos Valente c5e821df35 bump version to 3.4.0 2024-07-16 19:21:56 +02:00
jwetzell 0fd71c9a00 align docker-compose example with docker image layout 2024-07-16 19:20:05 +02:00
jwetzell 53b453abc8 change docker data path from /external to /data 2024-07-16 19:20:05 +02:00
Carlos Valente fe0665506e chore: update typescript 2024-07-16 13:18:20 +02:00
Carlos Valente 9bd600c4b1 chore: add watch mode in dev 2024-07-16 13:18:20 +02:00
jwetzell bbd4254637 Merge pull request #1138 from cpvalente/chore/bump-version
bump version to 3.4.0-beta.3
2024-07-15 08:23:16 -05:00
jwetzell 2603c777ef bump version to 3.4.0-beta.3 2024-07-15 07:23:00 -05:00
jwetzell 8942a4ba4f Merge pull request #1137 from cpvalente/fix/buildx-platforms
drop arm/v6 image
2024-07-15 07:20:32 -05:00
jwetzell f97be749dc drop arm/v6 image 2024-07-15 07:18:06 -05:00
Carlos Valente 5836d5ffc7 bump version to 3.4.0-beta.2 2024-07-15 10:28:58 +02:00
jwetzell 91f21fdeb1 Merge pull request #1134 from cpvalente/fix/docker-building
switch Dockerfile build stage to non-alpine image
2024-07-14 13:18:24 -05:00
jwetzell 844eec132b use npm to install specific pnpm version 2024-07-13 06:36:22 -05:00
jwetzell adc4b3f8bf switch Dockerfile build stage to non-alpine image 2024-07-12 21:12:57 -05:00
Carlos Valente fb09631db9 bump version to 3.4.0-beta 2024-07-11 14:44:02 +02:00
Carlos Valente 19febb98a5 bump version to 3.4.0.beta 2024-07-11 14:33:18 +02:00
Carlos Valente 6bfa11dd8d fix: format clock as timer 2024-07-11 13:31:25 +02:00
Carlos Valente 4758bc84b5 chore: upgrade node dependencies 2024-07-11 13:31:05 +02:00
Carlos Valente 917b182e39 feat: granular endpoints for rundown 2024-07-11 11:03:07 +02:00
Carlos Valente e6c3322321 feat: add list of changes to refetch 2024-07-11 11:03:07 +02:00
Carlos Valente 00d34eec9b refactor: emit load event on reload 2024-07-11 11:02:49 +02:00
Carlos Valente 84792bc00d refactor: initialise data 2024-07-11 11:02:22 +02:00
Carlos Valente dae32e89a3 refactor: patch project 2024-07-11 11:02:22 +02:00
Carlos Valente 367997fd16 refactor: simplify file checks 2024-07-11 11:02:22 +02:00
Carlos Valente 751c3329f0 refactor: load project 2024-07-11 11:02:22 +02:00
Carlos Valente 9c5e403b18 refactor: add directory for crash logs 2024-07-11 11:02:22 +02:00
Carlos Valente c944e77bc1 refactor: last project may not exist 2024-07-11 11:02:22 +02:00
Carlos Valente d94c1d4252 refactor: remove unnecessary check 2024-07-11 11:02:22 +02:00
Carlos Valente 9f395cb16d refactor: safe get info 2024-07-11 11:02:22 +02:00
Carlos Valente 46195fc043 refactor: rename project 2024-07-11 11:02:22 +02:00
Carlos Valente c9ef8d55c1 refactor: duplicate project 2024-07-11 11:02:22 +02:00
Carlos Valente ad0e821cc0 refactor: delete project 2024-07-11 11:02:22 +02:00
Carlos Valente 21454947e0 refactor: create project 2024-07-11 11:02:22 +02:00
Carlos Valente ad69c0ff80 refactor: project service boundaries 2024-07-11 11:02:22 +02:00
Carlos Valente 20d9df2501 refactor: escalate errors from parser 2024-07-11 11:02:22 +02:00
Carlos Valente de9af5aaa2 refactor: minor code cleanups 2024-07-11 11:02:22 +02:00
Carlos Valente 61280b06b7 refactor: simplify logic dynamic menus 2024-07-10 23:01:47 +02:00
Carlos Valente ce4a48cd8d refactor: extract options per view 2024-07-10 23:01:47 +02:00
Carlos Valente 2877475a35 refactor: allow reset selection by refreshing 2024-07-10 23:01:47 +02:00
Carlos Valente 73e23ee323 feat: projected times in countdown 2024-07-10 23:01:47 +02:00
Carlos Valente 938c5fbb4c refactor: offset cannot be null 2024-07-10 23:01:47 +02:00
Carlos Valente e90e94828d style: add npm badge 2024-07-09 09:30:01 +02:00
jwetzell 1f0401632e have electron dev script wait for local server instance 2024-07-03 22:31:20 +02:00
jwetzell 5c3989c9b6 switch workflows to release trigger (#1115)
* switch docker workflow to release trigger

* switch cli publish workflow to release trigger
2024-07-03 22:30:56 +02:00
Carlos Valente 6ad26f06c7 style: rename log feature 2024-07-02 16:56:23 +02:00
Carlos Valente a63fa68d39 bump version to 3.3.3 2024-06-23 23:02:14 +02:00
Carlos Valente 20503de6fd refactor: minor code cleanups 2024-06-23 22:07:34 +02:00
Carlos Valente 0335da9786 refactor: improve errors to user 2024-06-23 22:07:34 +02:00
Carlos Valente 9f1a64f53c chore: upgrade dependencies 2024-06-23 22:07:34 +02:00
Alex Christoffer Rasmussen 975356f4f4 events with 0 duration dose not contribute to next day calculation (#1099) 2024-06-23 22:06:44 +02:00
Carlos Valente 1b448835cd fix: duplicate validation 2024-06-23 16:17:03 +02:00
Carlos Valente cbf3d566b9 fix: prevent calling service before init 2024-06-23 11:50:49 +02:00
Carlos Valente 6c20cb5e99 refactor: remove size limits in title 2024-06-22 13:48:19 +02:00
Carlos Valente cfc3aab893 style: add background to highlight 2024-06-21 14:45:45 +02:00
229 changed files with 14208 additions and 9908 deletions
+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: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- 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: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- 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: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -1,8 +1,8 @@
name: Ontime CLI build name: Ontime CLI build
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -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: 18.18.2 node-version: 20
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: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+6 -8
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime name: Docker Image CI Ontime
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,8 +14,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
@@ -32,10 +30,10 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Build and push pre-release
if: github.event.release.prerelease == true if: github.event.release.prerelease == true
@@ -43,8 +41,8 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+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: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- 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: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+1 -1
View File
@@ -1 +1 @@
v18.18.2 v20.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__ (~18.18.2) - __node__ (~20)
- __pnpm__ (>=8) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+4 -5
View File
@@ -1,19 +1,19 @@
FROM node:18.18-alpine AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN npm install -g pnpm@9.5.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:18.18-alpine FROM node:20-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
ENV NODE_ENV=docker ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/external/ ENV ONTIME_DATA=/data/
WORKDIR /app/ WORKDIR /app/
@@ -22,7 +22,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
# Export default ports # Export default ports
+1
View File
@@ -1,6 +1,7 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.3.2", "version": "3.5.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",
@@ -20,8 +20,8 @@
"server" "server"
], ],
"devDependencies": { "devDependencies": {
"eslint": "^8.53.0", "eslint": "catalog:",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "catalog:",
"prettier": "^3.0.3" "prettier": "catalog:"
} }
} }
+11 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.3.2", "version": "3.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -13,7 +13,7 @@
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2", "@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3", "@tanstack/react-table": "^8.11.3",
@@ -22,9 +22,9 @@
"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",
"react": "^18.2.0", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
@@ -42,7 +42,6 @@
"build:docker": "vite build", "build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -70,13 +69,13 @@
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.12.0", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0", "eslint": "catalog:",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,9 +83,9 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.3.1", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.4.3", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
+57 -30
View File
@@ -1,24 +1,36 @@
import { lazy, Suspense } from 'react'; import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock')); const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public')); const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Public = React.lazy(() => import('./features/viewers/public/Public'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView)); const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -28,41 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public)); const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() { export default function AppRouter() {
// handle client path changes // handle client path changes
useClientPath(); useClientPath();
return ( return (
<Suspense fallback={null}> <React.Suspense fallback={null}>
<Routes> <SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} /> <Route path='/timer' element={<STimer />} />
<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} /> <Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} /> <Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} /> <Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} /> <Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} /> <Route path='/studio' element={<SStudio />} />
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route path='/timeline' element={<STimeline />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -99,7 +126,7 @@ export default function AppRouter() {
/> />
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</Routes> </SentryRoutes>
</Suspense> </React.Suspense>
); );
} }
+2 -2
View File
@@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/db`;
/** /**
* HTTP request to the current DB * HTTP request to the current DB
*/ */
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
const url = `${dbPath}/${filename}/rename`; const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, { const res = await axios.put(decodedUrl, {
filename: newFilename, newFilename,
}); });
return res.data; return res.data;
} }
@@ -38,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose(); onClose();
}; };
const host = `${window.location.origin}/`; const host = window.location.origin;
const canSubmit = path !== currentPath && path !== ''; const canSubmit = path !== currentPath && path !== '';
return ( return (
@@ -28,9 +28,11 @@ class ErrorBoundary extends React.Component {
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras('error', error); scope.setExtras({
scope.setExtras('store', runtimeStore.getState()); error,
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts }); store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
@@ -54,7 +54,6 @@ function Overlay() {
}; };
}, [showOverlay, id, setIdentify, handleClose]); }, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return ( return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}> <div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div> <div className={style.name}>{name}</div>
@@ -1,12 +1,12 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math'; import { getProgress } from '../../utils/getProgress';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: number; complete: MaybeNumber;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -31,10 +31,9 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return ( return (
<div <div
@@ -1,22 +1,23 @@
import { clamp } from '../../utils/math'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
now?: number; current: MaybeNumber;
complete?: number; duration: MaybeNumber;
hidden?: boolean; hidden?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { now = 0, complete = 100, hidden, className = '' } = props; const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return ( return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}> <div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} /> <div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div> </div>
); );
} }
@@ -1,25 +1,37 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
.title-card { .title-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
} }
.inline { .title-card__title,
display: flex; .title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.title-card__title { .title-card__title {
font-weight: 600; font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
line-height: 1.1em; line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
} }
.title-card__label { .title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem); font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto; margin-left: auto;
text-transform: uppercase; text-transform: uppercase;
@@ -28,13 +40,3 @@
color: var(--accent-color-override, $accent-color); color: var(--accent-color-override, $accent-color);
} }
} }
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -1,28 +1,32 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss'; import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
label: 'now' | 'next';
title: string; title: string;
label?: 'now' | 'next';
secondary?: string; secondary?: string;
className?: string;
} }
export default function TitleCard(props: TitleCardProps) { const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary } = props; const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const accent = label === 'now'; const accent = label === 'now';
return ( return (
<div className='title-card'> <div className={`title-card ${className}`} ref={ref}>
<div className='inline'> <span className='title-card__title'>{title}</span>
<span className='title-card__title'>{title}</span> <span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}> {label && getLocalizedString(`common.${label}`)}
{getLocalizedString(`common.${label}`)} </span>
</span>
</div>
<div className='title-card__secondary'>{secondary}</div> <div className='title-card__secondary'>{secondary}</div>
</div> </div>
); );
} });
TitleCard.displayName = 'TitleCard';
export default TitleCard;
@@ -27,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props; const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') { if (type === 'option') {
const optionFromParams = searchParams.get(id); const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue; const defaultOptionValue = optionFromParams || defaultValue;
@@ -13,14 +13,24 @@
color: $label-gray; color: $label-gray;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem gap: 0.25rem;
} }
.columnSection { .section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex; display: flex;
padding: $section-spacing 0; padding: $section-spacing 0;
flex-direction: column; flex-direction: column;
gap: $element-spacing; gap: $element-spacing;
margin-left: 0.5rem;
} }
.title { .title {
@@ -13,7 +13,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { ParamField } from './types'; import { isSection, ViewOption } from './types';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
@@ -22,25 +22,37 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/** /**
* Makes a new URLSearchParams object from the given params object * Makes a new URLSearchParams object from the given params object
*/ */
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => { const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => { const newSearchParams = new URLSearchParams();
acc[id] = String(defaultValue);
return acc;
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => { // Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value); newSearchParams.set(id, value);
} }
return newSearchParams; });
}, new URLSearchParams()); return newSearchParams;
}; };
interface EditFormDrawerProps { interface EditFormDrawerProps {
paramFields: ParamField[]; viewOptions: ViewOption[];
} }
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
@@ -68,7 +80,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
formEvent.preventDefault(); formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose(); onClose();
@@ -85,15 +97,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerBody> <DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => ( {viewOptions.map((option) => {
<div key={field.title} className={style.columnSection}> if (isSection(option)) {
<label className={style.label}> return (
<span className={style.title}>{field.title}</span> <div key={option.section} className={style.section}>
<span className={style.description}>{field.description}</span> {option.section}
<ParamInput key={field.title} paramField={field} /> </div>
</label> );
</div> }
))}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
</form> </form>
</DrawerBody> </DrawerBody>
@@ -1,15 +1,18 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { type ParamField } from './types'; import type { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => { export const makeOptionsFromCustomFields = (
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => { customFields: CustomFields,
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` }; additionalOptions: Record<string, string> = {},
}, additionalOptions ?? {}); ) => {
return customFieldOptions; return Object.entries(customFields).reduce((options, [key, value]) => {
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
}; };
const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
id: 'timeformat', id: 'timeformat',
@@ -20,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
}; };
}; };
const hideTimerSeconds: ParamField = { export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds', id: 'hideTimerSeconds',
title: 'Hide seconds in timer', title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer', description: 'Whether to hide seconds in the running timer',
@@ -28,484 +31,10 @@ const hideTimerSeconds: ParamField = {
defaultValue: false, defaultValue: false,
}; };
const showLeadingZeros: ParamField = { export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros', id: 'showLeadingZeros',
title: 'Show leading zeros in timer', title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer', description: 'Whether to show leading zeros in the running timer',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}; };
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
showLeadingZeros,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {});
return [
getTimeOption(timeFormat),
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,3 +1,7 @@
type ParamSection = {
section: string;
};
type BaseField = { type BaseField = {
id: string; id: string;
title: string; title: string;
@@ -19,5 +23,15 @@ type MultiOptionsField = {
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField); export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields'; import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
@@ -16,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
@@ -12,9 +12,7 @@ export const useClientPath = () => {
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
//remove leading '/' from path socketSendJson('set-client-path', pathname + search);
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]); }, [pathname, search]);
// navigate to new path when received from server // navigate to new path when received from server
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
const { threshold = 400, onStart, onFinish, onCancel } = options; const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false); const isLongPressActive = useRef(false);
const isPressed = useRef(false); const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>(); const timerId = useRef<NodeJS.Timeout>();
return useMemo(() => { return useMemo(() => {
const start = (event: SyntheticEvent) => { const start = (event: SyntheticEvent) => {
+13 -1
View File
@@ -50,6 +50,7 @@ export const usePlaybackControl = () => {
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -115,6 +116,7 @@ export const setAuxTimer = {
export const useCuesheet = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
@@ -149,7 +151,6 @@ export const useClock = () => {
/** Used by the progress bar components */ /** Used by the progress bar components */
export const useProgressData = () => { export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
@@ -179,6 +180,17 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
+5
View File
@@ -38,6 +38,10 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null, actualStart: null,
expectedEnd: null, expectedEnd: null,
}, },
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
publicEventNow: null, publicEventNow: null,
@@ -48,6 +52,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
duration: 0, duration: 0,
playback: SimplePlayback.Stop, playback: SimplePlayback.Stop,
}, },
frozen: false,
}; };
const deepCompare = <T>(a: T, b: T) => isEqual(a, b); const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
@@ -0,0 +1,23 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from './math';
/**
* Returns completion percentage of a progress bar
* This code assumes the current time and duration have addedTime already applied
*/
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
if (current === null || duration === null) {
return 0;
}
if (current <= 0) {
return 100;
}
if (current >= duration) {
return 0;
}
return clamp(((duration - current) / duration) * 100, 0, 100);
}
+20 -3
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types'; import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
getClientId, getClientId,
@@ -36,7 +36,8 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -150,6 +151,11 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload); patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
@@ -170,6 +176,17 @@ export const connectSocket = () => {
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
break;
}
} }
} catch (_) { } catch (_) {
// ignore unhandled // ignore unhandled
@@ -34,3 +34,16 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––'; export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––'; export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
+37 -7
View File
@@ -1,25 +1,25 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils'; import { 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';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
* Returns current time in milliseconds * Returns current time in milliseconds from midnight
* @returns {number} * @returns {number}
*/ */
export const nowInMillis = () => { export function nowInMillis(): number {
const now = new Date(); const now = new Date();
// extract milliseconds since midnight // extract milliseconds since midnight
let elapsed = now.getHours() * 3600000; let elapsed = now.getHours() * MILLIS_PER_HOUR;
elapsed += now.getMinutes() * 60000; elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
elapsed += now.getSeconds() * 1000; elapsed += now.getSeconds() * MILLIS_PER_SECOND;
elapsed += now.getMilliseconds(); elapsed += now.getMilliseconds();
return elapsed; return elapsed;
}; }
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
@@ -95,3 +95,33 @@ export const formatTime = (
const isNegative = milliseconds < 0; const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`; return `${isNegative ? '-' : ''}${display}`;
}; };
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
}
if (minutes > 0) {
result += `${minutes}m`;
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
@@ -148,6 +148,7 @@ export default function GeneralPanelForm() {
<option value='en'>English</option> <option value='en'>English</option>
<option value='fr'>French</option> <option value='fr'>French</option>
<option value='de'>German</option> <option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option> <option value='it'>Italian</option>
<option value='no'>Norwegian</option> <option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
@@ -100,7 +100,7 @@ export default function EditorSettingsForm() {
</Panel.ListGroup> </Panel.ListGroup>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Title>Play mode</Panel.Title> <Panel.Title>Run mode</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
@@ -17,7 +17,7 @@ export default function LogExport() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Network log Event log
<Button <Button
variant='ontime-subtle' variant='ontime-subtle'
size='sm' size='sm'
@@ -42,7 +42,7 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error); const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`); setError(`Error uploading file: ${errorMessage}`);
} finally { } finally {
invalidateAllCaches(); await invalidateAllCaches();
} }
setLoading(null); setLoading(null);
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type ProjectFormValues = { export type ProjectFormValues = {
@@ -11,14 +9,13 @@ export type ProjectFormValues = {
}; };
interface ProjectFormProps { interface ProjectFormProps {
action: 'duplicate' | 'rename'; action: 'duplicate' | 'rename' | 'merge';
filename: string; filename: string;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>; onSubmit: (values: ProjectFormValues) => Promise<void>;
submitError: string | null;
} }
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) { export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
}, [setFocus]); }, [setFocus]);
return ( return (
<> <form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<form onSubmit={handleSubmit(onSubmit)} className={style.form}> <Input
<Input className={style.formInput}
className={style.formInput} id='filename'
id='filename' size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm' size='sm'
type='text'
variant='ontime-filled' variant='ontime-filled'
placeholder='Enter new name' isDisabled={!isDirty || !isValid || isSubmitting}
autoComplete='off' type='submit'
{...register('filename')} className={style.saveButton}
/> >
<div className={style.actionButtons}> {action}
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}> </Button>
Cancel </div>
</Button> </form>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
); );
} }
@@ -11,12 +11,15 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | null; export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
interface ProjectListItemProps { interface ProjectListItemProps {
current?: boolean; current?: boolean;
@@ -40,36 +43,53 @@ export default function ProjectListItem({
onToggleEditMode, onToggleEditMode,
}: ProjectListItemProps) { }: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmitRename = async (values: ProjectFormValues) => { const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
try { return async (values: ProjectFormValues) => {
setLoading(true);
setSubmitError(null); setSubmitError(null);
try {
if (!values.filename) { if (!values.filename) {
setSubmitError('Filename cannot be blank'); setSubmitError('Filename cannot be blank');
return; return;
}
const action = actionType === 'rename' ? renameProject : duplicateProject;
await action(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
await renameProject(filename, values.filename); };
};
const handleLoad = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try {
await loadProject(filename);
await onRefetch(); await onRefetch();
onSubmit(); await invalidateAllCaches();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
const handleSubmitDuplicate = async (values: ProjectFormValues) => { const handleDelete = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try { try {
setSubmitError(null); await deleteProject(filename);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await duplicateProject(filename, values.filename);
await onRefetch(); await onRefetch();
onSubmit();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
@@ -82,54 +102,70 @@ export default function ProjectListItem({
handleToggleEditMode(null, null); handleToggleEditMode(null, null);
}; };
const isCurrentlyBeingEdited = editingMode && filename === editingFilename; const isCurrentlyBeingEdited = filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined; const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return ( return (
<tr key={filename} className={classes}> <>
{isCurrentlyBeingEdited ? ( {submitError && (
<td colSpan={99}> <tr key='filename-error'>
<ProjectForm <td colSpan={99}>
action={editingMode} <Panel.Error>{submitError}</Panel.Error>
filename={filename} </td>
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename} </tr>
onCancel={handleCancel} )}
submitError={submitError} <tr key={filename} className={classes}>
/> {showProjectForm ? (
</td> <td colSpan={99}>
) : ( <ProjectForm
<> action={editingMode}
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename} filename={filename}
onChangeEditMode={handleToggleEditMode} onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
onRefetch={onRefetch} onCancel={handleCancel}
/> />
</td> </td>
</> ) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
/>
</td>
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)} )}
</tr> </>
); );
} }
function ActionMenu({ interface ActionMenuProps {
current,
filename,
onChangeEditMode,
onRefetch,
}: {
current?: boolean; current?: boolean;
filename: string; filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onRefetch: () => Promise<void>; onDelete: (filename: string) => Promise<void>;
}) { onLoad: (filename: string) => Promise<void>;
const handleLoad = async () => { onMerge: (filename: string) => void;
await loadProject(filename); }
await invalidateAllCaches(); function ActionMenu(props: ActionMenuProps) {
}; const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
const handleRename = () => { const handleRename = () => {
onChangeEditMode('rename', filename); onChangeEditMode('rename', filename);
@@ -139,11 +175,6 @@ function ActionMenu({
onChangeEditMode('duplicate', filename); onChangeEditMode('duplicate', filename);
}; };
const handleDelete = async () => {
await deleteProject(filename);
await onRefetch();
};
const handleDownload = async () => { const handleDownload = async () => {
await downloadProject(filename); await downloadProject(filename);
}; };
@@ -161,16 +192,20 @@ function ActionMenu({
color='#e2e2e2' // $gray-200 color='#e2e2e2' // $gray-200
variant='ontime-ghosted' variant='ontime-ghosted'
size='sm' size='sm'
isDisabled={isDisabled}
/> />
<MenuList> <MenuList>
<MenuItem onClick={handleLoad} isDisabled={current}> <MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load Load
</MenuItem> </MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem> <MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem> <MenuItem onClick={handleDownload}>Download</MenuItem>
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem> <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={handleDelete}> <MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
Delete Delete
</MenuItem> </MenuItem>
</MenuList> </MenuList>
@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
}
type ProjectMergeFormValues = {
project: boolean;
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
const { onClose, fileName } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
formState: { isSubmitting, isValid, isDirty },
} = useForm<ProjectMergeFormValues>({
defaultValues: {
project: false,
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const handleSubmitCreate = async (values: ProjectMergeFormValues) => {
const allFalse = Object.values(values).every((value) => !value);
if (allFalse) {
setError('At least one option must be selected');
return;
}
try {
setError(null);
// make patch object
const { data } = await getDb(fileName);
const patch = await makeProjectPatch(data, values);
// request patch
await patchData(patch);
await queryClient.invalidateQueries({ queryKey: PROJECT_DATA });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Merge {`"${fileName}"`}
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
Merge
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible.
</Panel.Description>
<label>
<Switch variant='ontime' {...register('project')} />
Project data
</label>
<label>
<Switch variant='ontime' {...register('rundown')} />
Rundown + Custom Fields
</label>
<label>
<Switch variant='ontime' {...register('viewSettings')} />
View Settings
</label>
<label>
<Switch variant='ontime' {...register('urlPresets')} />
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
</label>
</div>
</Panel.Section>
);
}
@@ -2,6 +2,10 @@
background-color: $blue-1100; background-color: $blue-1100;
} }
.isEditing {
color: $blue-500;
}
.actionButton { .actionButton {
flex: 1; flex: 1;
text-align: right; text-align: right;
@@ -49,4 +53,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
label {
display: flex;
gap: 1em;
}
} }
@@ -0,0 +1,17 @@
import { DatabaseModel, isKeyOfType } from 'ontime-types';
export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<string, boolean>) {
const patchObject: Partial<DatabaseModel> = {};
for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields
if (key === 'rundown') {
patchObject.customFields = data['customFields'];
}
Object.assign(patchObject, { [key]: data[key] });
}
}
return patchObject;
}
@@ -78,7 +78,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading); const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading; const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading; const canSubmitGSheet = !isLoading && !stepData.worksheet.error;
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet); const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return ( return (
@@ -57,7 +57,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
secondary: [ secondary: [
{ {
id: 'network__log', id: 'network__log',
label: 'Application log', label: 'Event log',
}, },
{ {
id: 'network__clients', id: 'network__clients',
@@ -21,6 +21,7 @@ export default function PlaybackControl() {
playback={data.playback} playback={data.playback}
numEvents={data.numEvents} numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex} selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/> />
<AuxTimer /> <AuxTimer />
</div> </div>
@@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoReload } from '@react-icons/all-files/io5/IoReload'; import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils'; import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -19,10 +19,11 @@ interface PlaybackButtonsProps {
playback: Playback; playback: Playback;
numEvents: number; numEvents: number;
selectedEventIndex: number | null; selectedEventIndex: number | null;
timerPhase: TimerPhase;
} }
export default function PlaybackButtons(props: PlaybackButtonsProps) { export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props; const { playback, numEvents, selectedEventIndex, timerPhase } = props;
const isRolling = playback === Playback.Roll; const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play; const isPlaying = playback === Playback.Play;
@@ -37,7 +38,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableNext = isRolling || noEvents || isLast; const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst; const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback); const playbackCan = validatePlayback(playback, timerPhase);
const disableStart = !playbackCan.start; const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause; const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents; const disableRoll = !playbackCan.roll || noEvents;
@@ -21,11 +21,11 @@ interface CuesheetProps {
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void; handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null; selectedId: string | null;
currentBlockId: string | null;
} }
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) { export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings(); const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const { const {
columnVisibility, columnVisibility,
columnOrder, columnOrder,
@@ -114,11 +114,16 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} }
if (isOntimeBlock(row.original)) { if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />; return <BlockRow key={key} title={row.original.title} />;
} }
if (isOntimeDelay(row.original)) { if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration; const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) { if (!showDelayBlock || delayVal === 0) {
return null; return null;
} }
@@ -128,9 +133,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
if (isOntimeEvent(row.original)) { if (isOntimeEvent(row.original)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedId; const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) { if (isPast && !showPrevious) {
return null; return null;
@@ -107,6 +107,7 @@ export default function CuesheetWrapper() {
columns={columns} columns={columns}
handleUpdate={handleUpdate} handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId} selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/> />
</div> </div>
); );
@@ -6,18 +6,17 @@ import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() { export default function CuesheetProgress() {
const { data } = useViewSettings(); const { data } = useViewSettings();
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData(); const { current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
return ( return (
<MultiPartProgressBar <MultiPartProgressBar
now={current} now={current}
complete={totalTime} complete={duration}
normalColor={data!.normalColor} normalColor={data.normalColor}
warning={timeWarning} warning={timeWarning}
warningColor={data!.warningColor} warningColor={data.warningColor}
danger={timeDanger} danger={timeDanger}
dangerColor={data!.dangerColor} dangerColor={data.dangerColor}
className={styles.progressOverride} className={styles.progressOverride}
ignoreCssOverride ignoreCssOverride
/> />
@@ -4,7 +4,6 @@ import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
@@ -22,6 +21,7 @@ import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock'; import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar'; import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions } from './operator.options';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
@@ -134,7 +134,7 @@ export default function Operator() {
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar <StatusBar
@@ -170,9 +170,12 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title; const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? ''; const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = subscriptions const subscribedData = subscriptions
? subscriptions.map((id) => { ? subscriptions.flatMap((id) => {
if (!customFields[id]) {
return [];
}
const { label, colour } = customFields[id]; const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] }; return [{ id, label, colour, value: entry.custom[id] }];
}) })
: null; : null;
@@ -30,7 +30,7 @@
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700); background-color: var(--operator-running-bg-override, $active-red);
} }
&.past { &.past {
@@ -65,12 +65,15 @@
font-size: 1.5rem; font-size: 1.5rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: $ui-white; color: $ui-white;
@include ellipsis-text;
} }
.secondaryField { .secondaryField {
grid-area: secondary; grid-area: secondary;
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@include ellipsis-text;
} }
.schedule { .schedule {
@@ -98,8 +101,7 @@
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding-inline: 0.25rem;
margin-right: 0.25rem;
} }
.noColour { .noColour {
@@ -110,6 +112,8 @@
} }
.value { .value {
padding-inline: 0.25rem;
background-color: $gray-1250;
color: var(--operator-highlight-override, $ui-white); color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem; margin-right: 1rem;
} }
@@ -0,0 +1,60 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{ section: 'Element visibility' },
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -11,13 +11,12 @@ interface StatusBarProgressProps {
export default function StatusBarProgress(props: StatusBarProgressProps) { export default function StatusBarProgress(props: StatusBarProgressProps) {
const { viewSettings } = props; const { viewSettings } = props;
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData(); const { current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
return ( return (
<MultiPartProgressBar <MultiPartProgressBar
now={current} now={current}
complete={totalTime} complete={duration}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={timeWarning} warning={timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
@@ -33,6 +33,7 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} /> <TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div> </div>
<ProgressOverview /> <ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview /> <RuntimeOverview />
<div> <div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} /> <TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
@@ -94,6 +95,14 @@ function TitlesOverview() {
); );
} }
function CurrentBlockOverview() {
const { currentBlock, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
}
function TimerOverview() { function TimerOverview() {
const { current } = useTimer(); const { current } = useTimer();
+78 -26
View File
@@ -2,8 +2,24 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types'; import {
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils'; isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
PlayableEvent,
Playback,
RundownCached,
SupportedEvent,
} from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextBlockNormal,
getNextNormal,
getPreviousBlockNormal,
getPreviousNormal,
isNewLatest,
} from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -98,28 +114,61 @@ export default function Rundown({ data }: RundownProps) {
[rundown, order, addEvent], [rundown, order, addEvent],
); );
const selectBlock = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
);
const selectEntry = useCallback( const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => { (cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) { if (order.length < 1) {
return; return;
} }
let newCursor: string | null;
let newIndex: number | null;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // there is no cursor, we select the first or last depending on direction if it exists
newCursor = const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null; if (selected !== null) {
newIndex = direction === 'up' ? order.length : 0; setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
} else { }
// otherwise we select the next or previous return;
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
newCursor = selected.entry?.id ?? null;
newIndex = selected.index;
} }
if (newCursor && newIndex !== null) { // otherwise we select the next or previous
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex }); const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, rundown, setSelectedEvents],
@@ -145,6 +194,10 @@ export default function Rundown({ data }: RundownProps) {
useHotkeys([ useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }], ['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }], ['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }], ['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }], ['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
@@ -203,11 +256,9 @@ export default function Rundown({ data }: RundownProps) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />; return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
} }
let previousStart: MaybeNumber = null; let lastEntry: PlayableEvent | undefined; // used by indicators
let previousEnd: MaybeNumber = null; let thisEntry: PlayableEvent | undefined;
let previousEventId: string | undefined; let previousEventId: string | undefined;
let thisStart: MaybeNumber = null;
let thisEnd: MaybeNumber = null;
let thisId = previousEventId; let thisId = previousEventId;
let eventIndex = 0; let eventIndex = 0;
@@ -235,13 +286,14 @@ export default function Rundown({ data }: RundownProps) {
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
// event indexes are 1 based in frontend // event indexes are 1 based in frontend
eventIndex++; eventIndex++;
previousStart = thisStart;
previousEnd = thisEnd;
previousEventId = thisId; previousEventId = thisId;
lastEntry = thisEntry;
if (!event.skip) { if (isPlayableEvent(event)) {
thisStart = event.timeStart; // populate previous entry
thisEnd = event.timeEnd; if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
thisEntry = event;
}
thisId = eventId; thisId = eventId;
} }
} }
@@ -268,8 +320,8 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded} loaded={isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} isNext={isNext}
previousStart={previousStart} previousStart={lastEntry?.timeStart}
previousEnd={previousEnd} previousEnd={lastEntry?.timeEnd}
previousEventId={previousEventId} previousEventId={previousEventId}
playback={isLoaded ? featureData.playback : undefined} playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -32,8 +32,8 @@ interface RundownEntryProps {
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
previousStart: MaybeNumber; previousStart?: number;
previousEnd: MaybeNumber; previousEnd?: number;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
@@ -8,7 +8,7 @@ import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -36,8 +36,8 @@ interface EventBlockProps {
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
previousStart: MaybeNumber; previousStart?: number;
previousEnd: MaybeNumber; previousEnd?: number;
colour: string; colour: string;
isPast: boolean; isPast: boolean;
isNext: boolean; isNext: boolean;
@@ -1,5 +1,13 @@
import { MaybeNumber } from 'ontime-types'; import {
import { checkIsNextDay, dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils'; calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined { export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return; if (!delay) return;
@@ -10,31 +18,24 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
return `New start ${timeTag}`; return `New start ${timeTag}`;
} }
export function formatOverlap( export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
previousStart: MaybeNumber, const noPreviousElement = previousEnd === undefined || previousStart === undefined;
previousEnd: MaybeNumber,
timeStart: number,
): string | undefined {
const noPreviousElement = previousEnd === null || previousStart === null;
if (noPreviousElement) return; if (noPreviousElement) return;
const overlap = previousEnd - timeStart; const normalisedDuration = calculateDuration(previousStart, previousEnd);
if (overlap === 0) return; const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
if (timeFromPrevious === 0) return;
const previousCrossMidnight = previousStart > previousEnd; if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const isNextDay = previousCrossMidnight const previousCrossMidnight = previousStart > previousEnd;
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
: checkIsNextDay(previousStart, timeStart);
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd; const gap = dayInMs - normalisedPreviousEnd + timeStart;
if (isNextDay) {
const gap = dayInMs - correctedPreviousEnd + timeStart;
if (gap === 0) return; if (gap === 0) return;
const gapString = removeLeadingZero(millisToString(Math.abs(gap))); const gapString = formatDuration(Math.abs(gap), false);
return `Gap ${gapString} (next day)`; return `Gap ${gapString} (next day)`;
} }
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap))); const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
return `${overlap > 0 ? 'Overlap' : 'Gap'} ${overlapString}`; return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
} }
@@ -1,20 +1,18 @@
import { MaybeNumber } from 'ontime-types';
import { formatDelay, formatOverlap } from './EventBlock.utils'; import { formatDelay, formatOverlap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss'; import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps { interface RundownIndicatorProps {
timeStart: number; timeStart: number;
previousStart: MaybeNumber; previousStart?: number;
previousEnd: MaybeNumber; previousEnd?: number;
delay: number; delay: number;
} }
export default function RundownIndicators(props: RundownIndicatorProps) { export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props; const { timeStart, previousStart, previousEnd, delay } = props;
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart); const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
const hasDelay = formatDelay(timeStart, delay); const hasDelay = formatDelay(timeStart, delay);
return ( return (
@@ -16,47 +16,63 @@ describe('formatOverlap()', () => {
const previousStart = 0; const previousStart = 0;
const previousEnd = 60000; // 1 min const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec const timeStart = 30000; // 30 sec
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 0:30'); expect(result).toEqual('Overlap 30s');
}); });
it('bug #949 recognises an overlap between two times', () => { it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00 const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00 const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00 const timeStart = 48300000; // 13:25:00
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5:00'); expect(result).toEqual('Overlap 5m');
}); });
it('handles events the day after, without overlap', () => { it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR; const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR; const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR; const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18:00:00 (next day)'); expect(result).toBe('Gap 18h (next day)');
}); });
it('handles events the day after, with gap', () => { it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR; const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR; const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR; const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10:00:00 (next day)'); expect(result).toBe('Gap 10h (next day)');
}); });
it('handles events the day after, with previous ending at midnight', () => { it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00 const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00 const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00 const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 01:00:00 (next day)'); expect(result).toBe('Gap 1h (next day)');
}); });
it('handles events the day after, with previous ending over midnight', () => { it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR; const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR; const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR; const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart); const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 01:00:00'); expect(result).toBe('Gap 1h');
}); });
}); });
@@ -1,29 +1,12 @@
import { MaybeNumber } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math'; import { getProgress } from '../../../../common/utils/getProgress';
import style from './EventBlockProgressBar.module.scss'; import style from './EventBlockProgressBar.module.scss';
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
}
if (remaining <= 0) {
return 100;
}
if (remaining === total) {
return 0;
}
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar() { export default function EventBlockProgressBar() {
const timer = useTimer(); const timer = useTimer();
const progress = `${getPercentComplete(timer.current, timer.duration)}%`; const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: progress }} />;
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -1,27 +0,0 @@
import { dayInMs } from 'ontime-utils';
import { getPercentComplete } from '../EventBlockProgressBar';
describe('getPercentComplete()', () => {
describe('calculates progress in normal cases', () => {
const testScenarios = [
{ current: 0, duration: 0, expect: 100 },
{ current: 0, duration: 100, expect: 100 },
{ current: 0, duration: dayInMs, expect: 100 },
{ current: 10, duration: 100, expect: 90 },
{ current: 50, duration: 100, expect: 50 },
{ current: 100, duration: 100, expect: 0 },
];
testScenarios.forEach((testCase) => {
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
const progress = getPercentComplete(testCase.current, testCase.duration);
expect(progress).toBe(testCase.expect);
});
});
});
it('is 0 if we dont have a current or duration', () => {
const progress = getPercentComplete(null, null);
expect(progress).toBe(0);
});
});
@@ -28,6 +28,7 @@
} }
td:nth-child(even) { td:nth-child(even) {
text-align: right; text-align: right;
white-space: nowrap;
} }
} }
} }
@@ -24,6 +24,18 @@ function EventEditorEmpty() {
<Kbd></Kbd> <Kbd></Kbd>
</td> </td>
</tr> </tr>
<tr>
<td>Select block</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr> <tr>
<td>Deselect entry</td> <td>Deselect entry</td>
<td> <td>
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextInputProps extends InputProps { interface EventTextInputProps extends InputProps {
field: EditorUpdateFields; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function EventTextInput(props: CountedTextInputProps) { export default function EventTextInput(props: EventTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props; const { field, label, initialValue, submitHandler, maxLength } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
variant='ontime-filled' variant='ontime-filled'
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 50} maxLength={maxLength || 100}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -5,6 +5,7 @@ import {
Message, Message,
OntimeEvent, OntimeEvent,
ProjectData, ProjectData,
Runtime,
Settings, Settings,
SupportedEvent, SupportedEvent,
TimerMessage, TimerMessage,
@@ -36,8 +37,9 @@ type WithDataProps = {
publicEventNext: OntimeEvent | null; publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null;
publicSelectedId: string | null; publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null; selectedId: string | null;
settings: Settings | undefined; settings: Settings | undefined; // TODO: what is the case for this being undefined?
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings; viewSettings: ViewSettings;
}; };
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]); }, [rundownData]);
// websocket data // websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } = const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
useStore(runtimeStore); useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null; const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null; const selectedId = eventNow?.id ?? null;
@@ -108,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
publicEventNext={publicEventNext} publicEventNext={publicEventNext}
publicEventNow={publicEventNow} publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId} publicSelectedId={publicSelectedId}
runtime={runtime}
selectedId={selectedId} selectedId={selectedId}
settings={settings} settings={settings}
time={TimeManagerType} time={TimeManagerType}
@@ -11,7 +11,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import { getBackstageOptions } from '../../../common/components/view-params-editor/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';
@@ -23,8 +22,12 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getBackstageOptions } from './backstage.options';
import './Backstage.scss'; import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps { interface BackstageProps {
customFields: CustomFields; customFields: CustomFields;
isMirrored: boolean; isMirrored: boolean;
@@ -91,13 +94,12 @@ export default function Backstage(props: BackstageProps) {
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin }); let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer); stageTimer = removeLeadingZero(stageTimer);
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields); const backstageOptions = getBackstageOptions(defaultFormat, customFields);
return ( return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'> <div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor paramFields={backstageOptions} /> <ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'> <div className='project-header'>
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
@@ -108,8 +110,8 @@ export default function Backstage(props: BackstageProps) {
<ProgressBar <ProgressBar
className='progress-container' className='progress-container'
now={time.current ?? undefined} current={time.current}
complete={totalTime} duration={time.duration}
hidden={!showProgress} hidden={!showProgress}
/> />
@@ -124,7 +126,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible' animate='visible'
exit='exit' exit='exit'
> >
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} /> <TitleCard title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div> <div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
@@ -151,16 +153,17 @@ export default function Backstage(props: BackstageProps) {
<AnimatePresence> <AnimatePresence>
{eventNext && ( {eventNext && (
<motion.div <MotionTitleCard
className='event next' className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} /> title={eventNext.title}
</motion.div> secondary={secondaryTextNext}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types'; import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/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';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getClockOptions } from './clock.options';
import './Clock.scss'; import './Clock.scss';
interface ClockProps { interface ClockProps {
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
}} }}
data-testid='clock-view' data-testid='clock-view'
> >
<ViewParamsEditor paramFields={clockOptions} /> <ViewParamsEditor viewOptions={clockOptions} />
<SuperscriptTime <SuperscriptTime
time={clock} time={clock}
className='clock' className='clock'
@@ -0,0 +1,76 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -3,6 +3,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>; type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
@@ -68,11 +69,13 @@ export function getFormattedTimer(
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder; return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
} }
let timeToParse = timer; if (timerType === TimerType.Clock) {
return formatTime(timer);
}
let timeToParse = timer;
if (options.removeSeconds) { if (options.removeSeconds) {
const timerIsTimeOfDay = timerType === TimerType.Clock; const isNegative = timeToParse < -MILLIS_PER_SECOND && timerType !== TimerType.CountUp;
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
if (isNegative) { if (isNegative) {
// in negative numbers, we need to round down // in negative numbers, we need to round down
timeToParse -= MILLIS_PER_MINUTE; timeToParse -= MILLIS_PER_MINUTE;
@@ -21,11 +21,11 @@
flex-direction: column; flex-direction: column;
&__title { &__title {
font-size: clamp(24px, 2vw, 32px); font-size: clamp(1.5rem, 2vw, 2rem);
} }
&__events { &__events {
font-size: clamp(16px, 1.5vw, 24px); font-size: clamp(1rem, 1.5vw, 1.5rem);
margin-top: 1em; margin-top: 1em;
overflow-y: auto; overflow-y: auto;
height: 70vh; height: 70vh;
@@ -38,18 +38,18 @@
.countdown-container { .countdown-container {
height: 100%; height: 100%;
width: 100%; width: 100%;
gap: min(2vh, 16px); gap: min(2vh, 1rem);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px); padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
display: grid; display: grid;
grid-template-rows: auto auto auto auto 1fr; grid-template-rows: auto auto auto auto 1fr;
grid-template-columns: 100%; grid-template-columns: 100%;
grid-template-areas: grid-template-areas:
'header' 'header'
'status' 'status'
'clock' 'clock'
'title' 'title'
'timers'; 'timers';
/* =================== HEADER + EXTRAS ===================*/ /* =================== HEADER + EXTRAS ===================*/
@@ -59,13 +59,13 @@
font-weight: 600; font-weight: 600;
.label { .label {
font-size: clamp(16px, 1.5vw, 24px); font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.time { .time {
font-size: clamp(32px, 3.5vw, 50px); font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em; line-height: 0.95em;
@@ -75,7 +75,7 @@
.status { .status {
grid-area: status; grid-area: status;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(2rem, 3.5vw, 3.5rem);
font-weight: 600; font-weight: 600;
} }
@@ -105,7 +105,7 @@
.title { .title {
grid-area: title; grid-area: title;
background-color: var(--card-background-color-override, $viewer-card-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px; padding: 1rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
@@ -119,28 +119,49 @@
.timer-group { .timer-group {
grid-area: timers; grid-area: timers;
display: flex; display: grid;
justify-content: space-evenly; grid-template-areas:
align-items: flex-end; 'projected-start projected-end'
'scheduled-start scheduled-end';
grid-template-columns: 1fr 1fr;
justify-items: center;
text-align: center;
row-gap: clamp(1rem, 5vh, 4rem);
align-self: flex-end;
height: fit-content;
.aux-timers { &__projected-start {
text-align: center; grid-area: projected-start;
font-size: clamp(24px, 1.75vw, 32px); }
&__label { &__projected-end {
color: var(--label-color-override, $viewer-label-color); grid-area: projected-end;
text-transform: uppercase; }
}
&__value { &__scheduled-start {
font-size: clamp(32px, 3.5vw, 50px); grid-area: scheduled-start;
color: var(--secondary-color-override, $viewer-secondary-color); }
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed { &__scheduled-end {
color: $delay-color; grid-area: scheduled-end;
} }
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
margin-top: 0.5rem;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed {
color: $delay-color;
} }
} }
} }
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types'; import {
import { millisToString, removeLeadingZero } from 'ontime-utils'; OntimeEvent,
OntimeRundownEntry,
Playback,
Runtime,
Settings,
SupportedEvent,
TimerPhase,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/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';
@@ -12,8 +19,10 @@ import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { fetchTimerData, TimerMessage } from './countdown.helpers'; import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import './Countdown.scss'; import './Countdown.scss';
@@ -21,27 +30,26 @@ import './Countdown.scss';
interface CountdownProps { interface CountdownProps {
isMirrored: boolean; isMirrored: boolean;
backstageEvents: OntimeEvent[]; backstageEvents: OntimeEvent[];
time: ViewExtendedTimer; runtime: Runtime;
selectedId: string | null; selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
} }
export default function Countdown(props: CountdownProps) { export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props; const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [follow, setFollow] = useState<OntimeEvent | null>(null); const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
useWindowTitle('Countdown'); useWindowTitle('Countdown');
// eg. http://localhost:4001/countdown?eventId=ei0us // eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options // update data to the event we are following
useEffect(() => { useEffect(() => {
if (!backstageEvents) { if (!backstageEvents) {
return; return;
@@ -50,6 +58,12 @@ export default function Countdown(props: CountdownProps) {
const eventId = searchParams.get('eventid'); const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event'); const eventIndex = searchParams.get('event');
// if there is no event selected, we reset the data
if (!eventId && !eventIndex) {
setFollow(null);
return;
}
let followThis: OntimeEvent | null = null; let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event); const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
@@ -66,49 +80,50 @@ export default function Countdown(props: CountdownProps) {
} }
}, [backstageEvents, searchParams]); }, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
return null; return null;
} }
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id; const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt; const finished = time.phase === TimerPhase.Overtime;
const isRunningFinished = finished && runningMessage === TimerMessage.running; const isRunningFinished = finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : ''; const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock); const clock = formatTime(time.clock);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay); const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay); follow?.timeStart,
follow?.timeEnd,
delay,
runtime.offset,
);
const formatTimer = (): string => { const hideSeconds = searchParams.get('hideTimerSeconds');
if (runningMessage === TimerMessage.ended) { const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' }); removeSeconds: isStringBoolean(hideSeconds),
removeLeadingZero: false,
});
const persistParam = () => {
const eventId = searchParams.get('eventid');
if (eventId !== null) {
return { id: 'eventid', value: eventId };
} }
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay); const eventIndex = searchParams.get('event');
if (isSelected || runningMessage === TimerMessage.waiting) { if (eventIndex !== null) {
formattedTime = removeLeadingZero(formattedTime); return { id: 'eventindex', value: eventIndex };
} }
return formattedTime; return undefined;
}; };
const formattedTimer = formatTimer();
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timeOption = getCountdownOptions(defaultFormat); const viewOptions = getCountdownOptions(defaultFormat, persistParam());
return ( return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'> <div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor paramFields={timeOption} /> <ViewParamsEditor viewOptions={viewOptions} />
{follow === null ? ( {follow === null ? (
<CountdownSelect events={backstageEvents} /> <CountdownSelect events={backstageEvents} />
) : ( ) : (
@@ -129,13 +144,25 @@ export default function Countdown(props: CountdownProps) {
{follow?.title && <div className='title'>{follow.title}</div>} {follow?.title && <div className='title'>{follow.title}</div>}
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> {projectedStart && projectedEnd && (
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div> <div className='timer-group__projected-start'>
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} /> <div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
{projectedStart && projectedEnd && (
<div className='timer-group__projected-end'>
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
<div className='timer-group__scheduled-start'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div> </div>
<div className='aux-timers'> <div className='timer-group__scheduled-end'>
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div> <div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} /> <SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { OntimeEvent, Playback } from 'ontime-types'; import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage { export enum TimerMessage {
toStart = 'to_start', toStart = 'to_start',
@@ -20,48 +22,85 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
*/ */
export const fetchTimerData = ( export const fetchTimerData = (
time: ViewExtendedTimer, time: ViewExtendedTimer,
follow: OntimeEvent, follow: OntimeEvent | null,
selectedId: string | null, selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => { ): { message: TimerMessage; timer: number } => {
let message; if (follow === null) {
let timer; return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) { if (selectedId === follow.id) {
// check that is not running // if it is selected, it may not be running
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running; return {
timer = time.current ?? 0; message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
} else if (time.clock < follow.timeStart) { timer: time.current ?? 0,
// if it hasnt started, we count to start };
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// running timer timer is not the one we are following
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} }
return { message, timer };
const showProjected = getShouldShowProjected();
const addedTime = showProjected ? offset : 0;
if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// running timer timer is not the one we are following
// ends day after
if (follow.timeStart > follow.timeEnd) {
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}; };
/**
* Gets values for the timer items
*/
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
if (start == null || end == null) {
return {
scheduledStart: '',
scheduledEnd: '',
projectedStart: '',
projectedEnd: '',
};
}
const showProjected = getShouldShowProjected();
const scheduledStart = formatTime(start + delay);
const scheduledEnd = formatTime(end + delay);
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
return {
scheduledStart,
scheduledEnd,
projectedStart,
projectedEnd,
};
}
/**
* Gets from the URL whether the showProjected option is active
*/
function getShouldShowProjected() {
const params = new URL(document.location.href).searchParams;
return isStringBoolean(params.get('showProjected'));
}
@@ -0,0 +1,29 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
const makePersistedField = (id: string, value: string): ViewOption => {
return {
id,
title: 'Used to keep the selection on submit',
description: 'Used to keep the selection on submit',
type: 'persist',
value,
};
};
type Persisted = { id: string; value: string };
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'View behaviour' },
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
];
@@ -72,6 +72,8 @@
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, left);
white-space: nowrap; white-space: nowrap;
@include ellipsis-text();
&::after { &::after {
content: '\200b'; content: '\200b';
} }
@@ -3,12 +3,13 @@ import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types'; import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/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 { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions } from './lowerThird.options';
import './LowerThird.scss'; import './LowerThird.scss';
type LowerOptions = { type LowerOptions = {
@@ -182,7 +183,7 @@ export default function LowerThird(props: LowerProps) {
return ( return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}> <div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} /> <ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div <div
className={`container container--${playState}`} className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }} style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
@@ -0,0 +1,120 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{ section: 'Data sources' },
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{ section: 'View style override' },
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types'; import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/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';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils'; import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
import './MinimalTimer.scss'; import './MinimalTimer.scss';
interface MinimalTimerProps { interface MinimalTimerProps {
@@ -161,7 +162,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
}} }}
data-testid='minimal-timer' data-testid='minimal-timer'
> >
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} /> <ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
{showEndMessage ? ( {showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div> <div className='end-message'>{viewSettings.endMessage}</div>
) : ( ) : (
@@ -0,0 +1,91 @@
import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -43,7 +43,7 @@
.time { .time {
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600; color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em; line-height: 0.95em;
} }
@@ -8,7 +8,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import { getPublicOptions } from '../../../common/components/view-params-editor/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';
@@ -19,8 +18,12 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getPublicOptions } from './public.options';
import './Public.scss'; import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps { interface BackstageProps {
customFields: CustomFields; customFields: CustomFields;
isMirrored: boolean; isMirrored: boolean;
@@ -71,7 +74,7 @@ export default function Public(props: BackstageProps) {
return ( return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'> <div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<ViewParamsEditor paramFields={publicOptions} /> <ViewParamsEditor viewOptions={publicOptions} />
<div className='project-header'> <div className='project-header'>
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
@@ -83,31 +86,33 @@ export default function Public(props: BackstageProps) {
<div className='now-container'> <div className='now-container'>
<AnimatePresence> <AnimatePresence>
{publicEventNow && ( {publicEventNow && (
<motion.div <MotionTitleCard
className='event now' className='event now'
key='now' key='now'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='now'
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} /> title={publicEventNow.title}
</motion.div> secondary={secondaryTextNow}
/>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{publicEventNext && ( {publicEventNext && (
<motion.div <MotionTitleCard
className='event next' className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} /> title={publicEventNext.title}
</motion.div> secondary={secondaryTextNext}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -42,6 +42,11 @@ $orange-active: #f60;
grid-template-areas: 'clock schedule'; grid-template-areas: 'clock schedule';
text-transform: uppercase; text-transform: uppercase;
&.hide-right {
grid-template-columns: 1fr;
grid-template-areas: 'clock';
}
.clock-container { .clock-container {
grid-area: clock; grid-area: clock;
display: grid; display: grid;
@@ -168,7 +173,6 @@ $orange-active: #f60;
display: flex; display: flex;
align-items: start; align-items: start;
gap: 0.5em; gap: 0.5em;
white-space: nowrap;
&--now { &--now {
color: var(--studio-active-label, $red-active); color: var(--studio-active-label, $red-active);
@@ -1,20 +1,19 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types'; import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText'; import useFitText from '../../../common/hooks/useFitText';
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 { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils'; import { isStringBoolean } from '../common/viewUtils';
import { secondsInMillis, trimRundown } from './studioClock.utils'; import { getStudioClockOptions } from './studioClock.options';
import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss'; import './StudioClock.scss';
@@ -23,8 +22,8 @@ interface StudioClockProps {
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
time: ViewExtendedTimer; time: ViewExtendedTimer;
backstageEvents: OntimeRundown; backstageEvents: OntimeRundown;
selectedId: string | null; selectedId: MaybeString;
nextId: string | null; nextId: MaybeString;
onAir: boolean; onAir: boolean;
viewSettings: ViewSettings; viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
@@ -36,18 +35,15 @@ export default function StudioClock(props: StudioClockProps) {
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts? // TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText) // deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 }); const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
// TODO: fit titles on screen
const MAX_TITLES = 11;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useWindowTitle('Studio Clock'); useWindowTitle('Studio Clock');
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
let clock = formatTime(time.clock); let clock = formatTime(time.clock);
let hasAmPm = ''; let hasAmPm = '';
if (clock.includes('AM')) { if (clock.includes('AM')) {
@@ -65,8 +61,7 @@ export default function StudioClock(props: StudioClockProps) {
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioClockOptions = getStudioClockOptions(defaultFormat); const studioClockOptions = getStudioClockOptions(defaultFormat);
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[]; const hideRight = isStringBoolean(searchParams.get('hideRight'));
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
let timer = millisToString(time.current, { fallback: '---' }); let timer = millisToString(time.current, { fallback: '---' });
const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds')); const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds'));
if (time.current != null && hideSeconds) { if (time.current != null && hideSeconds) {
@@ -74,8 +69,11 @@ export default function StudioClock(props: StudioClockProps) {
} }
return ( return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'> <div
<ViewParamsEditor paramFields={studioClockOptions} /> className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
data-testid='studio-view'
>
<ViewParamsEditor viewOptions={studioClockOptions} />
<div className='clock-container'> <div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>} {hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div> <div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
@@ -114,31 +112,9 @@ export default function StudioClock(props: StudioClockProps) {
))} ))}
</div> </div>
</div> </div>
<div className='schedule-container'> {!hideRight && (
<div <StudioClockSchedule rundown={backstageEvents} selectedId={selectedId} nextId={nextId} onAir={onAir} />
className={onAir ? 'onAir' : 'onAir onAir--idle'} )}
data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}
>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,50 @@
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { trimRundown } from './studioClock.utils';
import './StudioClock.scss';
interface StudioClockScheduleProps {
rundown: OntimeRundown;
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
}
// TODO: fit titles on screen
const MAX_TITLES = 11;
export default function StudioClockSchedule(props: StudioClockScheduleProps) {
const { rundown, selectedId, nextId, onAir } = props;
const delayed = rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
return (
<div className='schedule-container'>
<div className={onAir ? 'onAir' : 'onAir onAir--idle'} data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -1,6 +1,6 @@
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { secondsInMillis, trimRundown } from '../studioClock.utils'; import { trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => { describe('test trimEventlist function', () => {
const limit = 8; const limit = 8;
@@ -118,14 +118,3 @@ describe('test trimEventlist function', () => {
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
}); });
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
@@ -0,0 +1,17 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../../common/components/view-params-editor/types';
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideRight',
title: 'Hide right section',
description: 'Hides the right section with On Air indicator and the schedule',
type: 'boolean',
defaultValue: false,
},
];
@@ -1,5 +1,4 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/** /**
* @description Returns trimmed event list array * @description Returns trimmed event list array
@@ -20,15 +19,3 @@ export function trimRundown(rundown: OntimeEvent[], selectedId: string | null, l
const trimmedRundown = rundown.slice(startIndex, endIndex); const trimmedRundown = rundown.slice(startIndex, endIndex);
return trimmedRundown; return trimmedRundown;
} }
/**
* @description Returns amount of seconds in a date given in milliseconds. For studio clock second indicator
* @param {MaybeNumber} millis time to format
* @returns amount of elapsed seconds
*/
export function secondsInMillis(millis: MaybeNumber): number {
if (!millis) {
return 0;
}
return Math.floor((millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
}
@@ -0,0 +1,112 @@
@use '../../../theme/viewerDefs' as *;
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;
.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
background-color: $ui-black;
}
.timelineEvents {
position: relative;
height: 100%;
}
.column {
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);
// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}
.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}
.timeOverview {
opacity: 0;
}
}
.hide {
// hide text elements
& > div {
display: none;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 0.25rem;
padding-inline-start: 0.25rem;
overflow: hidden;
line-height: 1rem;
background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
}
.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
.status {
color: $active-red;
}
}
&[data-status='future'] {
.status {
color: $green-500;
}
}
}
.cross {
text-decoration: line-through;
}
@@ -0,0 +1,95 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import TimelineProgressBar from './timeline-progress-bar/TimelineProgressBar';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
rundown: OntimeRundown;
selectedEventId: string | null;
totalDuration: number;
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { firstStart, rundown, selectedEventId, totalDuration } = props;
const { width: screenWidth } = useViewportSize();
if (totalDuration === 0) {
return null;
}
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let elapsedDays = 0;
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<TimelineProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}
// we only need to check for next day if we have a previous event
if (
previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
}
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);
// prepare values for next iteration
previousEventStartTime = normalisedStart;
return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // dataset solves issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
export type ProgressStatus = 'done' | 'live' | 'future';
interface TimelineEntryProps {
colour: string;
delay: number;
duration: number;
left: number;
status: ProgressStatus;
start: number;
title: string;
width: number;
}
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntryProps) {
const { colour, delay, duration, left, status, start, title, width } = props;
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const delayedStart = start + delay;
const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25;
return (
<div
className={columnClasses}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
}}
>
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{showTitle && <div>{title}</div>}
</div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && (
<>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</>
)}
</div>
</div>
);
}
interface TimelineEntryStatusProps {
status: ProgressStatus;
start: number;
}
// we isolate this component to avoid isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { status, start } = props;
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
}
return <div className={style.status}>{statusText}</div>;
}
@@ -0,0 +1,101 @@
@use '../../../theme/viewerDefs' as *;
.timeline {
width: 100vw;
height: 100vh;
padding-top: 0.5rem;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: flex;
flex-direction: column;
gap: 2rem;
.project-header {
padding-inline: 2rem;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
justify-content: space-between;
}
.clock-container {
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.title-grid {
display: grid;
grid-template-columns: 2fr 3fr;
row-gap: 1rem;
column-gap: 2rem;
grid-template-areas:
'now next'
'now following';
padding-inline: 2rem;
}
.section {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 0.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-radius: $element-border-radius;
}
.section--now {
grid-area: now;
}
.section-title {
line-height: 1em;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.section-title__label {
text-transform: uppercase;
}
.section-title__status {
color: $green-500;
}
.section-content {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
}
.section-content--now {
color: $red-500;
}
.section-content--next {
color: $green-500;
}
.section-content--subdue {
opacity: $opacity-disabled;
}
}
@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
import './TimelinePage.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
general: ProjectData;
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
/**
* since we inherit from viewPage
* which refreshes at least once a second
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
useWindowTitle('Timeline');
if (!shouldRender) {
return null;
}
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat);
const titleNow = now?.title ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-';
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
return (
<div className='timeline'>
<ViewParamsEditor viewOptions={progressOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
<div className='title-grid'>
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
<Section title={getLocalizedString('common.next')} status={nextStatus} content={nextText} category='next' />
<Section
title={getLocalizedString('timeline.followedby')}
status={followedByStatus}
content={followedByText}
category='next'
/>
</div>
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedId}
totalDuration={totalDuration}
/>
</div>
);
}
@@ -0,0 +1,66 @@
import { dayInMs } from 'ontime-utils';
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
});
});
describe('makeTmelineSections', () => {
it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17);
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
});
it('wraps around midnight', () => {
const result = makeTimelineSections(22, 26);
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
});
});
@@ -0,0 +1,16 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
margin-bottom: 0.25rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid $white-7;
height: 100vh;
}
}
@@ -0,0 +1,22 @@
import { makeTimelineSections } from '../timeline.utils';
import style from './TimelineMarkers.module.scss';
interface TimelineMarkersProps {
startHour: number;
endHour: number;
}
export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props;
const elements = makeTimelineSections(startHour, endHour);
return (
<div className={style.markers}>
{elements.map((tag, index) => {
return <span key={`${index}-${tag}`}>{tag}</span>;
})}
</div>
);
}
@@ -0,0 +1,19 @@
.progressBar {
width: 100%;
height: 1rem;
position: relative;
background-color: $gray-1000;
}
.progress {
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 2;
background-color: $active-red;
transition-duration: 0.3s;
transition-property: width;
}

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