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
},
"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"],
"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:
- 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
uses: pnpm/action-setup@v4
@@ -45,10 +50,9 @@ jobs:
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
+8 -6
View File
@@ -1,8 +1,6 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
@@ -14,6 +12,11 @@ jobs:
steps:
- 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
uses: pnpm/action-setup@v4
@@ -34,10 +37,9 @@ jobs:
- name: Build project packages
run: pnpm build:resolver
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
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!
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 is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0",
"version": "4.2.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0",
"version": "4.2.1",
"private": true,
"type": "module",
"dependencies": {
@@ -9,7 +9,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9",
@@ -17,7 +17,6 @@
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
+4 -11
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -11,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @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();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
const response = await axios.post(`${excelPath}/upload`, formData, {
headers: {
'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;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
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';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`);
}
/**
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
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
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`);
}
// #endregion operations on project rundowns ======================
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: string,
rundownId: RundownId,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +75,10 @@ export async function postAddEntry(
/**
* 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);
}
@@ -72,7 +90,7 @@ export type BatchEditEntry = {
/**
* 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);
}
@@ -85,56 +103,64 @@ export type ReorderEntry = {
/**
* 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);
}
/**
* 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 });
}
/**
* 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}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(
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
*/
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 });
}
/**
* 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}`);
}
/**
* 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 } });
}
/**
* 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`);
}
+2 -1
View File
@@ -1,5 +1,5 @@
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 { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
@@ -27,7 +27,6 @@
}
}
.item {
outline: 0;
cursor: default;
@@ -61,6 +60,13 @@
border-radius: 3px;
background-color: $gray-1000;
}
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
}
.separator {
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
type: 'item' | 'destructive';
label: string;
icon?: IconType;
disabled?: boolean;
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />;
}
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.label}
</BaseMenu.Item>
@@ -1,6 +1,7 @@
.corner {
.arrow {
transform: rotate(45deg);
}
.corner {
position: absolute;
top: 0.5rem;
right: 0.5rem;
@@ -21,6 +22,10 @@
}
}
.offsetCorner {
right: 2rem;
}
.header {
font-size: 1.5rem;
}
@@ -51,6 +56,6 @@
&.vertical {
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 { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
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>) {
@@ -11,7 +11,7 @@
outline: none;
&::placeholder {
color: $gray-500;
color: $gray-600;
letter-spacing: 0;
}
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton>
</div>
<div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} />
<EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} />
{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}
</ClientLink>
))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps {
current: boolean;
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 navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
<NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
}
return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
<NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children}
</NavigationMenuItem>
);
@@ -32,11 +32,11 @@
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg);
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
}
.empty {
color: $ui-white;
}
}
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const handleClose = () => {
close();
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
};
return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
colour: value.colour,
});
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
@@ -24,22 +24,33 @@ function useProjectList() {
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 { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
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];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return sorted;
}, [files, lastLoadedProject, sort]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
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
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
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({
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
useEffect(() => {
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);
setPrevRevision(data.revision);
}
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
isOntimeEvent,
isOntimeGroup,
MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private
*/
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 }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation([rundownId, entryId, options]);
} catch (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,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
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);
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,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
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()', () => {
it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
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();
});
it('throws on on invalid route', () => {
it('throws on invalid route', () => {
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 { millisToString } from 'ontime-utils';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = '';
if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset));
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText;
}
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
*/
processedData.totalGap += entry.gap;
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 {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
return '0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
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) {
result += `${seconds}s`;
}
}
return result;
}
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: {
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 websiteUrl = 'https://www.getontime.no';
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 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 currentHostName = window.location.hostname;
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
@@ -5,6 +5,7 @@ import {
documentationUrl,
githubSponsorUrl,
githubUrl,
subredditUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
);
@@ -10,7 +10,7 @@ export default function AppVersion() {
return (
<Panel.Paragraph>
{`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>
);
}
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
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 * as Panel from '../../../panel-utils/PanelUtils';
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues,
mode: 'onChange',
resetOptions: {
keepDirtyValues: true,
},
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Panel.InlineElements>
<div>
<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 className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
</Panel.InlineElements>
</div>
</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>
<Panel.Description>Target</Panel.Description>
@@ -1,5 +1,13 @@
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 { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open();
};
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => {
try {
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 () => {
try {
await remove(targetRundown);
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
<tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => {
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 (
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{
type: 'item',
icon: IoDownloadOutline,
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
},
{
type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
<Dialog
isOpen={isOpenDelete}
onClose={deleteHandlers.close}
title='Load rundown'
title='Delete rundown'
showBackdrop
showCloseButton
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 { 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 useRundown from '../../../../../common/hooks-query/useRundown';
import { formatDuration } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
interface ImportReviewProps {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
onFinished: () => void;
onCancel: () => void;
onBack: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
export default function ImportReview({
rundown,
customFields,
summary,
onFinished,
onCancel,
onBack,
}: ImportReviewProps) {
const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel
</Button>
<Button onClick={onBack} variant='subtle' disabled={loading}>
Back
</Button>
<Button onClick={applyImport} variant='primary' loading={loading}>
Apply
</Button>
</Panel.InlineElements>
</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} />
</Panel.Section>
);
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../../common/api/excel';
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
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 sheetId = useSheetStore((state) => state.sheetId);
const resetPreview = useSheetStore((state) => state.resetPreview);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
try {
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
const names = await uploadExcel(fileToUpload);
setWorksheets(names);
setImportFlow('excel');
setHasFile('done');
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setSummary(null);
setError('');
setSheetId(null);
};
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
setSummary(previewData.summary);
} catch (error) {
setError(maybeAxiosError(error));
}
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
const showReview = rundown !== null && customFields !== null && summary !== null;
return (
<Panel.Section>
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
<ImportReview
rundown={rundown}
customFields={customFields}
summary={summary}
onFinished={handleFinished}
onCancel={cancelImportMap}
onBack={resetPreview}
/>
)}
</Panel.Card>
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton';
import Info from '../../../../../../common/components/info/Info';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
@@ -136,6 +137,10 @@ export default function ImportMapForm({
</Button>
</Panel.InlineElements>
</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>
<thead>
<tr>
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown(props: PreviewRundownProps) {
const { rundown, customFields } = props;
export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return (
<tr key={entry.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td /> {/** Index */}
<td className={style.center}>
<Tag>{entry.type}</Tag>
</td>
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSummary = useSheetStore((state) => state.setSummary);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
setCustomFields(data.customFields);
setSummary(data.summary);
} catch (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 { create } from 'zustand';
@@ -17,10 +17,10 @@ type SheetStore = {
// we get this from a preview response
rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void;
summary: RundownSummary | null;
setSummary: (metadata: RundownSummary | null) => void;
spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
@@ -43,6 +43,7 @@ const initialState = {
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
customFields: null,
summary: null,
spreadsheetImportMap: defaultImportMap,
};
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setSummary: (summary: RundownSummary | null) => set({ summary }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) {
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
},
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 { IoArrowDown, IoArrowUp } from 'react-icons/io5';
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 ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
export default function ProjectList() {
const { data, refetch, status } = useOrderedProjectList();
type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() {
const [editingMode, setEditingMode] = useState<EditMode | 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) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -28,6 +32,13 @@ export default function ProjectList() {
await refetch();
};
const handleSort = (sortParameter: SortParameter) => {
setSortMode((current) => {
const isAscending = current === `${sortParameter}-asc`;
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
});
};
if (status === 'pending') {
return (
<div className={style.empty}>
@@ -48,8 +59,18 @@ export default function ProjectList() {
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>File Name</th>
<th>Last Used</th>
<th className={style.containCell} onClick={() => handleSort('alphabetical')}>
<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 />
</tr>
</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 {
max-width: 400px;
max-width: 50%;
}
.sortableHeader {
display: inline-flex;
align-items: center;
gap: 0.5em;
}
.fullWidth {
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings';
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
<Panel.ListItem>
<Panel.Field
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}
/>
<Select
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
<Panel.ListItem>
<Panel.Field
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}
/>
<Select
@@ -1,11 +1,12 @@
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 ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
@@ -19,8 +20,8 @@ function MessageControlExport() {
return (
<ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />}
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}>
<ErrorBoundary>
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
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 { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import style from './MessageControl.module.scss';
@@ -52,7 +53,7 @@ export default function TimerPreview() {
return (
<div className={style.preview}>
<Corner onClick={(event) => handleLinks('timer', event)} />
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
@@ -1,10 +1,11 @@
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 ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl';
@@ -17,8 +18,8 @@ function TimerControlExport() {
return (
<ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />}
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}>
<ErrorBoundary>
@@ -58,7 +58,6 @@
height: 1.5rem;
display: flex;
gap: $section-spacing;
margin-left: 1.5rem;
}
.tag {
@@ -70,6 +69,7 @@
.time {
color: $section-white;
font-size: $text-body-size;
display: inline-block;
}
.rolltag {
@@ -1,84 +1,10 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react';
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);
}
import { useWakelock } from './useWakeLock';
/**
* Hook exposes the keep awake options
* Composes the wakelock hook to contain re-renders
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
export default function KeepAwake() {
useWakelock();
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]);
return { keepAwake, toggleKeepAwake };
return null;
}
@@ -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 { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal';
@@ -122,7 +123,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
<StatusBar />
{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}>
@@ -168,6 +171,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} />
@@ -30,12 +30,12 @@
flex-direction: column;
gap: 0.5rem;
max-height: min(80vh, 600px);
max-height: min(60vh, 600px);
overflow-y: auto;
}
.label {
background-color: var(--user-bg);
background-color: var(--user-bg, $gray-900);
width: fit-content;
padding-inline: 1rem;
}
@@ -13,7 +13,7 @@
column-gap: 0.5rem;
row-gap: 0.5rem;
grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto;
grid-template-columns: 1.5rem 1fr auto;
grid-template-areas:
'binder main schedule'
'binder secondary running'
@@ -42,6 +42,7 @@
place-content: center;
position: relative;
background-color: $gray-1050; // to override inline
font-weight: 600;
.cue {
white-space: nowrap;
@@ -71,47 +72,41 @@
white-space: pre-line;
}
.plannedStart, .timeUntil {
line-height: 1em;
background-color: $gray-1000;
.plannedStart,
.timeUntil,
.runningTime {
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem;
border-radius: 1px;
line-height: 1;
}
.plannedStart {
font-size: 1.5rem;
margin-right: 0.5rem;
display: inline;
background-color: $white-20;
letter-spacing: 1px;
}
.timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule;
font-size: calc(1rem - 2px);
justify-self: end;
background-color: $white-20;
letter-spacing: 1px;
}
.runningTime {
font-size: 1.25rem;
line-height: 1em;
grid-area: running;
font-size: 1.25rem;
justify-self: end;
align-self: flex-start;
display: flex;
align-items: center;
gap: 0.5em;
background-color: $white-7;
}
.fields {
grid-area: fields;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
line-height: 1.25em;
margin-top: 0;
border-top: 0;
.field {
font-weight: 600;
@@ -133,8 +128,18 @@
// allow multi-line text but trim before
white-space: pre-line;
}
}
.fields::after {
content: '\200b';
&.fieldsWithContent {
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 { useLongPress } from '@mantine/hooks';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useLongPress } from '../../../common/hooks/useLongPress';
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 SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import type { EditEvent, Subscribed } from '../operator.types';
import style from './OperatorEvent.module.scss';
@@ -54,8 +55,10 @@ function OperatorEvent({
* gather behaviour for long press and context menu
*/
const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress
event?.preventDefault();
// prevent default if the event is cancelable to avoid browser intervention warnings
if (event && event.cancelable) {
event.preventDefault();
}
if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed });
}
@@ -64,22 +67,33 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([
style.event,
isSelected && style.running,
isPast && style.past,
]);
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
const fieldGridStyle =
columnCount > 0
? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
} satisfies CSSProperties)
: undefined;
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 }}>
<span className={style.cue}>{cue}</span>
</div>
<span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>}
{showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
{main}
</span>
</span>
<span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule
timeStart={timeStart}
@@ -95,22 +109,25 @@ function OperatorEvent({
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
</span>
<div className={style.fields}>
{subscribed
.filter((field) => field.value)
.map((field) => {
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
return (
<div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
{field.label}
</span>
<span className={style.value} style={{ color: field.colour }}>
{field.value}
</span>
</div>
);
})}
<div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
{subscribed.map((field) => {
if (!field.value) {
return <div key={field.id} />;
}
return (
<div key={field.id}>
<span
className={cx([style.field, !field.colour && style.noColour])}
style={{ backgroundColor: field.colour }}
>
{field.label}
</span>
<span className={style.value} style={{ color: field.colour }}>
{field.value}
</span>
</div>
);
})}
</div>
</div>
);
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const isDue = timeUntil < MILLIS_PER_SECOND;
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;
--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 (
<div className={style.timers}>
<TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} />
<ClockOverview className={style.timeNow} shouldFormat />
</div>
);
}
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return (
<OverviewWrapper navElements={children}>
<TitleOverview />
<StartTimes />
<StartTimes shouldFormat />
<TimerOverview />
<OffsetOverview />
<MetadataTimes />
<ClockOverview />
<ClockOverview shouldFormat />
</OverviewWrapper>
);
}
@@ -34,4 +34,6 @@
display: flex;
align-items: center;
justify-content: space-between;
overflow-x: auto;
overflow-y: hidden;
}
@@ -9,7 +9,7 @@
.row {
display: grid;
grid-template-columns: 3rem 10rem 8rem;
grid-template-columns: 3rem 9rem 9rem;
align-items: center;
gap: 0.5rem;
}
@@ -29,7 +29,8 @@
.icon {
font-size: 1rem;
color: $label-gray;
color: $secondary-text-gray;
flex-shrink: 0;
}
.label {
@@ -40,18 +41,24 @@
.time {
font-size: 1.25rem;
letter-spacing: 0.5px;
letter-spacing: 0.25px;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.daySpan {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
&::after {
content: "+"attr(data-day-offset);
vertical-align: super;
font-size: 0.6em;
letter-spacing: 0;
color: $info-blue;
content: "+" attr(data-day-offset);
font-size: 0.75rem;
font-weight: 400;
color: $ui-white;
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;
letter-spacing: 0;
color: $playback-over;
border-left: 3px solid $playback-over;
padding-left: 0.375rem;
display: inline-block;
}
@@ -20,6 +20,7 @@ import {
useNextFlag,
useOffsetOverview,
useProgressOverview,
useRundownExpectedEnd,
useStartTimesOverview,
useTimer,
} from '../../../common/hooks/useSocket';
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
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';
export function StartTimes() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview();
interface OverviewTimeElementsProps {
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 [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 (
<div className={style.column}>
<div className={style.row}>
<span className={style.label}>Start</span>
<div className={style.labelledElement}>
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
</div>
<div className={style.labelledElement} data-testid='actual-start-time'>
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span>
</div>
<Tooltip
text='Planned start time'
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedStart === null && style.muted])}
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 className={style.row}>
<span className={style.label}>End</span>
<div className={style.labelledElement}>
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? (
<Tooltip
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
>
{plannedEndText}
</Tooltip>
) : (
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span>
)}
</div>
<div className={style.labelledElement}>
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
<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>
<Tooltip
text={plannedEndTooltip}
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedEnd === null && style.muted])}
time={plannedEndText}
/>
{multipleDays && (
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)}
</div>
}
/>
<RundownExpectedEnd shouldFormat={shouldFormat} />
</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() {
return (
<div className={style.column}>
@@ -229,11 +297,17 @@ export function OffsetOverview() {
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 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 }) {
@@ -1,6 +1,6 @@
.label {
color: $label-gray;
font-size: calc(1rem - 2px);
font-size: calc(1rem - 3px);
}
.clock {
@@ -29,14 +29,21 @@
gap: 0.25rem;
}
.over,
.under {
transition: color 0.2s ease;
}
&[data-state='waiting'] {
.label {
color: $ontime-roll;
}
}
&[data-state='over'] {
.label .over {
color: $playback-over;
font-weight: 600;
}
.clock {
color: $playback-over;
@@ -46,6 +53,7 @@
&[data-state='under'] {
.label .under {
color: $playback-under;
font-weight: 600;
}
.clock {
color: $playback-under;
@@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils';
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 {
state: 'over' | 'under' | 'muted' | null;
value: string;
@@ -1,10 +1,14 @@
.title {
font-size: 1.5rem;
font-size: 1.75rem;
font-weight: 600;
color: $ui-white;
line-height: 1;
@include ellipsis-overflow;
}
.description {
font-size: 1rem;
color: $label-gray;
color: $secondary-text-gray;
font-weight: 400;
@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 {
closestCenter,
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -58,6 +57,8 @@ interface RundownProps {
}
export default function Rundown({ data, rundownMetadata }: RundownProps) {
'use memo';
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy();
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
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 scrollRef = useRef<HTMLDivElement | null>(null);
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
);
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
(atId: string | null, above = false) => {
// lazily get the value from the store
const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry);
addEntry(newEvent, { after: adjustedCursor ?? undefined });
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
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(
(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 + V', () => insertCopyAtId(cursor, entryCopyId)],
[
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + V', () => insertCopyAtId(cursor)],
['mod + shift + V', () => insertCopyAtId(cursor, true), { 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
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
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
}
// 1. gather presentation options
// gather presentation options
const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
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
@@ -1,16 +1,4 @@
import {
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 { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent';
@@ -44,13 +32,6 @@ export default function RundownEntry({
totalGap,
isLinkedToLoaded,
}: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) {
return (
<RundownEvent
@@ -83,7 +64,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0}
/>
);
@@ -1,13 +1,14 @@
import { memo } from 'react';
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 ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
@@ -55,11 +56,11 @@ function RundownExport() {
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />}
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu>
<RundownWrapper />
</RundownContextMenu>
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<Editor.Title>Group schedule</Editor.Title>
<div className={style.inline}>
<div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput} disabled>
{millisToString(group.timeStart, { fallback: timerPlaceholder })}
@@ -2,16 +2,21 @@
display: flex;
align-items: center;
gap: 1rem;
height: 0;
position: relative;
height: 1px;
background: $blue-500;
z-index: $zindex-floating;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: $blue-500;
}
}
.addButton {
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
position: relative;
}
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
height: 100%;
}
.eventTimers.editMode:hover {
[class*="hoverLabel"] {
opacity: 1;
transition: opacity 0.15s 0.5s ease;
}
}
.chipSection {
grid-area: chip;
}
@@ -56,7 +56,6 @@ interface RundownEventProps {
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
createCloneEvent: () => void;
hasTriggers: boolean;
}
@@ -91,10 +90,9 @@ export default function RundownEvent({
totalGap,
isLinkedToLoaded,
hasTriggers,
createCloneEvent,
}: RundownEventProps) {
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 handleRef = useRef<null | HTMLSpanElement>(null);
@@ -172,7 +170,7 @@ export default function RundownEvent({
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
onClick: createCloneEvent,
onClick: () => clone(eventId, { after: eventId }),
},
{ type: 'divider' },
{
@@ -10,10 +10,12 @@ import {
IoTime,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
@@ -76,6 +78,11 @@ function RundownEventInner({
}: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false);
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
useEffect(() => {
setRenderInner(true);
}, []);
@@ -92,7 +99,7 @@ function RundownEventInner({
return !renderInner ? null : (
<>
<div className={style.eventTimers}>
<div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
@@ -10,14 +10,14 @@ import {
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
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 { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor';
import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaRow}>
<div className={style.metaEntry}>
<div>Start</div>
<div>{formatTime(data.timeStart)}</div>
<div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
</div>
<div className={style.metaEntry}>
<div>End</div>
<div>{formatTime(data.timeEnd)}</div>
<div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
</div>
<div className={style.metaEntry}>
<div>Duration</div>
@@ -15,3 +15,26 @@
.active {
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 (
<>
<div>
<div className={style.inputWrapper}>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeStart'
@@ -88,8 +89,9 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
<div>
<div className={style.inputWrapper}>
{showLabels && <Editor.Label>End time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeEnd'
@@ -110,8 +112,9 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
<div>
<div className={style.inputWrapper}>
{showLabels && <Editor.Label>Duration</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='duration'
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../common/utils/copyToClipboard';
import { preventEscape } from '../../common/utils/keyEvent';
import { linkToOtherHost } from '../../common/utils/linkUtils';
import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals';
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({
baseUrl: linkToOtherHost(options.baseUrl),
baseUrl: options.baseUrl,
path,
authenticate: options.authenticate,
lockConfig: options.lockConfig,
@@ -14,12 +14,18 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
const { data: infoData } = useInfo();
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(() => {
return infoData.networkInterfaces.map((nif) => ({
value: nif.address,
value: `http://${nif.address}:${infoData.serverPort}`,
label: `${nif.name} - ${nif.address}`,
}));
}, [infoData.networkInterfaces]);
}, [infoData.networkInterfaces, infoData.serverPort]);
const pathOptions = useMemo(() => {
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(
freezeEnd: boolean,
timerTypeNow: TimerType,
countToEndNow: boolean,
clock: number,
timerObject: Pick<TimerState, 'current' | 'elapsed'>,
timerTypeOverride?: TimerType,
@@ -22,13 +21,6 @@ export function getTimerByType(
const viewTimerType = timerTypeOverride ?? timerTypeNow;
if (countToEndNow) {
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (viewTimerType) {
case TimerType.CountDown:
if (timerObject.current === null) {
@@ -36,7 +28,7 @@ export function getTimerByType(
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
case TimerType.CountUp:
return Math.abs(timerObject.elapsed ?? 0);
return timerObject.elapsed;
case TimerType.Clock:
return clock;
case TimerType.None:
@@ -1,6 +1,7 @@
// from https://www.genomecolor.space/
$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-7: rgba(255, 255, 255, 0.07);
$white-9: rgba(255, 255, 255, 0.09);
+2 -2
View File
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
$ontime-stop: #E4281E;
$playback-negative: $red-500;
$playback-ahead: $green-500;
$active-indicator: #8bb33d;
$active-indicator: $green-400;
$text-black: $gray-1350;
$playback-over: #F57C13;
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
// interface text
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
$title-gray: $gray-200;
$label-gray: $gray-400;
$label-gray: $gray-600;
$secondary-text-gray: $gray-400;
$muted-gray: $gray-600;
$section-white: $ui-white;
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const scheduledStart = (() => {
if (showNow) 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 = (() => {
if (showNow) 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 });
@@ -5,14 +5,14 @@ import { getOffsetState } from '../../../common/utils/offset';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { cx } from '../../../common/utils/styleUtils';
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 './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format12: 'h:mm a',
format24: 'HH:mm',
};
@@ -84,9 +84,9 @@ function PlannedScheduleItem({
return (
<>
<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-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptPeriod time={start} />
<SuperscriptTime time={end} />
<SuperscriptPeriod time={end} />
</span>
<span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} />
<SuperscriptPeriod time={delayedStart} />
<SuperscriptTime time={delayedEnd} />
<SuperscriptPeriod time={delayedEnd} />
</span>
</>
);
@@ -161,5 +161,5 @@ interface ExpectedTimeProps {
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
const timeDisplay = formatTime(expectedTime);
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 Button from '../../common/components/buttons/Button';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
@@ -12,7 +13,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: PlayableEvent[];
events: ExtendedEntry<PlayableEvent>[];
subscriptions: EntryId[];
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__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 className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div>
@@ -1,9 +1,8 @@
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 { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
@@ -36,23 +35,6 @@ export const timerProgress: TimerMessage = {
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
* Handles events in different days but disregards whether an event has actually played
@@ -65,7 +47,11 @@ export function useSubscriptionDisplayData(
const bigDuration = (value: number) => {
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('s', getLocalizedString('common.seconds'));
};
@@ -90,7 +76,7 @@ export function useSubscriptionDisplayData(
return {
status: '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 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;
if (!isOntimeEvent(event)) {
@@ -62,7 +62,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
}
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;
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 className='label center'>Over / under</div>
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState && offsetState])}>
{schedule.offset}
</div>
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState])}>{schedule.offset}</div>
</div>
<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