mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad8f6dfcc7 | |||
| 4706ed39f6 | |||
| a8182cac8c | |||
| f9a5c55c07 | |||
| 980ae7a7c5 | |||
| db8ed93d41 | |||
| f8ca5b9cef | |||
| b9db08089f | |||
| a3020ef3b2 | |||
| 2492972097 | |||
| aa4ee546ec | |||
| 0abaf7724d | |||
| 7b932ef0f7 | |||
| 8bd7645c95 | |||
| 81f0e61953 | |||
| 3877e207d0 | |||
| d78e8838c3 | |||
| 16c7107069 | |||
| e20cd87673 | |||
| db5fbd8019 | |||
| 25aefe322b | |||
| 73db14c383 | |||
| f5936e5254 | |||
| 25c7915cf7 | |||
| c43b6f37f9 | |||
| 6e5034ef72 | |||
| 66774321a5 | |||
| d71df886f2 | |||
| abcee741e2 | |||
| ee1b5b7fdd | |||
| 1b1823e0fe | |||
| 340f5478fb | |||
| f708dc0bc4 | |||
| 49467c66b7 | |||
| b539b2ecd5 | |||
| dc06a9afc3 | |||
| d3620b52f4 | |||
| 66ed92e215 | |||
| 347f333b3e | |||
| c3fb3061f6 | |||
| a26aa9e01c | |||
| da11ef64c2 | |||
| 3012d73285 | |||
| bad491063d | |||
| a103f8b347 | |||
| dffccd258f | |||
| ce8b0d9850 | |||
| e500fa6b5e | |||
| 67be4a5631 | |||
| c7893938eb | |||
| 158ef05ff0 | |||
| 20838c038a | |||
| 1ddcec993a | |||
| 91d6adf8e0 | |||
| a7fe5eceef | |||
| e6e00c0d4a | |||
| edc7f20d7a | |||
| cb773ded9f | |||
| 2f65711078 | |||
| 3056b75960 | |||
| dd9423abf4 | |||
| dee6a67018 | |||
| a79fb4ecbb | |||
| a53a1f87be | |||
| ef9afcbf3e | |||
| 33b04e01c9 | |||
| c5e821df35 | |||
| 0fd71c9a00 | |||
| 53b453abc8 | |||
| fe0665506e | |||
| 9bd600c4b1 | |||
| bbd4254637 | |||
| 2603c777ef | |||
| 8942a4ba4f | |||
| f97be749dc | |||
| 5836d5ffc7 | |||
| 91f21fdeb1 | |||
| 844eec132b | |||
| adc4b3f8bf | |||
| fb09631db9 | |||
| 19febb98a5 | |||
| 6bfa11dd8d | |||
| 4758bc84b5 | |||
| 917b182e39 | |||
| e6c3322321 | |||
| 00d34eec9b | |||
| 84792bc00d | |||
| dae32e89a3 | |||
| 367997fd16 | |||
| 751c3329f0 | |||
| 9c5e403b18 | |||
| c944e77bc1 | |||
| d94c1d4252 | |||
| 9f395cb16d | |||
| 46195fc043 | |||
| c9ef8d55c1 | |||
| ad0e821cc0 | |||
| 21454947e0 | |||
| ad69c0ff80 | |||
| 20d9df2501 | |||
| de9af5aaa2 | |||
| 61280b06b7 | |||
| ce4a48cd8d | |||
| 2877475a35 | |||
| 73e23ee323 | |||
| 938c5fbb4c | |||
| e90e94828d | |||
| 1f0401632e | |||
| 5c3989c9b6 | |||
| 6ad26f06c7 | |||
| a63fa68d39 | |||
| 20503de6fd | |||
| 0335da9786 | |||
| 9f1a64f53c | |||
| 975356f4f4 | |||
| 1b448835cd | |||
| cbf3d566b9 | |||
| 6c20cb5e99 | |||
| cfc3aab893 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm lint-staged
|
|
||||||
+2
-2
@@ -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
@@ -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,6 +1,7 @@
|
|||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
[](https://github.com/sponsors/cpvalente)
|
[](https://github.com/sponsors/cpvalente)
|
||||||
[](https://www.buymeacoffee.com/cpvalente)
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+1
-1
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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}%` }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
-27
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
@@ -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
Reference in New Issue
Block a user