Compare commits

...

83 Commits

Author SHA1 Message Date
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
Carlos Valente c20ea88021 docs: add link to subreddit 2025-11-20 18:44:23 +01:00
Carlos Valente ce2b2b9606 bump version to 4.2.0 2025-11-20 18:44:23 +01:00
Alex Christoffer Rasmussen f223766445 feat: pip for timer preview (#1862)
---------

Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-11-20 18:00:16 +01:00
Carlos Valente 3dfb49e63f refactor(overview): prevent vertical scrollbar 2025-11-20 17:58:53 +01:00
Carlos Valente a13d3dad93 refactor(op): style tweaks to tags and subscriptions 2025-11-20 17:58:53 +01:00
Carlos Valente fa9695873a refactor(timeline): style tweaks on status tags 2025-11-20 17:58:53 +01:00
Carlos Valente 70ce1d5553 feat(projects): allow sorting project table 2025-11-19 06:52:58 +01:00
Shobhit Nagpal 9974ce24cd refactor(operator): hide groups where all events have passed (#1878) 2025-11-18 06:12:02 +01:00
Carlos Valente 93e9c19e0d refactor(input): consistent placeholder colours 2025-11-16 07:44:49 +01:00
Carlos Valente 1694cca92c refactor(settings): improve error message on version fetch 2025-11-16 07:44:49 +01:00
Carlos Valente c1d9276dd5 refactor(timeline): style tweaks to small screens 2025-11-16 07:44:49 +01:00
Carlos Valente f5753a0f5a refactor(timeline): default to fixed size timeline 2025-11-16 07:44:49 +01:00
Carlos Valente 843f5a968d refactor(operator): style tweaks for small screens 2025-11-16 07:44:49 +01:00
Carlos Valente e09b6bbc16 refactor: close menu in small screen navigation 2025-11-16 07:44:49 +01:00
Carlos Valente 19d6b40bdb refactor: hide fullscreen button if not feature available 2025-11-16 07:44:49 +01:00
Carlos Valente 1f2706a063 refactor(operator): improvements to editing 2025-11-16 07:44:49 +01:00
Carlos Valente aaeadd13cf chore(operator): clarify edit prompt 2025-11-16 07:44:49 +01:00
Carlos Valente 72e3eb11d7 chore: upgrade mantine dependencies 2025-11-16 07:44:49 +01:00
Alex Christoffer Rasmussen 6f55d8a6c9 use data in <base> componenet to mark ontime cloud status (#1872) 2025-11-12 19:28:29 +08:00
Shobhit Nagpal 70846aefbe refactor: time input labels on hover (#1855) 2025-11-11 06:42:55 +01:00
Carlos Valente 47ba90f191 refactor: ensure preset names are url safe 2025-11-11 06:41:48 +01:00
Alex Christoffer Rasmussen cee3c9c070 Fix integration api (#1866)
* allow change api to set flag and countToEnd

* fix: custom fields can be uppercase

* check that prop exists and add target duration for group
2025-11-09 14:36:13 +01:00
Carlos Valente 15f3b9625c refactor: review overview styles 2025-11-09 06:44:24 +01:00
Carlos Valente d34a1f0fd8 chore: improve documentation on the 12 hour view 2025-11-09 06:44:24 +01:00
Carlos Valente 0e3b3bcf9a refactor: consistent time formatting for 12h mode 2025-11-09 06:44:24 +01:00
Carlos Valente 50c91f976b refactor: show welcome modal on connect 2025-11-02 18:58:13 +01:00
Carlos Valente 50599eccd2 refactor: show countdown if less than 10 min from start 2025-11-02 18:53:03 +01:00
Carlos Valente 9f2db10548 fix: prevent rounding error show 60s 2025-11-02 18:53:03 +01:00
Carlos Valente 5dce1a40bc refactor: prevent ids from being overriden 2025-11-02 13:27:53 +01:00
Carlos Valente b75b69c3b1 chore: cleanup directory structure 2025-11-02 13:23:05 +01:00
Carlos Valente e6070e6efd refactor: add rundown summary to import preview 2025-11-02 13:23:05 +01:00
Carlos Valente 3918664945 fix: ensure IDs on import from excel 2025-11-02 13:23:05 +01:00
Carlos Valente b65b3fa6d2 refactor: get worksheet names from excel upload 2025-11-02 13:23:05 +01:00
Carlos Valente 162693e19e refactor: allow user to review import map 2025-11-02 13:23:05 +01:00
Carlos Valente becd734ea8 refactor: simplify ux for sheet import 2025-11-02 13:23:05 +01:00
Carlos Valente 9dfc8083f0 style: run code formatting 2025-10-31 19:30:34 +01:00
Carlos Valente 7bd98c23a4 bump version to 4.1.0 2025-10-31 19:30:34 +01:00
arc-alex 6f19d78e53 addedTimer always updates the restore point 2025-10-31 19:26:30 +01:00
arc-alex 163baa752f keep restorpoint on container shutdown 2025-10-31 19:26:30 +01:00
mdavies1 6f708df6f6 feat: lock extracted rundown panels 2025-10-31 19:23:08 +01:00
Carlos Valente eb5c62cca1 chore: simplify prettier setup 2025-10-30 17:26:49 +01:00
Carlos Valente 99d2debca8 typo: incorrect title in delete confirmation 2025-10-30 17:26:49 +01:00
Carlos Valente 74e59dccdb feat: allow renaming rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 812b17a221 refactor: create destructive menu type 2025-10-30 17:26:49 +01:00
Carlos Valente 32bf0f53f6 feat: allow user to duplicate a rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 17f80baf48 chore: improve rundown API documentation 2025-10-30 17:26:49 +01:00
Carlos Valente e0377a7244 feat: implement simple healthcheck endpoint 2025-10-30 17:26:18 +01:00
Alex Christoffer Rasmussen b7e5723fb2 Fix multiday (#1850)
* chore: cleanup typeings

* fix: total gap should only accumulate after the curent loaded

* fix: add the current day of the event to start day offset
2025-10-30 14:22:14 +01:00
Alex Christoffer Rasmussen 897210d5e3 add timer type for css override selector (#1845) 2025-10-30 13:15:14 +01:00
Alex Christoffer Rasmussen ee3d38fce8 Fix link to host in local network (#1843)
* descripe testing for cloud

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
Carlos Valente 37abd3fe47 chore(ci): prevent publishing resolver on release 2025-10-22 06:26:35 +02:00
Carlos Valente b509ee5b1f bump to version 4.0.2 2025-10-22 06:26:35 +02:00
Shobhit-Nagpal dd2b59efe5 fix: move blue line to pseudo element 2025-10-21 16:29:59 +02:00
Shobhit-Nagpal 5788785b3e fix: minor ui shift on quick add inline 2025-10-21 16:29:59 +02:00
Carlos Valente e488ad19b3 fix: ensure well formed URLs for IP address destinations 2025-10-18 15:29:16 +02:00
Carlos Valente cbc03c898c fix: correct alignment to end message 2025-10-18 15:28:47 +02:00
jwetzell 155bfbdc42 Merge pull request #1833 from cpvalente/fix/npm-action-access-error 2025-10-18 07:13:05 -05:00
jwetzell 5d3714a4fc Merge pull request #1836 from cpvalente/fix/use-flat-rundown 2025-10-18 07:12:39 -05:00
jwetzell a8a1d210d8 use flat order when building flat rundown 2025-10-17 21:46:13 -05:00
jwetzell 49de8b63a7 add setup-node step back to resolve npm access issues 2025-10-17 08:13:11 -05:00
Carlos Valente 03c9f51b47 chore: update demo project 2025-10-17 12:20:18 +02:00
Carlos Valente 8d00fd3966 bump version to 4.0.1 2025-10-17 12:20:18 +02:00
Carlos Valente 80fd21354e fix: apply styling to overtime timer 2025-10-16 21:13:26 +02:00
Carlos Valente a87b15825a fix: end message scaling 2025-10-16 21:13:26 +02:00
Carlos Valente 6a8f98c469 fix: end message only shows if timer is in overtime 2025-10-16 21:13:26 +02:00
jwetzell 8685298430 Merge pull request #1830 from cpvalente/fix/linux-project-download 2025-10-15 07:25:57 -05:00
jwetzell d43af2db57 pass options to project download to allow folders beginning with a dot 2025-10-14 19:51:15 -05:00
jwetzell ef718c48e4 Merge pull request #1828 from cpvalente/npm-trusted-publisher
update cli and resolver workflows for new NPM auth
2025-10-14 16:18:53 -05:00
Alex Christoffer Rasmussen 45e7373279 Small clean-up (#1827)
* chore: avoid shadow variable name

* chore: map could just be foreach

* clean uneeded template literal

* lint
2025-10-13 20:24:25 +02:00
jwetzell 6af2d6e021 update cli and resolver workflows for new NPM auth 2025-10-12 14:11:52 -05:00
Alex Christoffer Rasmussen 9b36f36f73 fix: next cue (#1821)
* fix next cue

* Update apps/server/src/services/runtime-service/runtime.service.ts

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-10-12 14:42:55 +02:00
Carlos Valente 3a0dbf057e chore: update docs 2025-10-12 07:48:25 +02:00
Carlos Valente dae84ab48a chore: update screenshots 2025-10-12 07:48:25 +02:00
160 changed files with 2599 additions and 900 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"overrides": [ "overrides": [
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+8 -4
View File
@@ -14,6 +14,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -45,10 +50,9 @@ jobs:
- name: Copy external - name: Copy external
run: cp -R apps/server/src/external apps/cli/external run: cp -R apps/server/src/external apps/cli/external
- name: Setup pnpm auth config # This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" - name: Install newer version of npm
env: run: npm install -g npm@11
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
+8 -6
View File
@@ -1,8 +1,6 @@
name: Ontime Resolver build name: Ontime Resolver build
on: on:
release:
types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,6 +12,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -34,10 +37,9 @@ jobs:
- name: Build project packages - name: Build project packages
run: pnpm build:resolver run: pnpm build:resolver
- name: Setup pnpm auth config # This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" - name: Install newer version of npm
env: run: npm install -g npm@11
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
-2
View File
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime # Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing. Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0", "version": "4.2.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",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0", "version": "4.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -9,7 +9,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
@@ -17,7 +17,6 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+4 -11
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -11,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File) { export async function upload(file: File): Promise<string[]> {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, { const response = await axios.post(`${excelPath}/upload`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = { type PreviewSpreadsheetResponse = {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}; };
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
+42 -16
View File
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns ========================= // #region operations on project rundowns =========================
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`); return axios.post(`${rundownPath}/${rundownId}/load`);
} }
/** /**
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title }); return axios.post(rundownPath, { title });
} }
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/** /**
* HTTP request to delete a rundown * HTTP request to delete a rundown
*/ */
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: string, rundownId: RundownId,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +75,10 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -72,7 +90,7 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
@@ -85,56 +103,64 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for dissolving of a group * HTTP request for dissolving of a group
*/ */
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries of a given rundown * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all entries of a given rundown * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{ ): Promise<{
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data; return response.data;
@@ -27,7 +27,6 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
@@ -61,6 +60,13 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
} }
.separator { .separator {
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item'; type: 'item' | 'destructive';
label: string; label: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />; return <BaseMenu.Separator key={index} className={style.separator} />;
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
{item.icon && <item.icon />} {item.icon && <item.icon />}
{item.label} {item.label}
</BaseMenu.Item> </BaseMenu.Item>
@@ -1,6 +1,7 @@
.corner { .arrow {
transform: rotate(45deg); transform: rotate(45deg);
}
.corner {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -21,6 +22,10 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -51,6 +56,6 @@
&.vertical { &.vertical {
width: 1px; width: 1px;
height: 0.75em; height: 0.75em;
} }
} }
@@ -1,13 +1,33 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss'; import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) { export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
} }
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-600;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuItem active={fullscreen} onClick={toggle}>
{fullscreen ? <IoContract /> : <IoExpand />} Toggle Fullscreen
</NavigationMenuItem> {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
{mirror && <span className={style.note}>Active</span>} {mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem> </NavigationMenuItem>
{window.isSecureContext && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake Keep Awake
<LuCoffee /> <LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<EditorNavigation /> <EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}> <ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps { interface ClientLinkProps {
current: boolean; current: boolean;
to: string; to: string;
postAction?: () => void;
} }
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate(); const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem> </NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
} }
return ( return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</NavigationMenuItem> </NavigationMenuItem>
); );
@@ -32,11 +32,11 @@
margin-left: 0.25rem; margin-left: 0.25rem;
width: 0.75em; width: 0.75em;
height: 0.75em; height: 0.75em;
background: var(--user-bg); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
} }
.empty { .empty {
color: $ui-white; color: $ui-white;
} }
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const handleClose = () => { const handleClose = () => {
close(); close();
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
}; };
return ( return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({ options.push({
value: key, value: key,
label: value.label, label: value.label,
colour: value.colour || 'transparent', colour: value.colour,
}); });
} }
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
@@ -24,22 +24,33 @@ function useProjectList() {
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() { export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
const sortComparators: Record<ProjectSortMode, SortComparator> = {
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
};
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
const response = useProjectList(); const response = useProjectList();
const { files, lastLoadedProject } = response.data; const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => { const reorderedProjectFiles: ProjectFileList = useMemo(() => {
if (!files.length) return []; if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); const sorted = [...files].sort(sortComparators[sort]);
if (currentlyLoadedIndex === -1) return files; // keep loaded always on top
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
const projectFiles = [...files]; return sorted;
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0]; }, [files, lastLoadedProject, sort]);
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } }; return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
} }
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
}, },
}); });
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({ const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown, mutationFn: deleteRundown,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, remove, load }; return { create, duplicate, remove, load, rename };
} }
@@ -55,7 +55,7 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) { if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]); const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId), mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId]); await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { throttle } from '../utils/throttle';
export interface UseLongPressOptions {
/** Time in milliseconds to trigger the long press, default is 400ms */
threshold?: number;
/** Callback triggered when the long press starts */
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press finishes */
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press is canceled */
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
}
export interface UseLongPressReturnValue {
onMouseDown: (event: React.MouseEvent) => void;
onMouseUp: (event: React.MouseEvent) => void;
onMouseLeave: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onTouchEnd: (event: React.TouchEvent) => void;
}
export function useLongPress(
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
options: UseLongPressOptions = {},
): UseLongPressReturnValue {
const { threshold = 700, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timeout = useRef<number>(-1);
useEffect(() => () => window.clearTimeout(timeout.current), []);
return useMemo(() => {
if (typeof onLongPress !== 'function') {
return {} as UseLongPressReturnValue;
}
const start = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (onStart) {
onStart(event);
}
isPressed.current = true;
timeout.current = window.setTimeout(() => {
onLongPress(event);
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (isLongPressActive.current) {
onFinish?.(event);
} else if (isPressed.current) {
onCancel?.(event);
}
isLongPressActive.current = false;
isPressed.current = false;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: throttle(cancel, 150),
};
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
}
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
}
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
return event.nativeEvent instanceof MouseEvent;
}
@@ -202,6 +202,9 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
expectedEnd: state.offset.expectedRundownEnd, expectedEnd: state.offset.expectedRundownEnd,
})); }));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri); const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path'); expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
}); });
it('should handle ontime app links', () => {
const serverUrl = 'https://app.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
}); });
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 17, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: null, groupId: null,
@@ -1,4 +1,6 @@
import { formatTime, nowInMillis } from '../time'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatDuration, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('formatDuration()', () => {
it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5000)).toBe('0m');
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
});
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow(); expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
}); });
it('throws on on invalid route', () => { it('throws on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
}); });
}); });
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils'; import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = ''; let offsetText = '';
if (offset < 0) offsetText += '-'; if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+'; if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset)); offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText; return offsetText;
} }
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) { if (!processedData.isPast && !processedData.isLoaded) {
/** /**
* isLinkToLoaded is a chain value that we maintain until we * isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd event * b) find a countToEnd event
*/ */
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded = processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded; entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
} }
+8 -2
View File
@@ -112,11 +112,12 @@ export const formatTime = (
export function formatDuration(duration: number, hideSeconds = true): string { 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 // durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) { if (duration <= 0) {
return '0h 0m'; return '0m';
} }
const hours = Math.floor(duration / MILLIS_PER_HOUR); const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE); const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = ''; let result = '';
if (hours > 0) { if (hours > 0) {
result += `${hours}h`; result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const remainingMs = duration % MILLIS_PER_MINUTE;
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
// cap at 59 to avoid showing 60s
const seconds = Math.min(59, Math.ceil(exactSeconds));
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
} }
return result; return result;
} }
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: { process: {
type: string; type: string;
}; };
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
} }
} }
+5 -1
View File
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm'; export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -22,7 +23,10 @@ export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV; export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname; export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1'; export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no'); export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -5,6 +5,7 @@ import {
documentationUrl, documentationUrl,
githubSponsorUrl, githubSponsorUrl,
githubUrl, githubUrl,
subredditUrl,
websiteUrl, websiteUrl,
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -10,7 +10,7 @@ export default function AppVersion() {
return ( return (
<Panel.Paragraph> <Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`} {`You are currently using Ontime version ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph> </Panel.Paragraph>
); );
} }
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({ } = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues, defaultValues: urlPreset ?? defaultValues,
mode: 'onChange',
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Panel.InlineElements> <Panel.InlineElements>
<div> <div>
<Panel.Description>Alias</Panel.Description> <Panel.Description>Alias</Panel.Description>
<Input {...register('alias', { required: 'Alias is required' })} /> <Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div> </div>
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
<div> - or -</div> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div> <div>2. Choose a view and its parameters</div>
<div> <div>
<Panel.Description>Target</Panel.Description> <Panel.Description>Target</Panel.Description>
@@ -1,5 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5'; import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel'; import { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure(); const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure(); const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState(''); const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => { const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open(); deleteHandlers.open();
}; };
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => { const submitRundownLoad = async () => {
try { try {
await load(targetRundown); await load(targetRundown);
@@ -48,6 +63,27 @@ export default function ManageRundowns() {
} }
}; };
const submitRundownDuplicate = async (id: string) => {
setActionError(null);
setRenamingRundown(null);
setTargetRundown('');
try {
await duplicate(id);
} catch (error) {
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => { const submitRundownDelete = async () => {
try { try {
await remove(targetRundown); await remove(targetRundown);
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
<tbody> <tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => { {data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id; const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return ( return (
<tr key={id} className={cx([isLoaded && style.current])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu <DropdownMenu
render={<IconButton variant='ghosted-white' />} render={<IconButton variant='ghosted-white' />}
items={[ items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{ {
type: 'item', type: 'item',
icon: IoDownloadOutline, icon: IoDownloadOutline,
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
}, },
{ {
type: 'item', type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash, icon: IoTrash,
label: 'Delete', label: 'Delete',
onClick: () => openDelete(id), onClick: () => openDelete(id),
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
<Dialog <Dialog
isOpen={isOpenDelete} isOpen={isOpenDelete}
onClose={deleteHandlers.close} onClose={deleteHandlers.close}
title='Load rundown' title='Delete rundown'
showBackdrop showBackdrop
showCloseButton showCloseButton
bodyElements={ bodyElements={
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { checkRegex } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
interface RundownRenameFormProps {
onSubmit: (newTitle: string) => Promise<void>;
onCancel: () => void;
initialTitle: string;
}
interface FormData {
title: string;
}
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<FormData>({
defaultValues: { title: initialTitle },
mode: 'onChange',
});
const setupSubmit = async (values: FormData) => {
try {
await onSubmit(values.title);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// Give initial focus to the title input
useEffect(() => {
setFocus('title');
}, [setFocus]);
const canSubmit = isDirty && isValid;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input
{...register('title', {
required: { value: true, message: 'Title is required' },
validate: (value) => {
if (value.trim().length === 0) return 'Title cannot be empty';
if (checkRegex.isAlphanumericWithSpace(value) === false)
return 'Title can only contain alphanumeric characters, spaces and underscores';
return true;
},
})}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import useRundown from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { formatDuration } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
onBack: () => void;
} }
export default function ImportReview(props: ImportReviewProps) { export default function ImportReview({
const { rundown, customFields, onFinished, onCancel } = props; rundown,
customFields,
summary,
onFinished,
onCancel,
onBack,
}: ImportReviewProps) {
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet(); const { importRundown } = useGoogleSheet();
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
<Button onClick={handleCancel} variant='ghosted' disabled={loading}> <Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button onClick={onBack} variant='subtle' disabled={loading}>
Back
</Button>
<Button onClick={applyImport} variant='primary' loading={loading}> <Button onClick={applyImport} variant='primary' loading={loading}>
Apply Apply
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<b>Title</b> {rundown.title}
</Panel.ListItem>
<Panel.ListItem>
<b>Number of entries</b> {rundown.flatOrder.length}
</Panel.ListItem>
<Panel.ListItem>
<b>Start time</b> {millisToString(summary.start)}
</Panel.ListItem>
<Panel.ListItem>
<b>End time</b> {millisToString(summary.end)}
</Panel.ListItem>
<Panel.ListItem>
<b>Total duration</b> {formatDuration(summary.duration)}
</Panel.ListItem>
</Panel.ListGroup>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} /> <PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section> </Panel.Section>
); );
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel, importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel, upload as uploadExcel,
} from '../../../../../common/api/excel'; } from '../../../../../common/api/excel';
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields); const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const summary = useSheetStore((state) => state.summary);
const setSummary = useSheetStore((state) => state.setSummary);
const setSheetId = useSheetStore((state) => state.setSheetId); const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId); const sheetId = useSheetStore((state) => state.sheetId);
const resetPreview = useSheetStore((state) => state.resetPreview);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
try { try {
setHasFile('loading'); setHasFile('loading');
validateExcelImport(fileToUpload); validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload); const names = await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names); setWorksheets(names);
setImportFlow('excel'); setImportFlow('excel');
setHasFile('done'); setHasFile('done');
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setWorksheets(null); setWorksheets(null);
setCustomFields(null); setCustomFields(null);
setSummary(null);
setError(''); setError('');
setSheetId(null); setSheetId(null);
}; };
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown); setRundown(previewData.rundown);
setCustomFields(previewData.customFields); setCustomFields(previewData.customFields);
setSummary(previewData.summary);
} catch (error) { } catch (error) {
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
} }
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
const showCompleted = importFlow === 'finished'; const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated; const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done'); const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null; const showReview = rundown !== null && customFields !== null && summary !== null;
return ( return (
<Panel.Section> <Panel.Section>
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
<ImportReview <ImportReview
rundown={rundown} rundown={rundown}
customFields={customFields} customFields={customFields}
summary={summary}
onFinished={handleFinished} onFinished={handleFinished}
onCancel={cancelImportMap} onCancel={cancelImportMap}
onBack={resetPreview}
/> />
)} )}
</Panel.Card> </Panel.Card>
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../../common/components/buttons/IconButton';
import Info from '../../../../../../common/components/info/Info';
import Input from '../../../../../../common/components/input/input/Input'; import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
@@ -136,6 +137,10 @@ export default function ImportMapForm({
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Info>
Match your spreadsheet columns to Ontime fields. <br />
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
</Info>
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined; return value ? 'Yes' : undefined;
} }
export default function PreviewRundown(props: PreviewRundownProps) { export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
const { rundown, customFields } = props;
// we only count Ontime Events which are 1 based in client // we only count Ontime Events which are 1 based in client
let eventIndex = 0; let eventIndex = 0;
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return ( return (
<tr key={entry.id}> <tr key={entry.id}>
<td className={style.center}> <td /> {/** Index */}
<Tag>-</Tag>
</td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
const patchStepData = useSheetStore((state) => state.patchStepData); const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSummary = useSheetStore((state) => state.setSummary);
/** whether the current session has been authenticated */ /** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => { const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
const data = await previewRundown(sheetId, fileOptions); const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown); setRundown(data.rundown);
setCustomFields(data.customFields); setCustomFields(data.customFields);
setSummary(data.summary);
} catch (error) { } catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
} }
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -17,10 +17,10 @@ type SheetStore = {
// we get this from a preview response // we get this from a preview response
rundown: Rundown | null; rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void; setCustomFields: (customFields: CustomFields | null) => void;
summary: RundownSummary | null;
setSummary: (metadata: RundownSummary | null) => void;
spreadsheetImportMap: ImportMap; spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void; patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
@@ -43,6 +43,7 @@ const initialState = {
authenticationStatus: 'not_authenticated' as AuthenticationStatus, authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null, rundown: null,
customFields: null, customFields: null,
summary: null,
spreadsheetImportMap: defaultImportMap, spreadsheetImportMap: defaultImportMap,
}; };
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setSummary: (summary: RundownSummary | null) => set({ summary }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => { patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap; const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) { if (currentImportMap[field] !== value) {
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
}, },
reset: () => set(initialState), reset: () => set(initialState),
resetPreview: () => set({ rundown: null, customFields: null }), resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
})); }));
@@ -1,18 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { ProjectSortMode, useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem'; import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ProjectList() { type SortParameter = 'alphabetical' | 'modified';
const { data, refetch, status } = useOrderedProjectList();
export default function ProjectList() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -28,6 +32,13 @@ export default function ProjectList() {
await refetch(); await refetch();
}; };
const handleSort = (sortParameter: SortParameter) => {
setSortMode((current) => {
const isAscending = current === `${sortParameter}-asc`;
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
});
};
if (status === 'pending') { if (status === 'pending') {
return ( return (
<div className={style.empty}> <div className={style.empty}>
@@ -48,8 +59,18 @@ export default function ProjectList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th className={style.containCell}>File Name</th> <th className={style.containCell} onClick={() => handleSort('alphabetical')}>
<th>Last Used</th> <span className={style.sortableHeader}>
File Name
<SortIcon sortMode={sortMode} type='alphabetical' />
</span>
</th>
<th onClick={() => handleSort('modified')}>
<span className={style.sortableHeader}>
Last Used
<SortIcon sortMode={sortMode} type='modified' />
</span>
</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -72,3 +93,10 @@ export default function ProjectList() {
</> </>
); );
} }
function SortIcon({ sortMode, type }: { sortMode: ProjectSortMode; type: SortParameter }) {
const prefix = `${type}-`;
if (sortMode === `${prefix}asc`) return <IoArrowDown />;
if (sortMode === `${prefix}desc`) return <IoArrowUp />;
return null;
}
@@ -23,7 +23,13 @@
} }
.containCell { .containCell {
max-width: 400px; max-width: 50%;
}
.sortableHeader {
display: inline-flex;
align-items: center;
gap: 0.5em;
} }
.fullWidth { .fullWidth {
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
{submitError && <Panel.Error>{submitError}</Panel.Error>} {submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Time format' title='Time format'
description='Default time format to show in views 12 /24 hours' description='Default time format to show in views 12 / 24 hours'
error={errors.timeFormat?.message} error={errors.timeFormat?.message}
/> />
<Select <Select
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Time format' title='Time format'
description='Default time format to show in views 12 /24 hours' description='Default time format to show in views 12 / 24 hours (does not affect editor)'
error={errors.settings?.timeFormat?.message} error={errors.settings?.timeFormat?.message}
/> />
<Select <Select
@@ -1,11 +1,12 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
@@ -19,8 +20,8 @@ function MessageControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}> <div className={classes}>
<ErrorBoundary> <ErrorBoundary>
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useMessagePreview } from '../../../common/hooks/useSocket'; import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
@@ -52,7 +53,7 @@ export default function TimerPreview() {
return ( return (
<div className={style.preview}> <div className={style.preview}>
<Corner onClick={(event) => handleLinks('timer', event)} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
<div <div
className={style.mainContent} className={style.mainContent}
@@ -1,10 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl'; import PlaybackControl from './PlaybackControl';
@@ -17,8 +18,8 @@ function TimerControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'> <div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
@@ -58,7 +58,6 @@
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
gap: $section-spacing; gap: $section-spacing;
margin-left: 1.5rem;
} }
.tag { .tag {
@@ -70,6 +69,7 @@
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
display: inline-block;
} }
.rolltag { .rolltag {
@@ -1,84 +1,10 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react'; import { useWakelock } from './useWakeLock';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export default function KeepAwake() {
const { keepAwake } = useKeepAwakeOptions();
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
const removeLock = () => {
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
};
const acquireLock = () => {
if (!wakeLockSentinel || wakeLockSentinel.released) {
setWakeLockSentinel(null);
navigator.wakeLock
.request('screen')
.then((sentinel) => {
setWakeLockSentinel(sentinel);
})
.catch(console.error);
}
};
useEffect(() => {
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
return <></>;
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/** /**
* Hook exposes the keep awake options * Composes the wakelock hook to contain re-renders
*/ */
export function useKeepAwakeOptions() { export default function KeepAwake() {
const [searchParams, setSearchParams] = useSearchParams(); useWakelock();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => { return null;
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake]);
return { keepAwake, toggleKeepAwake };
} }
@@ -0,0 +1,91 @@
import { use, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
// wakelock is only available in secure contexts
// however, that is covered by the navigator check
export const canUseWakeLock = typeof window !== 'undefined' && 'wakeLock' in navigator;
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export function useWakelock() {
const { keepAwake } = useKeepAwakeOptions();
const wakeLockSentinelRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
const removeLock = () => {
if (wakeLockSentinelRef.current) {
wakeLockSentinelRef.current.release().finally(() => {
wakeLockSentinelRef.current = null;
});
}
};
const acquireLock = () => {
const sentinel = wakeLockSentinelRef.current;
if (!sentinel || sentinel.released) {
wakeLockSentinelRef.current = null;
navigator.wakeLock
.request('screen')
.then((sentinel) => {
wakeLockSentinelRef.current = sentinel;
})
.catch(console.error);
}
};
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinelRef.current !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/**
* Hook exposes the keep awake options
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake, setSearchParams]);
return { keepAwake, toggleKeepAwake };
}
+12 -1
View File
@@ -9,6 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal'; import EditModal from './edit-modal/EditModal';
@@ -122,7 +123,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
<StatusBar /> <StatusBar />
{canEdit && ( {canEdit && (
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>Press and hold to edit user field</div> <div className={cx([style.editPrompt, showEditPrompt && style.show])}>
{isTouchDevice ? 'Press and hold to edit user field' : 'Right click to edit user field'}
</div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
@@ -168,6 +171,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} /> <OperatorGroup key={entry.id} title={entry.title} />
@@ -30,12 +30,12 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: min(80vh, 600px); max-height: min(60vh, 600px);
overflow-y: auto; overflow-y: auto;
} }
.label { .label {
background-color: var(--user-bg); background-color: var(--user-bg, $gray-900);
width: fit-content; width: fit-content;
padding-inline: 1rem; padding-inline: 1rem;
} }
@@ -13,7 +13,7 @@
column-gap: 0.5rem; column-gap: 0.5rem;
row-gap: 0.5rem; row-gap: 0.5rem;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto; grid-template-columns: 1.5rem 1fr auto;
grid-template-areas: grid-template-areas:
'binder main schedule' 'binder main schedule'
'binder secondary running' 'binder secondary running'
@@ -42,6 +42,7 @@
place-content: center; place-content: center;
position: relative; position: relative;
background-color: $gray-1050; // to override inline background-color: $gray-1050; // to override inline
font-weight: 600;
.cue { .cue {
white-space: nowrap; white-space: nowrap;
@@ -71,47 +72,41 @@
white-space: pre-line; white-space: pre-line;
} }
.plannedStart, .timeUntil { .plannedStart,
line-height: 1em; .timeUntil,
background-color: $gray-1000; .runningTime {
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 1px; line-height: 1;
} }
.plannedStart { .plannedStart {
font-size: 1.5rem; font-size: 1.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
display: inline;
background-color: $white-20;
letter-spacing: 1px;
} }
.timeUntil { .timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule; grid-area: schedule;
font-size: calc(1rem - 2px);
justify-self: end; justify-self: end;
background-color: $white-20;
letter-spacing: 1px;
} }
.runningTime { .runningTime {
font-size: 1.25rem;
line-height: 1em;
grid-area: running; grid-area: running;
font-size: 1.25rem;
justify-self: end; justify-self: end;
align-self: flex-start; background-color: $white-7;
display: flex;
align-items: center;
gap: 0.5em;
} }
.fields { .fields {
grid-area: fields; grid-area: fields;
font-size: var(--operator-customfield-font-size-override, 1.25rem); margin-top: 0;
font-weight: 400; border-top: 0;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
line-height: 1.25em;
.field { .field {
font-weight: 600; font-weight: 600;
@@ -133,8 +128,18 @@
// allow multi-line text but trim before // allow multi-line text but trim before
white-space: pre-line; white-space: pre-line;
} }
}
.fields::after { &.fieldsWithContent {
content: '\200b'; margin-top: 0.25rem;
padding-block: 0.5rem;
border-top: 1px solid $white-13;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.25rem 0.75rem;
line-height: 1.25em;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
}
} }
@@ -1,11 +1,12 @@
import { memo, RefObject, SyntheticEvent } from 'react'; import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { useLongPress } from '@mantine/hooks'; import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useLongPress } from '../../../common/hooks/useLongPress';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time'; import { formatDuration, formatTime, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime'; import RunningTime from '../../viewers/common/running-time/RunningTime';
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import type { EditEvent, Subscribed } from '../operator.types'; import type { EditEvent, Subscribed } from '../operator.types';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
@@ -54,8 +55,10 @@ function OperatorEvent({
* gather behaviour for long press and context menu * gather behaviour for long press and context menu
*/ */
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // prevent default if the event is cancelable to avoid browser intervention warnings
event?.preventDefault(); if (event && event.cancelable) {
event.preventDefault();
}
if (subscribed) { if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed }); onLongPress({ id, cue, subscriptions: subscribed });
} }
@@ -64,22 +67,33 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([ const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
style.event,
isSelected && style.running, const hasFields = subscribed.some((field) => field.value);
isPast && style.past, const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
]); const fieldGridStyle =
columnCount > 0
? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
} satisfies CSSProperties)
: undefined;
return ( return (
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}> <div
className={operatorClasses}
data-testid={cue}
ref={selectedRef}
onContextMenu={handleLongPress}
{...mouseHandlers}
>
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
<span className={style.mainField}> <span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>} {showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
{main} {main}
</span> </span>
<span className={style.secondaryField}>{secondary}</span> <span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule <OperatorEventSchedule
timeStart={timeStart} timeStart={timeStart}
@@ -95,22 +109,25 @@ function OperatorEvent({
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero /> <RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
</span> </span>
<div className={style.fields}> <div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
{subscribed {subscribed.map((field) => {
.filter((field) => field.value) if (!field.value) {
.map((field) => { return <div key={field.id} />;
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]); }
return ( return (
<div key={field.id}> <div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}> <span
{field.label} className={cx([style.field, !field.colour && style.noColour])}
</span> style={{ backgroundColor: field.colour }}
<span className={style.value} style={{ color: field.colour }}> >
{field.value} {field.label}
</span> </span>
</div> <span className={style.value} style={{ color: field.colour }}>
); {field.value}
})} </span>
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const isDue = timeUntil < MILLIS_PER_SECOND; const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>; return (
<span className={style.timeUntil} data-testid='time-until'>
{timeUntilString}
</span>
);
} }
@@ -40,3 +40,10 @@
height: 1rem; height: 1rem;
--progress-bar-br: 0; --progress-bar-br: 0;
} }
@media (max-width: $min-tablet) {
.timers {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'timers clock';
}
}
@@ -6,7 +6,7 @@ export default function StatusBarTimers() {
return ( return (
<div className={style.timers}> <div className={style.timers}>
<TimerOverview className={style.runningTimer} /> <TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} /> <ClockOverview className={style.timeNow} shouldFormat />
</div> </div>
); );
} }
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> <TitleOverview />
<StartTimes /> <StartTimes shouldFormat />
<TimerOverview /> <TimerOverview />
<OffsetOverview /> <OffsetOverview />
<MetadataTimes /> <MetadataTimes />
<ClockOverview /> <ClockOverview shouldFormat />
</OverviewWrapper> </OverviewWrapper>
); );
} }
@@ -34,4 +34,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow-x: auto;
overflow-y: hidden;
} }
@@ -9,7 +9,7 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: 3rem 10rem 8rem; grid-template-columns: 3rem 9rem 9rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -29,7 +29,8 @@
.icon { .icon {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
flex-shrink: 0;
} }
.label { .label {
@@ -40,18 +41,24 @@
.time { .time {
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.25px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.1; line-height: 1.1;
} }
.daySpan { .daySpan {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
&::after { &::after {
content: "+"attr(data-day-offset); content: "+" attr(data-day-offset);
vertical-align: super; font-size: 0.75rem;
font-size: 0.6em; font-weight: 400;
letter-spacing: 0; color: $ui-white;
color: $info-blue; background: $gray-900; // same as the tag component
padding: 0.125rem 0.25rem;
border-radius: $component-border-radius-sm;
} }
} }
@@ -74,4 +81,7 @@
font-size: 1rem; font-size: 1rem;
letter-spacing: 0; letter-spacing: 0;
color: $playback-over; color: $playback-over;
border-left: 3px solid $playback-over;
padding-left: 0.375rem;
display: inline-block;
} }
@@ -20,6 +20,7 @@ import {
useNextFlag, useNextFlag,
useOffsetOverview, useOffsetOverview,
useProgressOverview, useProgressOverview,
useRundownExpectedEnd,
useStartTimesOverview, useStartTimesOverview,
useTimer, useTimer,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils'; import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout'; import { OverUnder, TimeColumn, WrappedInTimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss'; import style from './TimeElements.module.scss';
export function StartTimes() { interface OverviewTimeElementsProps {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview(); shouldFormat?: boolean;
}
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart); export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
const plannedStartText = (() => {
if (plannedStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(plannedStart, formatOptions);
return millisToString(plannedStart, { fallback: timerPlaceholder });
})();
const actualStartText = (() => {
if (actualStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(actualStart, formatOptions);
return millisToString(actualStart, { fallback: timerPlaceholder });
})();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const plannedEndText = maybePlannedEnd === null ? timerPlaceholder : formatTime(maybePlannedEnd); const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
: 'Planned end time';
return ( return (
<div className={style.column}> <div className={style.column}>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>Start</span> <span className={style.label}>Start</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} /> text='Planned start time'
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span> render={
</div> <div className={style.labelledElement}>
<div className={style.labelledElement} data-testid='actual-start-time'> <TbCalendarPin className={style.icon} />
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} /> <SuperscriptPeriod
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span> className={cx([style.time, plannedStart === null && style.muted])}
</div> time={plannedStartText}
/>
</div>
}
/>
<Tooltip
text='Actual start time'
render={
<div className={style.labelledElement} data-testid='actual-start-time'>
<TbCalendarClock className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, actualStart === null && style.muted])}
time={actualStartText}
/>
</div>
}
/>
</div> </div>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>End</span> <span className={style.label}>End</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} /> text={plannedEndTooltip}
{maybePlannedDaySpan > 0 ? ( render={
<Tooltip <div className={style.labelledElement}>
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`} <TbCalendarPin className={style.icon} />
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />} <SuperscriptPeriod
> className={cx([style.time, plannedEnd === null && style.muted])}
{plannedEndText} time={plannedEndText}
</Tooltip> />
) : ( {multipleDays && (
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span> <span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)} )}
</div> </div>
<div className={style.labelledElement}> }
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} /> />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? ( <RundownExpectedEnd shouldFormat={shouldFormat} />
<Tooltip
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
) : (
<span className={cx([style.time, maybeExpectedEnd === null && style.muted])}>
{formattedTime(maybeExpectedEnd)}
</span>
)}
</div>
</div> </div>
</div> </div>
); );
} }
/**
* Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value
*/
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => {
if (maybeExpectedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays
? `Expected end time (rundown spans over ${maybeExpectedDaySpan + 1} days)`
: 'Expected end time';
return (
<Tooltip
text={tooltip}
render={
<div className={style.labelledElement}>
<TbCalendarStar className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, maybeExpectedEnd === null && style.muted])}
time={maybeExpectedEndText}
/>
{multipleDays && <span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
</div>
}
/>
);
}
export function MetadataTimes() { export function MetadataTimes() {
return ( return (
<div className={style.column}> <div className={style.column}>
@@ -229,11 +297,17 @@ export function OffsetOverview() {
return <OverUnder state={offsetState} value={offsetText} testId='offset' />; return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
} }
export function ClockOverview({ className }: { className?: string }) { export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock(); const { clock } = useClock();
const formattedClock = formatTime(clock); const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return <TimeColumn label='Time now' value={formattedClock} className={className} />; return (
<WrappedInTimeColumn
label='Time now'
className={className}
render={(clockClasses) => <SuperscriptPeriod className={clockClasses} time={formattedClock} />}
/>
);
} }
export function TimerOverview({ className }: { className?: string }) { export function TimerOverview({ className }: { className?: string }) {
@@ -1,6 +1,6 @@
.label { .label {
color: $label-gray; color: $label-gray;
font-size: calc(1rem - 2px); font-size: calc(1rem - 3px);
} }
.clock { .clock {
@@ -29,14 +29,21 @@
gap: 0.25rem; gap: 0.25rem;
} }
.over,
.under {
transition: color 0.2s ease;
}
&[data-state='waiting'] { &[data-state='waiting'] {
.label { .label {
color: $ontime-roll; color: $ontime-roll;
} }
} }
&[data-state='over'] { &[data-state='over'] {
.label .over { .label .over {
color: $playback-over; color: $playback-over;
font-weight: 600;
} }
.clock { .clock {
color: $playback-over; color: $playback-over;
@@ -46,6 +53,7 @@
&[data-state='under'] { &[data-state='under'] {
.label .under { .label .under {
color: $playback-under; color: $playback-under;
font-weight: 600;
} }
.clock { .clock {
color: $playback-under; color: $playback-under;
@@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss'; import style from './TimeLayout.module.scss';
@@ -22,6 +24,21 @@ export function TimeColumn({ label, value, state = 'active', className, testId }
); );
} }
interface WrappedInTimeColumnProps {
label: string;
state?: 'muted' | 'waiting' | 'active';
className?: string;
render: (className: string) => ReactNode;
}
export function WrappedInTimeColumn({ label, state = 'active', className, render }: WrappedInTimeColumnProps) {
return (
<div className={cx([style.column, className])} data-state={state}>
<span className={style.label}>{label}</span>
{render(style.clock)}
</div>
);
}
interface OverUnderProps { interface OverUnderProps {
state: 'over' | 'under' | 'muted' | null; state: 'over' | 'under' | 'muted' | null;
value: string; value: string;
@@ -1,10 +1,14 @@
.title { .title {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 600;
color: $ui-white;
line-height: 1;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.description { .description {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
font-weight: 400;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
+39 -26
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { import {
closestCenter, closestCenter,
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -58,6 +57,8 @@ interface RundownProps {
} }
export default function Rundown({ data, rundownMetadata }: RundownProps) { export default function Rundown({ data, rundownMetadata }: RundownProps) {
'use memo';
const { order, entries, id } = data; const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
key: `rundown.${id}-editor-collapsed-groups`, key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [], defaultValue: [],
}); });
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions(); const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor // cursor
const [editorMode] = useSessionStorage<AppMode>({ const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode, key: sessionKeys.editorMode,
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId; // lazily get the value from the store
if (copyId === null) { const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) { let normalisedAtId = atId;
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const elementToCopy = entries[entryCopyId];
addEntry(newEvent, { after: adjustedCursor ?? undefined }); const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
} }
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
}, },
[addEntry, order, entries], [entries, clone],
); );
/** /**
@@ -200,9 +214,9 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
*/ */
const getIsCollapsed = useCallback( const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => { (groupId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === groupId)); return collapsedGroupSet.has(groupId);
}, },
[collapsedGroups], [collapsedGroupSet],
); );
/** /**
@@ -300,12 +314,8 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
], ],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor)],
[ ['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }], ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]); ]);
@@ -395,7 +405,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
} }
// keep copy of the current state in case we need to revert // keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData); const currentEntries = [...sortableData];
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => { setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />; return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
} }
// 1. gather presentation options // gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour; const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0; const isFirst = index === 0;
const isLast = entryId === order.at(-1); const isLast = entryId === lastEntryId;
/** /**
* We need to provide the parent ID for the QuickAdd components * We need to provide the parent ID for the QuickAdd components
@@ -1,16 +1,4 @@
import { import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay'; import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent'; import RundownEvent from './rundown-event/RundownEvent';
@@ -44,13 +32,6 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<RundownEvent <RundownEvent
@@ -83,7 +64,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset} dayOffset={data.dayOffset}
totalGap={totalGap} totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0} hasTriggers={data.triggers.length > 0}
/> />
); );
@@ -1,13 +1,14 @@
import { memo } from 'react'; import { memo } from 'react';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { Corner } from '../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
@@ -55,11 +56,11 @@ function RundownExport() {
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement /> <FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu> <RundownContextMenu>
<RundownWrapper /> <RundownWrapper />
</RundownContextMenu> </RundownContextMenu>
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<Editor.Title>Group schedule</Editor.Title> <Editor.Title>Group schedule</Editor.Title>
<div className={style.inline}> <div className={style.inline}>
<div> <div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label> <Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput} disabled> <TextLikeInput className={style.textLikeInput} disabled>
{millisToString(group.timeStart, { fallback: timerPlaceholder })} {millisToString(group.timeStart, { fallback: timerPlaceholder })}
@@ -2,16 +2,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
height: 0;
position: relative; position: relative;
height: 1px;
background: $blue-500;
z-index: $zindex-floating; z-index: $zindex-floating;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: $blue-500;
}
} }
.addButton { .addButton {
position: absolute; position: relative;
top: 0;
left: 0;
transform: translateY(-50%);
} }
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
height: 100%; height: 100%;
} }
.eventTimers.editMode:hover {
[class*="hoverLabel"] {
opacity: 1;
transition: opacity 0.15s 0.5s ease;
}
}
.chipSection { .chipSection {
grid-area: chip; grid-area: chip;
} }
@@ -56,7 +56,6 @@ interface RundownEventProps {
dayOffset: number; dayOffset: number;
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
createCloneEvent: () => void;
hasTriggers: boolean; hasTriggers: boolean;
} }
@@ -91,10 +90,9 @@ export default function RundownEvent({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
createCloneEvent,
}: RundownEventProps) { }: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -172,7 +170,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Clone', label: 'Clone',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
onClick: createCloneEvent, onClick: () => clone(eventId, { after: eventId }),
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
@@ -10,10 +10,12 @@ import {
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
@@ -76,6 +78,11 @@ function RundownEventInner({
}: RundownEventInnerProps) { }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
useEffect(() => { useEffect(() => {
setRenderInner(true); setRenderInner(true);
}, []); }, []);
@@ -92,7 +99,7 @@ function RundownEventInner({
return !renderInner ? null : ( return !renderInner ? null : (
<> <>
<div className={style.eventTimers}> <div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow <TimeInputFlow
eventId={eventId} eventId={eventId}
timeStart={timeStart} timeStart={timeStart}
@@ -10,14 +10,14 @@ import {
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import { canDrop } from '../rundown.utils'; import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaRow}> <div className={style.metaRow}>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Start</div> <div>Start</div>
<div>{formatTime(data.timeStart)}</div> <div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>End</div> <div>End</div>
<div>{formatTime(data.timeEnd)}</div> <div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Duration</div> <div>Duration</div>
@@ -15,3 +15,26 @@
.active { .active {
color: $active-indicator; color: $active-indicator;
} }
.inputWrapper {
position: relative;
}
.hoverLabel {
position: absolute;
top: -1.5rem;
background-color: $ui-white;
padding: 0.125rem 0.5rem;
color: $ui-black;
left: 0;
opacity: 0;
border-radius: $component-border-radius-md;
transition: opacity 0.15s ease; // no delay on fade out
pointer-events: none;
z-index: $zindex-floating;
&.visible {
opacity: 1;
transition: opacity 0.15s 0.5s ease; // small delay before fade in
}
}
@@ -67,8 +67,9 @@ function TimeInputFlow({
return ( return (
<> <>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>} {showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeStart' name='timeStart'
@@ -88,8 +89,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>End time</Editor.Label>} {showLabels && <Editor.Label>End time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeEnd' name='timeEnd'
@@ -110,8 +112,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>Duration</Editor.Label>} {showLabels && <Editor.Label>Duration</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='duration' name='duration'
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../common/utils/copyToClipboard'; import copyToClipboard from '../../common/utils/copyToClipboard';
import { preventEscape } from '../../common/utils/keyEvent'; import { preventEscape } from '../../common/utils/keyEvent';
import { linkToOtherHost } from '../../common/utils/linkUtils';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -136,7 +135,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
const url = await generateUrl({ const url = await generateUrl({
baseUrl: linkToOtherHost(options.baseUrl), baseUrl: options.baseUrl,
path, path,
authenticate: options.authenticate, authenticate: options.authenticate,
lockConfig: options.lockConfig, lockConfig: options.lockConfig,
@@ -14,12 +14,18 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
const { data: infoData } = useInfo(); const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined }); const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
/**
* hostOptions are only used for local networks
* the NIF address is a local IP address: 192.168.x.x or 10.x.x.x
* We need to inject the port and protocol to create a valid URL
* eg: http://192.168.x.x:port
*/
const hostOptions = useMemo(() => { const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({ return infoData.networkInterfaces.map((nif) => ({
value: nif.address, value: `http://${nif.address}:${infoData.serverPort}`,
label: `${nif.name} - ${nif.address}`, label: `${nif.name} - ${nif.address}`,
})); }));
}, [infoData.networkInterfaces]); }, [infoData.networkInterfaces, infoData.serverPort]);
const pathOptions = useMemo(() => { const pathOptions = useMemo(() => {
if (lockedPath) { if (lockedPath) {
@@ -0,0 +1,23 @@
import './SuperscriptTime.scss';
interface SuperscriptPeriodProps {
time: string;
className?: string;
}
/**
* Receives a time string and formats periods (am/pm) as superscript
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> no formatting changes applied
*/
export default function SuperscriptPeriod({ time, className }: SuperscriptPeriodProps) {
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
return (
<div className={className}>
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
}
@@ -11,7 +11,6 @@ import { formatTime } from '../../../common/utils/time';
export function getTimerByType( export function getTimerByType(
freezeEnd: boolean, freezeEnd: boolean,
timerTypeNow: TimerType, timerTypeNow: TimerType,
countToEndNow: boolean,
clock: number, clock: number,
timerObject: Pick<TimerState, 'current' | 'elapsed'>, timerObject: Pick<TimerState, 'current' | 'elapsed'>,
timerTypeOverride?: TimerType, timerTypeOverride?: TimerType,
@@ -22,13 +21,6 @@ export function getTimerByType(
const viewTimerType = timerTypeOverride ?? timerTypeNow; const viewTimerType = timerTypeOverride ?? timerTypeNow;
if (countToEndNow) {
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (viewTimerType) { switch (viewTimerType) {
case TimerType.CountDown: case TimerType.CountDown:
if (timerObject.current === null) { if (timerObject.current === null) {
@@ -36,7 +28,7 @@ export function getTimerByType(
} }
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current; return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
case TimerType.CountUp: case TimerType.CountUp:
return Math.abs(timerObject.elapsed ?? 0); return timerObject.elapsed;
case TimerType.Clock: case TimerType.Clock:
return clock; return clock;
case TimerType.None: case TimerType.None:
@@ -1,6 +1,7 @@
// from https://www.genomecolor.space/ // from https://www.genomecolor.space/
$white-1: rgba(255, 255, 255, 0.01); $white-1: rgba(255, 255, 255, 0.01);
$white-2: rgba(255, 255, 255, 0.02);
$white-3: rgba(255, 255, 255, 0.03); $white-3: rgba(255, 255, 255, 0.03);
$white-7: rgba(255, 255, 255, 0.07); $white-7: rgba(255, 255, 255, 0.07);
$white-9: rgba(255, 255, 255, 0.09); $white-9: rgba(255, 255, 255, 0.09);
+2 -2
View File
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
$ontime-stop: #E4281E; $ontime-stop: #E4281E;
$playback-negative: $red-500; $playback-negative: $red-500;
$playback-ahead: $green-500; $playback-ahead: $green-500;
$active-indicator: #8bb33d; $active-indicator: $green-400;
$text-black: $gray-1350; $text-black: $gray-1350;
$playback-over: #F57C13; $playback-over: #F57C13;
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
// interface text // interface text
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif; $ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
$title-gray: $gray-200; $title-gray: $gray-200;
$label-gray: $gray-400; $label-gray: $gray-600;
$secondary-text-gray: $gray-400; $secondary-text-gray: $gray-400;
$muted-gray: $gray-600; $muted-gray: $gray-600;
$section-white: $ui-white; $section-white: $ui-white;
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const scheduledStart = (() => { const scheduledStart = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
const scheduledEnd = (() => { const scheduledEnd = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin }); let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
@@ -5,14 +5,14 @@ import { getOffsetState } from '../../../common/utils/offset';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time'; import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime'; import SuperscriptPeriod from '../../../features/viewers/common/superscript-time/SuperscriptPeriod';
import { useScheduleOptions } from './schedule.options'; import { useScheduleOptions } from './schedule.options';
import './Schedule.scss'; import './Schedule.scss';
const formatOptions = { const formatOptions = {
format12: 'hh:mm a', format12: 'h:mm a',
format24: 'HH:mm', format24: 'HH:mm',
}; };
@@ -84,9 +84,9 @@ function PlannedScheduleItem({
return ( return (
<> <>
<span className='entry-colour' style={{ backgroundColor: colour }} /> <span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} /> <SuperscriptPeriod time={start} />
<SuperscriptTime time={end} /> <SuperscriptPeriod time={end} />
</> </>
); );
} }
@@ -106,14 +106,14 @@ function DelayedScheduleItem({
<> <>
<span className='entry-times--delayed'> <span className='entry-times--delayed'>
<span className='entry-colour' style={{ backgroundColor: colour }} /> <span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} /> <SuperscriptPeriod time={start} />
<SuperscriptTime time={end} /> <SuperscriptPeriod time={end} />
</span> </span>
<span className='entry-times--delay'> <span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} /> <SuperscriptPeriod time={delayedStart} />
<SuperscriptTime time={delayedEnd} /> <SuperscriptPeriod time={delayedEnd} />
</span> </span>
</> </>
); );
@@ -161,5 +161,5 @@ interface ExpectedTimeProps {
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) { function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
const timeDisplay = formatTime(expectedTime); const timeDisplay = formatTime(expectedTime);
const expectedState = getOffsetState(expectedTime - plannedTime); const expectedState = getOffsetState(expectedTime - plannedTime);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={timeDisplay} />; return <SuperscriptPeriod className={`entry-times--${expectedState}`} time={timeDisplay} />;
} }
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { EntryId, PlayableEvent } from 'ontime-types'; import { EntryId, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime'; import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
@@ -12,7 +13,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: PlayableEvent[]; events: ExtendedEntry<PlayableEvent>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
disableEdit: () => void; disableEdit: () => void;
} }
@@ -72,9 +73,9 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
> >
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} /> <div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div className='sub__schedule'> <div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div> </div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div> <div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div> <div className='sub__title'>{title}</div>
@@ -1,9 +1,8 @@
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types'; import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils'; import { getExpectedStart, MILLIS_PER_MINUTE, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time'; import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider'; import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
@@ -36,23 +35,6 @@ export const timerProgress: TimerMessage = {
done: 'countdown.ended', done: 'countdown.ended',
}; };
export function getFormattedTime(
value: MaybeNumber,
status: ProgressStatus,
minText: string,
secText: string,
dueText: string,
) {
if (value === null) return timerPlaceholderMin;
if (status === 'future' || status === 'live') {
if (value <= 0) return dueText.toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
.replace('m', `${minText} `)
.replace('s', secText);
}
return removeSeconds(formatTime(value));
}
/** /**
* Returns a parsed timer and relevant status message * Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
@@ -65,7 +47,11 @@ export function useSubscriptionDisplayData(
const bigDuration = (value: number) => { const bigDuration = (value: number) => {
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase(); if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2) if (value < MILLIS_PER_MINUTE * 10) {
return removeLeadingZero(millisToString(value));
}
return formatDuration(value, value > MILLIS_PER_MINUTE * 10)
.replace('m', `${getLocalizedString('common.minutes')} `) .replace('m', `${getLocalizedString('common.minutes')} `)
.replace('s', getLocalizedString('common.seconds')); .replace('s', getLocalizedString('common.seconds'));
}; };
@@ -90,7 +76,7 @@ export function useSubscriptionDisplayData(
return { return {
status: 'pending', status: 'pending',
statusDisplay: getLocalizedString(timerProgress['pending']), statusDisplay: getLocalizedString(timerProgress['pending']),
timeDisplay: ' ', timeDisplay: ' ',
}; };
} }
@@ -23,7 +23,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
} }
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formatOpts = hideTableSeconds ? { format12: 'h:mm a', format24: 'HH:mm' } : undefined;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
@@ -62,7 +62,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
} }
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formatOpts = hideTableSeconds ? { format12: 'h:mm a', format24: 'HH:mm' } : undefined;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
@@ -0,0 +1,65 @@
import { createRoot } from 'react-dom/client';
import { ErrorBoundary } from '@sentry/react';
import { CornerPipButton } from '../../../common/components/editor-utils/EditorUtils';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { PipTimer } from './PipTimer';
export default function PipTimerHost() {
const { data, status } = useViewSettings();
const openPictureInPicture = async () => {
if (window.documentPictureInPicture.window) {
return;
}
let pipWindow: Window;
try {
pipWindow = await window.documentPictureInPicture.requestWindow();
} catch (err) {
console.error('Failed to open Picture-in-Picture:', err);
return;
}
[...document.styleSheets].forEach((sheet) => {
try {
if (sheet.href) {
const link = pipWindow.document.createElement('link');
link.rel = 'stylesheet';
link.href = sheet.href;
pipWindow.document.head.appendChild(link);
} else if (sheet.cssRules) {
const style = pipWindow.document.createElement('style');
style.textContent = [...sheet.cssRules].map((rule) => rule.cssText).join('');
pipWindow.document.head.appendChild(style);
}
} catch (e) {
console.warn('Stylesheet copy blocked:', e);
}
});
const pipDiv = pipWindow.document.createElement('div');
pipDiv.setAttribute('id', 'pip-root');
pipDiv.style.height = '100vh';
pipWindow.document.body.append(pipDiv);
const pipRoot = createRoot(pipWindow.document.getElementById('pip-root') as Element, {
onCaughtError: (err, _errInfo) => console.error(err),
onUncaughtError: (err, _errInfo) => console.error(err),
onRecoverableError: (err, _errInfo) => console.error(err),
});
pipWindow.addEventListener('pagehide', () => {
pipRoot.unmount();
});
pipRoot.render(
<ErrorBoundary>
<PipTimer viewSettings={data} />
</ErrorBoundary>,
);
};
return <CornerPipButton onClick={status === 'success' ? openPictureInPicture : undefined} />;
}
@@ -0,0 +1,18 @@
import { lazy, memo, Suspense } from 'react';
import { isPipSupported } from './pip.utils';
const PipTimerHost = lazy(() => import('./PipHost'));
export default memo(PipRoot);
function PipRoot() {
if (!isPipSupported) {
return null;
}
return (
<Suspense fallback={null}>
<PipTimerHost />
</Suspense>
);
}
@@ -0,0 +1,134 @@
@use '../../../theme/viewerDefs' as *;
/* The styles in this file are a stripped down version of the timer */
.pip-timer {
margin: 0;
padding: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family;
background: $viewer-background-color;
color: $viewer-color;
gap: $view-element-gap;
display: flex;
flex-direction: column;
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
/* =================== MAIN ===================*/
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
width: 100%;
overflow: hidden;
.timer {
opacity: 1;
font-family: $viewer-font-family;
color: var(--timer-colour, $ui-white);
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
font-weight: 600;
transition-property: font-size;
transition-duration: $viewer-transition-time;
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
// use a class instead of a phase, to allow suppressing overtime style
&--finished {
color: var(--timer-overtime-color-override, $timer-finished-color);
}
&[data-phase='warning'] {
color: var(--timer-colour, var(--timer-warning-color-override));
}
&[data-phase='danger'] {
color: var(--timer-colour, var(--timer-danger-color-override));
}
&[data-type='none'] {
transition: 1s;
opacity: 0;
}
}
}
.secondary {
white-space: nowrap;
max-height: 100%;
height: auto;
margin-top: 0.125em;
padding-block: 0.125em;
font-weight: 600;
text-align: center;
color: $external-color;
letter-spacing: 0.5px;
line-height: 1;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, $external-color 10%, transparent);
&--hidden {
opacity: 0;
height: 0;
}
}
.progress-container {
width: 100%;
margin: 0 auto;
opacity: 1;
transition: $viewer-transition-time;
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
inset: 0;
padding: 2vw;
background: $viewer-background-color;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: $zindex-floating;
opacity: 1;
}
}
.message {
display: grid;
place-content: center;
height: 100%;
width: 100%;
color: $viewer-color;
text-align: center;
font-weight: 600;
}
}
@@ -0,0 +1,109 @@
import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../../features/viewers/common/viewUtils';
import {
getEstimatedFontSize,
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
import './PipTimer.scss';
interface PipTimerProps {
viewSettings: ViewSettings;
}
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const isPlaying = getIsPlaying(time.playback);
const showProgressBar = getShowProgressBar(timerTypeNow);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
});
const currentAux = (() => {
if (message.timer.secondarySource === 'aux1') {
return auxTimer.aux1;
}
if (message.timer.secondarySource === 'aux2') {
return auxTimer.aux2;
}
if (message.timer.secondarySource === 'aux3') {
return auxTimer.aux3;
}
return null;
})();
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
};
return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className='timer-container'>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
>
{display}
</div>
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
</FitText>
</div>
</div>
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export const isPipSupported = 'documentPictureInPicture' in window;
@@ -49,9 +49,7 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
</div> </div>
<div> <div>
<div className='label center'>Over / under</div> <div className='label center'>Over / under</div>
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState && offsetState])}> <div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState])}>{schedule.offset}</div>
{schedule.offset}
</div>
</div> </div>
<div> <div>
<div className='label right'>{getLocalizedString('common.expected_end')}</div> <div className='label right'>{getLocalizedString('common.expected_end')}</div>

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