mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 21:24:11 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00aa8a09fa | |||
| d04f958c11 | |||
| 5716fc31cf | |||
| a5d628af0c | |||
| 111a1f95ba | |||
| 4bf74d86b3 | |||
| d2638d64e4 | |||
| 02cddeec4d | |||
| a8fe8b5f1e | |||
| 8d2db7ffcd | |||
| 313590905f | |||
| 7bbdab7dd9 | |||
| 304832ecde | |||
| 0ab4abfcfe | |||
| 1847822f31 | |||
| 4499495212 | |||
| 0035070db8 | |||
| 890a97d071 | |||
| 5441e5b07f | |||
| ab4b94f2d8 | |||
| fd7ccb4a5b | |||
| 2e6bf234d5 | |||
| c934b0ecdd | |||
| cd6d6f24eb | |||
| e632a08557 | |||
| 0057b4a3f1 | |||
| ac8c1e333e | |||
| 321297b685 | |||
| 67e67d6a5d | |||
| 4385e0b9c9 | |||
| 02243242f1 | |||
| 1af42b766d | |||
| 9d6ec3758b | |||
| 9e78ff4d1c | |||
| 2037aef080 | |||
| 48b9cf042f | |||
| 1866e5e78d | |||
| bc1b3800c8 | |||
| a2505c838b | |||
| bffa863fb4 | |||
| d00d7677bc | |||
| 3ae73be562 | |||
| 763080cd4d | |||
| 6ff06b21a8 | |||
| adebde1b43 |
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
# Ignore build folders
|
# Ignore build folders
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
|
||||||
# Ignore default volumes created by running docker compose up
|
# Ignore default volumes created by running docker compose up
|
||||||
ontime-db
|
ontime-db
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Ontime Resolver build
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_cli:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project packages
|
||||||
|
run: pnpm turbo @getontime/resolver#build
|
||||||
|
|
||||||
|
- name: Publish to NPM
|
||||||
|
run: pnpm publish --access public --no-git-checks
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
|
working-directory: ./apps/resolver
|
||||||
@@ -99,3 +99,16 @@ Other useful commands
|
|||||||
|
|
||||||
- __List running processes__ by running `docker ps`
|
- __List running processes__ by running `docker ps`
|
||||||
- __Kill running process__ by running `docker kill <process-id>`
|
- __Kill running process__ by running `docker kill <process-id>`
|
||||||
|
|
||||||
|
## CONTRIBUTION GUIDELINES
|
||||||
|
|
||||||
|
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
|
||||||
|
|
||||||
|
For new PRs, please follow the following checklist:
|
||||||
|
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
|
||||||
|
* [ ] You have ran code formatting and linting in all your changes
|
||||||
|
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
|
||||||
|
* [ ] The PR body contains description and motivation for the changes
|
||||||
|
|
||||||
|
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
|
||||||
|
We will review as soon as possible
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.0.0-alpha.6",
|
"version": "4.0.0-beta.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
+11
-11
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-alpha.6",
|
"version": "4.0.0-beta.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui-components/react": "1.0.0-beta.2",
|
"@base-ui-components/react": "1.0.0-beta.3",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.2.6",
|
||||||
"@mantine/hooks": "^8.2.4",
|
"@mantine/hooks": "^8.2.8",
|
||||||
"@sentry/react": "^10.2.0",
|
"@sentry/react": "^10.2.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.84.1",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@tanstack/react-query-devtools": "^5.84.1",
|
"@tanstack/react-query-devtools": "^5.85.9",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router": "^7.8.0",
|
"react-router": "^7.8.2",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"react-virtuoso": "^4.14.0",
|
"react-virtuoso": "^4.14.0",
|
||||||
"web-vitals": "^5.1.0",
|
"web-vitals": "^5.1.0",
|
||||||
"zustand": "^5.0.7"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "4.5.1",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
if (newPath === '/' || newPath === currentPath) {
|
if (newPath === '/' || newPath === currentPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRedirect({ target: id, redirect: newPath });
|
|
||||||
|
if (newPath.startsWith('preset-')) {
|
||||||
|
setRedirect({ target: id, redirect: newPath.slice(7) });
|
||||||
|
} else {
|
||||||
|
setRedirect({ target: id, redirect: newPath });
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
label: view.label,
|
label: view.label,
|
||||||
})),
|
})),
|
||||||
...enabledPresets.map((preset) => ({
|
...enabledPresets.map((preset) => ({
|
||||||
value: preset.search,
|
value: `preset-${preset.alias}`,
|
||||||
label: `URL Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
<>
|
<>
|
||||||
<Info>
|
<Info>
|
||||||
Remotely redirect the client to a different URL. <br />
|
Remotely redirect the client to a different URL. <br />
|
||||||
Either by selecting a URL Preset or entering a custom path.
|
Either by entering a custom path or selecting a URL Preset.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||||
</Info>
|
</Info>
|
||||||
<div>
|
|
||||||
<span className={style.label}>Select View or URL Preset</span>
|
|
||||||
<div className={style.textEntry}>
|
|
||||||
<Select
|
|
||||||
fluid
|
|
||||||
options={viewOptions}
|
|
||||||
defaultValue={viewOptions[0].value}
|
|
||||||
onValueChange={(value) => setSelected(value)}
|
|
||||||
disabled={enabledPresets.length === 0}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant='primary'
|
|
||||||
aria-label='Redirect to preset'
|
|
||||||
className={style.redirect}
|
|
||||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
|
||||||
onClick={() => handleRedirect(selected)}
|
|
||||||
>
|
|
||||||
Redirect <IoArrowForward />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.inlineEntry}>
|
<div className={style.inlineEntry}>
|
||||||
<span className={style.label}>Enter custom path</span>
|
<span className={style.label}>Enter custom path</span>
|
||||||
<label className={style.textEntry}>
|
<label className={style.textEntry}>
|
||||||
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
className={style.redirect}
|
className={style.redirect}
|
||||||
onClick={() => handleRedirect(path)}
|
onClick={() => handleRedirect(path)}
|
||||||
>
|
>
|
||||||
Redirect <IoArrowForward />
|
Redirect
|
||||||
|
<IoArrowForward />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={style.label}>Select View or URL Preset</span>
|
||||||
|
<div className={style.inlineEntry}>
|
||||||
|
<label className={style.textEntry}>
|
||||||
|
{origin}
|
||||||
|
<Select
|
||||||
|
fluid
|
||||||
|
options={viewOptions}
|
||||||
|
defaultValue={viewOptions[0].value}
|
||||||
|
onValueChange={(value) => setSelected(value)}
|
||||||
|
disabled={enabledPresets.length === 0}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
aria-label='Redirect to preset'
|
||||||
|
className={style.redirect}
|
||||||
|
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||||
|
onClick={() => handleRedirect(selected)}
|
||||||
|
>
|
||||||
|
Redirect <IoArrowForward />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pin {
|
.pin {
|
||||||
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'multi-option') {
|
if (type === 'multi-option') {
|
||||||
return <MultiOption paramField={paramField} />;
|
const optionFromParams = searchParams.getAll(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiOption
|
||||||
|
paramField={paramField}
|
||||||
|
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
|
|
||||||
interface EditFormMultiOptionProps {
|
interface EditFormMultiOptionProps {
|
||||||
paramField: ParamField & { type: 'multi-option' };
|
paramField: ParamField & { type: 'multi-option' };
|
||||||
|
options: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||||
const [searchParams] = useSearchParams();
|
const { id, values } = paramField;
|
||||||
const { id, values, defaultValue = [''] } = paramField;
|
const [paramState, setParamState] = useState<string[]>(options);
|
||||||
|
|
||||||
const optionFromParams = searchParams.getAll(id);
|
|
||||||
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
|
|
||||||
|
|
||||||
|
// synchronise options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = searchParams.getAll(id);
|
setParamState(options);
|
||||||
setParamState(params.length ? params : defaultValue);
|
}, [options]);
|
||||||
}, [searchParams, id, defaultValue]);
|
|
||||||
|
|
||||||
const toggleValue = (value: string, checked: boolean) => {
|
const toggleValue = (value: string, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
||||||
width: 40rem;
|
width: 40rem;
|
||||||
|
max-width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
|||||||
/**
|
/**
|
||||||
* Creates data for a select element that displays project custom data
|
* Creates data for a select element that displays project custom data
|
||||||
*/
|
*/
|
||||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
export function makeProjectDataOptions(
|
||||||
return projectData.custom.map((entry, index) => {
|
projectData: ProjectData,
|
||||||
|
additionalOptions: SelectOption[] = [],
|
||||||
|
): SelectOption[] {
|
||||||
|
const generatedOptions = projectData.custom.map((entry, index) => {
|
||||||
return {
|
return {
|
||||||
value: `${index}-${entry.title}`,
|
value: `${index}-${entry.title}`,
|
||||||
label: entry.title,
|
label: entry.title,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...additionalOptions, ...generatedOptions];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
|
|||||||
|
|
||||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||||
|
|
||||||
|
// we call the function on mount, to make sure the menu is hidden
|
||||||
|
throttledShowMenu();
|
||||||
|
|
||||||
document.addEventListener('mousemove', throttledShowMenu);
|
document.addEventListener('mousemove', throttledShowMenu);
|
||||||
document.addEventListener('keydown', throttledShowMenu);
|
document.addEventListener('keydown', throttledShowMenu);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
import { RefObject, useCallback, useEffect } from 'react';
|
||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
import { useSelectedEventId } from './useSocket';
|
|
||||||
|
|
||||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: RefObject<ComponentRef>,
|
componentRef: RefObject<ComponentRef>,
|
||||||
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
|
|||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
|
||||||
componentRef: RefObject<ComponentRef>,
|
|
||||||
scrollRef: RefObject<ScrollRef>,
|
|
||||||
topOffset: number,
|
|
||||||
) {
|
|
||||||
if (!componentRef.current || !scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
|
||||||
|
|
||||||
// maintain current x scroll position
|
|
||||||
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFollowComponentProps {
|
interface UseFollowComponentProps {
|
||||||
followRef: RefObject<HTMLElement | null>;
|
followRef: RefObject<HTMLElement | null>;
|
||||||
scrollRef: RefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
setScrollFlag?: (newValue: boolean) => void;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
|
followTrigger?: MaybeString; // this would be an entry id or null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
export default function useFollowComponent({
|
||||||
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
followRef,
|
||||||
|
scrollRef,
|
||||||
// when cursor moves, view should follow
|
doFollow,
|
||||||
|
topOffset = 100,
|
||||||
|
setScrollFlag,
|
||||||
|
followTrigger,
|
||||||
|
}: UseFollowComponentProps) {
|
||||||
|
// when trigger moves, view should follow
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!doFollow) {
|
if (!doFollow || !followTrigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
setScrollFlag?.(false);
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect
|
|
||||||
}, [followRef?.current, scrollRef?.current]);
|
|
||||||
|
|
||||||
const scrollToRefComponent = useCallback(
|
const scrollToRefComponent = useCallback(
|
||||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
if (componentRef.current && containerRef.current) {
|
if (componentRef && containerRef) {
|
||||||
// @ts-expect-error -- we know this are not null
|
// @ts-expect-error -- we know this are not null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
scrollToComponent(componentRef!, scrollRef!, offset);
|
scrollToComponent(componentRef!, containerRef!, offset);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[followRef, scrollRef, topOffset],
|
[followRef, scrollRef, topOffset],
|
||||||
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
|
|
||||||
const selectedEvenId = useSelectedEventId();
|
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!doFollow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedEvenId && selectedRef.current && scrollRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
snapToComponent(
|
|
||||||
{ current: selectedRef.current } as RefObject<HTMLElement>,
|
|
||||||
{ current: scrollRef.current } as RefObject<HTMLElement>,
|
|
||||||
topOffset,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [doFollow, selectedEvenId, topOffset]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedRef,
|
|
||||||
scrollRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const createSelector =
|
|||||||
|
|
||||||
export const setClientRemote = {
|
export const setClientRemote = {
|
||||||
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
||||||
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
|
setRedirect: (payload: { target: string; redirect: string }) => {
|
||||||
|
sendSocket('client', payload);
|
||||||
|
},
|
||||||
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,24 +164,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
|
|||||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
|
|
||||||
plannedStart: state.rundown.plannedStart,
|
|
||||||
actualStart: state.rundown.actualStart,
|
|
||||||
plannedEnd: state.rundown.plannedEnd,
|
|
||||||
expectedEnd: state.offset.expectedRundownEnd,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
|
||||||
playback: state.timer.playback,
|
|
||||||
clock: state.clock,
|
|
||||||
|
|
||||||
numEvents: state.rundown.numEvents,
|
|
||||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
|
||||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
|
||||||
|
|
||||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
offset: state.offset.absolute,
|
offset: state.offset.absolute,
|
||||||
@@ -188,7 +172,7 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
|||||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
actualStart: state.rundown.actualStart,
|
actualStart: state.rundown.actualStart,
|
||||||
plannedStart: state.rundown.plannedStart,
|
plannedStart: state.rundown.plannedStart,
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
@@ -225,6 +209,45 @@ export const usePlayback = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ======================= Overview data subscriptions ======================= */
|
||||||
|
|
||||||
|
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
plannedStart: state.rundown.plannedStart,
|
||||||
|
actualStart: state.rundown.actualStart,
|
||||||
|
plannedEnd: state.rundown.plannedEnd,
|
||||||
|
expectedEnd: state.offset.expectedRundownEnd,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
numEvents: state.rundown.numEvents,
|
||||||
|
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
|
clock: state.clock,
|
||||||
|
mode: state.offset.mode,
|
||||||
|
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||||
|
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||||
|
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
|
clock: state.clock,
|
||||||
|
mode: state.offset.mode,
|
||||||
|
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||||
|
actualStart: state.rundown.actualStart ?? 0,
|
||||||
|
plannedStart: state.rundown.plannedStart ?? 0,
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
/* ======================= View specific subscriptions ======================= */
|
/* ======================= View specific subscriptions ======================= */
|
||||||
|
|
||||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
export type OverridableOptions = {
|
|
||||||
keyColour?: string;
|
|
||||||
textColour?: string;
|
|
||||||
textBackground?: string;
|
|
||||||
font?: string;
|
|
||||||
size?: number;
|
|
||||||
justifyContent?: 'start' | 'center' | 'end';
|
|
||||||
alignItems?: 'start' | 'center' | 'end';
|
|
||||||
left?: string;
|
|
||||||
top?: string;
|
|
||||||
hideNav?: boolean;
|
|
||||||
hideOvertime?: boolean;
|
|
||||||
hideMessagesOverlay?: boolean;
|
|
||||||
hideEndMessage?: boolean;
|
|
||||||
language?: string;
|
|
||||||
showProgressBar?: boolean;
|
|
||||||
hideTimerSeconds?: boolean;
|
|
||||||
removeLeadingZeros?: boolean;
|
|
||||||
};
|
|
||||||
@@ -173,7 +173,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 10,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
groupColour: 'red',
|
groupColour: 'red',
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ function processEntry(
|
|||||||
if (entry.id === selectedEventId) {
|
if (entry.id === selectedEventId) {
|
||||||
processedData.isLoaded = true;
|
processedData.isLoaded = true;
|
||||||
processedData.isPast = false;
|
processedData.isPast = false;
|
||||||
|
processedData.isLinkedToLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
@@ -158,7 +159,8 @@ function processEntry(
|
|||||||
* a) find an unlinked event
|
* a) find an unlinked event
|
||||||
* b) find a countToEnd event
|
* b) find a countToEnd event
|
||||||
*/
|
*/
|
||||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
processedData.isLinkedToLoaded =
|
||||||
|
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
ApiAction,
|
ApiActionTag,
|
||||||
Log,
|
Log,
|
||||||
MessageTag,
|
MessageTag,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
@@ -199,7 +199,7 @@ export const connectSocket = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sendSocket<T extends MessageTag | ApiAction>(
|
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||||
tag: T,
|
tag: T,
|
||||||
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
|
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
+5
-1
@@ -14,4 +14,8 @@
|
|||||||
.copiable {
|
.copiable {
|
||||||
cursor: text ;
|
cursor: text ;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.self {
|
||||||
|
background-color: $blue-1100;
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
|
|||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||||
|
import { cx } from '../../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ClientControlPanel.module.scss';
|
import style from './ClientControlPanel.module.scss';
|
||||||
@@ -71,13 +72,13 @@ export default function ClientList() {
|
|||||||
const { identify, name, path } = client;
|
const { identify, name, path } = client;
|
||||||
const isCurrent = id === key;
|
const isCurrent = id === key;
|
||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
{isCurrent && <Tag>SELF</Tag>}
|
{isCurrent && <Tag>SELF</Tag>}
|
||||||
{name}
|
{name}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<td className={style.copiable}>{path}</td>
|
<td className={style.copiable}>{path}</td>
|
||||||
<Panel.InlineElements relation='inner'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
className={`${identify ? style.blink : ''}`}
|
className={`${identify ? style.blink : ''}`}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function Operator() {
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
topOffset: selectedOffset,
|
topOffset: selectedOffset,
|
||||||
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useWindowTitle('Operator');
|
useWindowTitle('Operator');
|
||||||
@@ -181,7 +182,7 @@ export default function Operator() {
|
|||||||
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
|
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
|
||||||
|
|
||||||
// hide past events (if setting) and skipped events
|
// hide past events (if setting) and skipped events
|
||||||
if (hidePast && isPast) {
|
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
|
|||||||
|
|
||||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||||
const fieldOptions = makeOptionsFromCustomFields(customFields, [
|
const fieldOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'title', label: 'Title' },
|
{ value: 'title', label: 'Title' },
|
||||||
{ value: 'note', label: 'Note' },
|
{ value: 'note', label: 'Note' },
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +39,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
|||||||
description: 'Field to be shown in the second line of text',
|
description: 'Field to be shown in the second line of text',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: fieldOptions,
|
values: fieldOptions,
|
||||||
defaultValue: '',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subscribe',
|
id: 'subscribe',
|
||||||
|
|||||||
@@ -47,9 +47,10 @@
|
|||||||
|
|
||||||
.daySpan {
|
.daySpan {
|
||||||
&::after {
|
&::after {
|
||||||
content: '*';
|
content: "+"attr(data-day-offset);
|
||||||
vertical-align: super;
|
vertical-align: super;
|
||||||
font-size: 0.75em;
|
font-size: 0.6em;
|
||||||
|
letter-spacing: 0;
|
||||||
color: $info-blue;
|
color: $info-blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,3 +68,10 @@
|
|||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dueTime {
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: $playback-over;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,23 +8,26 @@ import {
|
|||||||
TbFolderPin,
|
TbFolderPin,
|
||||||
TbFolderStar,
|
TbFolderStar,
|
||||||
} from 'react-icons/tb';
|
} from 'react-icons/tb';
|
||||||
import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
|
import { OffsetMode, OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
|
||||||
import { isPlaybackActive, millisToString } from 'ontime-utils';
|
import { dayInMs, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import {
|
import {
|
||||||
useClock,
|
useClock,
|
||||||
useCurrentGroupId,
|
useCurrentGroupId,
|
||||||
|
useFlagTimerOverView,
|
||||||
|
useGroupTimerOverView,
|
||||||
useNextFlag,
|
useNextFlag,
|
||||||
|
useOffsetOverview,
|
||||||
|
useProgressOverview,
|
||||||
useRundownOverview,
|
useRundownOverview,
|
||||||
useRuntimePlaybackOverview,
|
|
||||||
useTimer,
|
useTimer,
|
||||||
} from '../../../common/hooks/useSocket';
|
} from '../../../common/hooks/useSocket';
|
||||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||||
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
||||||
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils';
|
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils';
|
||||||
|
|
||||||
import { OverUnder, TimeColumn } from './TimeLayout';
|
import { OverUnder, TimeColumn } from './TimeLayout';
|
||||||
|
|
||||||
@@ -58,8 +61,8 @@ export function StartTimes() {
|
|||||||
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
|
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
|
||||||
{maybePlannedDaySpan > 0 ? (
|
{maybePlannedDaySpan > 0 ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
|
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
|
||||||
render={<span className={cx([style.time, style.daySpan])} />}
|
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
|
||||||
>
|
>
|
||||||
{plannedEndText}
|
{plannedEndText}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -71,8 +74,8 @@ export function StartTimes() {
|
|||||||
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
|
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
|
||||||
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
|
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={`Event spans over ${maybeExpectedDaySpan + 1} days`}
|
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
|
||||||
render={<span className={cx([style.time, style.daySpan])} />}
|
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
|
||||||
>
|
>
|
||||||
{formattedTime(maybeExpectedEnd)}
|
{formattedTime(maybeExpectedEnd)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -96,61 +99,110 @@ export function MetadataTimes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
|
|
||||||
function GroupTimes() {
|
function GroupTimes() {
|
||||||
const { clock, groupExpectedEnd } = useRuntimePlaybackOverview();
|
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
|
||||||
const { currentGroupId } = useCurrentGroupId();
|
const { currentGroupId } = useCurrentGroupId();
|
||||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||||
|
|
||||||
// the group end time dose not encode any day offsets
|
const active = isPlaybackActive(playback);
|
||||||
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
|
|
||||||
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
|
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
|
||||||
|
const plannedGroupEnd = (() => {
|
||||||
|
if (!active) return null;
|
||||||
|
if (!group || group.timeStart === null) return null;
|
||||||
|
const normalizedClock = clock + currentDay * dayInMs;
|
||||||
|
|
||||||
|
return mode === OffsetMode.Absolute
|
||||||
|
? group.timeStart + group.duration - normalizedClock
|
||||||
|
: actualGroupStart + group.duration - normalizedClock;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||||
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
|
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const groupTitle = group?.title ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.metadataRow}>
|
<div className={style.metadataRow}>
|
||||||
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
|
<span className={group?.title ? style.labelTitle : style.label}>{`${group?.title || 'Group'} `}</span>
|
||||||
<div className={style.labelledElement}>
|
<div className={style.labelledElement}>
|
||||||
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
|
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
|
||||||
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
|
<span
|
||||||
|
className={cx([
|
||||||
|
style.time,
|
||||||
|
(!group || !active) && style.muted,
|
||||||
|
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{plannedTimeUntilGroupEnd}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.labelledElement}>
|
<div className={style.labelledElement}>
|
||||||
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
|
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
|
||||||
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
|
<span
|
||||||
|
className={cx([
|
||||||
|
style.time,
|
||||||
|
!groupExpectedEnd && style.muted,
|
||||||
|
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{expectedTimeUntilGroupEnd}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlagTimes() {
|
function FlagTimes() {
|
||||||
const { clock } = useClock();
|
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
|
||||||
const { id, expectedStart } = useNextFlag();
|
const { id, expectedStart } = useNextFlag();
|
||||||
const entry = useEntry(id) as OntimeEvent | null;
|
const entry = useEntry(id) as OntimeEvent | null;
|
||||||
|
|
||||||
const plannedFlagStart = entry ? entry.timeStart - clock : null;
|
const active = isPlaybackActive(playback);
|
||||||
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
|
|
||||||
|
const plannedFlagStart = (() => {
|
||||||
|
if (!active) return null;
|
||||||
|
if (!entry) return null;
|
||||||
|
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
|
||||||
|
const normalizedClock = clock + currentDay * dayInMs;
|
||||||
|
return mode === OffsetMode.Absolute
|
||||||
|
? normalizedTimeStart - normalizedClock
|
||||||
|
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
|
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
|
||||||
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown);
|
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const title = entry?.title ?? null;
|
const title = entry?.title ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.metadataRow}>
|
<div className={style.metadataRow}>
|
||||||
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
|
<span className={title ? style.labelTitle : style.label}>{`${title || 'Flag'} `}</span>
|
||||||
<div className={style.labelledElement}>
|
<div className={style.labelledElement}>
|
||||||
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
|
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
|
||||||
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
|
<span
|
||||||
|
data-testid='flag-plannedStart'
|
||||||
|
className={cx([
|
||||||
|
style.time,
|
||||||
|
(!entry || !active) && style.muted,
|
||||||
|
plannedTimeUntilDisplay === 'due' && style.dueTime,
|
||||||
|
])}
|
||||||
|
>
|
||||||
{plannedTimeUntilDisplay}
|
{plannedTimeUntilDisplay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.labelledElement}>
|
<div className={style.labelledElement}>
|
||||||
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
|
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
|
||||||
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
|
<span
|
||||||
|
data-testid='flag-expectedStart'
|
||||||
|
className={cx([
|
||||||
|
style.time,
|
||||||
|
expectedTimeUntil === null && style.muted,
|
||||||
|
expectedTimeUntilDisplay === 'due' && style.dueTime,
|
||||||
|
])}
|
||||||
|
>
|
||||||
{expectedTimeUntilDisplay}
|
{expectedTimeUntilDisplay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +211,7 @@ function FlagTimes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressOverview() {
|
export function ProgressOverview() {
|
||||||
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
|
const { numEvents, selectedEventIndex } = useProgressOverview();
|
||||||
|
|
||||||
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
|
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
|
||||||
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
|
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
|
||||||
@@ -168,7 +220,7 @@ export function ProgressOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OffsetOverview() {
|
export function OffsetOverview() {
|
||||||
const { offset, playback } = useRuntimePlaybackOverview();
|
const { offset, playback } = useOffsetOverview();
|
||||||
|
|
||||||
const isPlaying = isPlaybackActive(playback);
|
const isPlaying = isPlaybackActive(playback);
|
||||||
const offsetState = getOffsetState(isPlaying ? offset : null);
|
const offsetState = getOffsetState(isPlaying ? offset : null);
|
||||||
@@ -179,8 +231,9 @@ export function OffsetOverview() {
|
|||||||
|
|
||||||
export function ClockOverview({ className }: { className?: string }) {
|
export function ClockOverview({ className }: { className?: string }) {
|
||||||
const { clock } = useClock();
|
const { clock } = useClock();
|
||||||
|
const formattedClock = formatTime(clock);
|
||||||
|
|
||||||
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
|
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimerOverview({ className }: { className?: string }) {
|
export function TimerOverview({ className }: { className?: string }) {
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { dayInMs, millisToString } from 'ontime-utils';
|
|||||||
|
|
||||||
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition to stop negative timers from being formatted
|
||||||
|
* They should show a due string instead
|
||||||
|
*
|
||||||
|
* This is used for cases when a negative timer is unwanted
|
||||||
|
* eg: count down to a milestone
|
||||||
|
*/
|
||||||
|
export function formatDueTime(
|
||||||
|
time: MaybeNumber,
|
||||||
|
segments: number = 3,
|
||||||
|
direction?: TimerType.CountDown | TimerType.CountUp,
|
||||||
|
dueString = 'due',
|
||||||
|
): string {
|
||||||
|
if (time !== null && time <= 0) return dueString;
|
||||||
|
return formattedTime(time, segments, direction);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the logic for formatting time in overview
|
* Encapsulates the logic for formatting time in overview
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
|
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: editorMode === AppMode.Run });
|
useFollowComponent({
|
||||||
|
followRef: cursorRef,
|
||||||
|
scrollRef,
|
||||||
|
doFollow: true,
|
||||||
|
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
|
||||||
|
});
|
||||||
|
|
||||||
// DND KIT
|
// DND KIT
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||||
@@ -312,7 +317,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
setMetadata(rundownMetadata);
|
setMetadata(rundownMetadata);
|
||||||
}, [order, entries, rundownMetadata]);
|
}, [order, entries, rundownMetadata]);
|
||||||
|
|
||||||
// in run mode, we follow selection
|
// in run mode, we follow the playback selection and open groups as needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
|
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -28,20 +28,22 @@
|
|||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
box-shadow: $box-shadow-right;
|
box-shadow: $box-shadow-right;
|
||||||
|
|
||||||
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
|
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
||||||
min-width: 38rem;
|
min-width: 38rem;
|
||||||
max-width: 60rem;
|
max-width: none;
|
||||||
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side {
|
.side {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
max-width: 45rem;
|
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
background-color: $gray-1325;
|
background-color: $gray-1325;
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
|
|
||||||
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
|
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
||||||
max-width: 45rem;
|
// width is locked to swatch picker elements
|
||||||
|
min-width: calc(15 * 2rem + 13 * 0.5rem);
|
||||||
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ interface EventUntilProps {
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventUntil(props: EventUntilProps) {
|
function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: EventUntilProps) {
|
||||||
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
|
|
||||||
|
|
||||||
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
|
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
|
||||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ export function canDrop(
|
|||||||
order?: 'after' | 'before',
|
order?: 'after' | 'before',
|
||||||
isTargetCollapsed?: boolean,
|
isTargetCollapsed?: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
// this would mean inserting a group inside another
|
// inserting before would mean adding a group inside another
|
||||||
if (targetType === 'end-group') {
|
if (targetType === 'end-group') {
|
||||||
return false;
|
return order === 'after';
|
||||||
}
|
}
|
||||||
|
|
||||||
// this means swapping places with another group
|
// this means swapping places with another group
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
|||||||
{ value: OntimeView.Timer, label: 'Timer' },
|
{ value: OntimeView.Timer, label: 'Timer' },
|
||||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||||
{ value: OntimeView.Operator, label: 'Operator' },
|
{ value: OntimeView.Operator, label: 'Operator' },
|
||||||
{ value: '', label: 'Companion' },
|
{ value: '<<companion>>', label: 'Companion' },
|
||||||
...urlPresetData.map((preset) => ({
|
...urlPresetData.map((preset) => ({
|
||||||
value: `preset-${preset.alias}`,
|
value: `preset-${preset.alias}`,
|
||||||
label: `URL Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
|
|||||||
/NetworkError/i,
|
/NetworkError/i,
|
||||||
/The operation couldn't be completed/i,
|
/The operation couldn't be completed/i,
|
||||||
],
|
],
|
||||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /external\//i],
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
// Drop errors that happen during known data-unavailable states
|
// Drop errors that happen during known data-unavailable states
|
||||||
const error = event.exception?.values?.[0]?.value;
|
const error = event.exception?.values?.[0]?.value;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ $header-font-size: clamp(24px, 2.5vw, 48px);
|
|||||||
|
|
||||||
// General styling
|
// General styling
|
||||||
$accent-color: $red-500; // --accent-color-override
|
$accent-color: $red-500; // --accent-color-override
|
||||||
$delay-color: $ontime-delay-text;
|
|
||||||
$viewer-label-color: rgba(white, 25%);
|
$viewer-label-color: rgba(white, 25%);
|
||||||
|
|
||||||
// Main Properties of a viewer
|
// Main Properties of a viewer
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
export const langDe: TranslationObject = {
|
export const langDe: TranslationObject = {
|
||||||
'common.expected_finish': 'Erwartetes Ende',
|
'common.expected_finish': 'Erwartetes Ende',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
|
'common.seconds': 'sek',
|
||||||
'common.now': 'Jetzt',
|
'common.now': 'Jetzt',
|
||||||
'common.next': 'Nächste',
|
'common.next': 'Nächste',
|
||||||
'common.scheduled_start': 'Geplanter beginn',
|
'common.scheduled_start': 'Geplanter beginn',
|
||||||
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
|
|||||||
'common.no_data': 'Keine Daten',
|
'common.no_data': 'Keine Daten',
|
||||||
'countdown.ended': 'Veranstaltung endete um',
|
'countdown.ended': 'Veranstaltung endete um',
|
||||||
'countdown.running': 'Veranstaltung läuft',
|
'countdown.running': 'Veranstaltung läuft',
|
||||||
|
'countdown.loaded': 'Veranstaltung geladen',
|
||||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||||
'countdown.to_start': 'Zeit bis zum Start',
|
'countdown.to_start': 'Zeit bis zum Start',
|
||||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
export const langEs: TranslationObject = {
|
export const langEs: TranslationObject = {
|
||||||
'common.expected_finish': 'Finalización esperada',
|
'common.expected_finish': 'Finalización esperada',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
|
'common.seconds': 'sec',
|
||||||
'common.now': 'Ahora',
|
'common.now': 'Ahora',
|
||||||
'common.next': 'Siguiente',
|
'common.next': 'Siguiente',
|
||||||
'common.scheduled_start': 'Inicio programado',
|
'common.scheduled_start': 'Inicio programado',
|
||||||
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
|
|||||||
'common.no_data': 'Sin datos',
|
'common.no_data': 'Sin datos',
|
||||||
'countdown.ended': 'Evento finalizado a las',
|
'countdown.ended': 'Evento finalizado a las',
|
||||||
'countdown.running': 'Evento en curso',
|
'countdown.running': 'Evento en curso',
|
||||||
|
'countdown.loaded': 'Evento está cargado', //TODO: check translation
|
||||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||||
'countdown.to_start': 'Tiempo para comenzar',
|
'countdown.to_start': 'Tiempo para comenzar',
|
||||||
'countdown.waiting': 'Esperando el inicio del evento',
|
'countdown.waiting': 'Esperando el inicio del evento',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
export const langFr: TranslationObject = {
|
export const langFr: TranslationObject = {
|
||||||
'common.expected_finish': 'Fin estimée à',
|
'common.expected_finish': 'Fin estimée à',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
|
'common.seconds': 'sec',
|
||||||
'common.now': 'Maintenant',
|
'common.now': 'Maintenant',
|
||||||
'common.next': 'A suivre',
|
'common.next': 'A suivre',
|
||||||
'common.scheduled_start': 'Début prévu',
|
'common.scheduled_start': 'Début prévu',
|
||||||
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
|
|||||||
'common.no_data': 'Aucune donnée',
|
'common.no_data': 'Aucune donnée',
|
||||||
'countdown.ended': 'Évènement terminé à',
|
'countdown.ended': 'Évènement terminé à',
|
||||||
'countdown.running': 'Évènement en cours',
|
'countdown.running': 'Évènement en cours',
|
||||||
|
'countdown.loaded': 'Évènement chargé',
|
||||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
||||||
'countdown.to_start': 'Évènement commence dans',
|
'countdown.to_start': 'Évènement commence dans',
|
||||||
'countdown.waiting': 'En attente du début de l’évènement',
|
'countdown.waiting': 'En attente du début de l’évènement',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
export const langIt: TranslationObject = {
|
export const langIt: TranslationObject = {
|
||||||
'common.expected_finish': 'Fine Prevista',
|
'common.expected_finish': 'Fine Prevista',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
|
'common.seconds': 'sec',
|
||||||
'common.now': 'Adesso',
|
'common.now': 'Adesso',
|
||||||
'common.next': 'Prossimo',
|
'common.next': 'Prossimo',
|
||||||
'common.scheduled_start': 'Inizio programmato',
|
'common.scheduled_start': 'Inizio programmato',
|
||||||
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
|
|||||||
'common.no_data': 'Nessun dato disponibile',
|
'common.no_data': 'Nessun dato disponibile',
|
||||||
'countdown.ended': 'Evento finito alle',
|
'countdown.ended': 'Evento finito alle',
|
||||||
'countdown.running': 'Evento in corso',
|
'countdown.running': 'Evento in corso',
|
||||||
|
'countdown.loaded': 'Evento caricato', //TODO: check translation
|
||||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||||
'countdown.to_start': 'Tempo alla partenza',
|
'countdown.to_start': 'Tempo alla partenza',
|
||||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
export const langPt: TranslationObject = {
|
export const langPt: TranslationObject = {
|
||||||
'common.expected_finish': 'Término esperado',
|
'common.expected_finish': 'Término esperado',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
|
'common.seconds': 'sec',
|
||||||
'common.now': 'Agora',
|
'common.now': 'Agora',
|
||||||
'common.next': 'Próximo',
|
'common.next': 'Próximo',
|
||||||
'common.scheduled_start': 'Início programado',
|
'common.scheduled_start': 'Início programado',
|
||||||
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
|
|||||||
'common.no_data': 'Sem dados',
|
'common.no_data': 'Sem dados',
|
||||||
'countdown.ended': 'Evento encerrado às',
|
'countdown.ended': 'Evento encerrado às',
|
||||||
'countdown.running': 'Evento em andamento',
|
'countdown.running': 'Evento em andamento',
|
||||||
|
'countdown.loaded': 'Evento carregado', //TODO: check translation
|
||||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||||
'countdown.to_start': 'Tempo para iniciar',
|
'countdown.to_start': 'Tempo para iniciar',
|
||||||
'countdown.waiting': 'Aguardando o início do evento',
|
'countdown.waiting': 'Aguardando o início do evento',
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ import {
|
|||||||
makeProjectDataOptions,
|
makeProjectDataOptions,
|
||||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import { scheduleOptions } from '../common/schedule/schedule.options';
|
import { getScheduleOptions } from '../common/schedule/schedule.options';
|
||||||
|
|
||||||
export const getBackstageOptions = (
|
export const getBackstageOptions = (
|
||||||
timeFormat: string,
|
timeFormat: string,
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
projectData: ProjectData,
|
projectData: ProjectData,
|
||||||
): ViewOption[] => {
|
): ViewOption[] => {
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
|
||||||
const projectDataOptions = makeProjectDataOptions(projectData);
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'note', label: 'Note' },
|
||||||
|
]);
|
||||||
|
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||||
@@ -32,11 +36,11 @@ export const getBackstageOptions = (
|
|||||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: secondaryOptions,
|
values: secondaryOptions,
|
||||||
defaultValue: '',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
scheduleOptions,
|
getScheduleOptions(customFieldOptions),
|
||||||
{
|
{
|
||||||
title: OptionTitle.ElementVisibility,
|
title: OptionTitle.ElementVisibility,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
@@ -47,7 +51,7 @@ export const getBackstageOptions = (
|
|||||||
description: 'Select a project data source to show in the view',
|
description: 'Select a project data source to show in the view',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: projectDataOptions,
|
values: projectDataOptions,
|
||||||
defaultValue: '',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import {
|
import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
createContext,
|
import { EntryId, isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
|
||||||
PropsWithChildren,
|
|
||||||
RefObject,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
@@ -25,13 +16,18 @@ interface ScheduleContextState {
|
|||||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||||
|
|
||||||
interface ScheduleProviderProps {
|
interface ScheduleProviderProps {
|
||||||
selectedEventId: string | null;
|
selectedEventId: EntryId | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
|
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
|
||||||
const { cycleInterval, stopCycle } = useScheduleOptions();
|
const { cycleInterval, stopCycle, filter } = useScheduleOptions();
|
||||||
const { data: events } = usePartialRundown((event: OntimeEntry) => {
|
const { data: events } = usePartialRundown((entry: OntimeEntry) => {
|
||||||
return isOntimeEvent(event);
|
if (filter) {
|
||||||
|
// custom keys are prepended with custom-
|
||||||
|
const customKey = filter.startsWith('custom-') ? filter.slice('custom-'.length) : filter;
|
||||||
|
return isOntimeEvent(entry) && Boolean(entry.custom[customKey]);
|
||||||
|
}
|
||||||
|
return isOntimeEvent(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [firstIndex, setFirstIndex] = useState(-1);
|
const [firstIndex, setFirstIndex] = useState(-1);
|
||||||
@@ -135,7 +131,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
|||||||
selectedEventIndex = 0;
|
selectedEventIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScheduleContext.Provider
|
<ScheduleContext
|
||||||
value={{
|
value={{
|
||||||
events: viewEvents as OntimeEvent[],
|
events: viewEvents as OntimeEvent[],
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
@@ -145,12 +141,12 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScheduleContext.Provider>
|
</ScheduleContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSchedule = () => {
|
export const useSchedule = () => {
|
||||||
const context = useContext(ScheduleContext);
|
const context = use(ScheduleContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ interface ScheduleItemProps {
|
|||||||
delay: number;
|
delay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
|
||||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
|
||||||
const { showExpected } = useScheduleOptions();
|
const { showExpected } = useScheduleOptions();
|
||||||
|
|
||||||
if (showExpected) {
|
if (showExpected) {
|
||||||
@@ -67,9 +66,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayedScheduleItem(props: ScheduleItemProps) {
|
function DelayedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
|
||||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
|
||||||
|
|
||||||
const start = formatTime(timeStart, formatOptions);
|
const start = formatTime(timeStart, formatOptions);
|
||||||
const end = formatTime(timeEnd, formatOptions);
|
const end = formatTime(timeEnd, formatOptions);
|
||||||
const delayedStart = formatTime(timeStart + delay, formatOptions);
|
const delayedStart = formatTime(timeStart + delay, formatOptions);
|
||||||
@@ -95,9 +92,7 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpectedScheduleItem(props: ScheduleItemProps) {
|
function ExpectedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
|
||||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||||
<div className='entry-times'>
|
<div className='entry-times'>
|
||||||
@@ -116,12 +111,11 @@ interface ExpectedTimeProps {
|
|||||||
delay: number;
|
delay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpectedTime(props: ExpectedTimeProps) {
|
function ExpectedTime({ time, delay }: ExpectedTimeProps) {
|
||||||
const { time, delay } = props;
|
|
||||||
const { offset } = useRuntimeOffset();
|
const { offset } = useRuntimeOffset();
|
||||||
|
|
||||||
const expectedOffset = offset - delay;
|
const expectedOffset = offset - delay;
|
||||||
const expectedTime = formatTime(time - offset, formatOptions);
|
const expectedTime = formatTime(time + offset, formatOptions);
|
||||||
const expectedState = getOffsetState(expectedOffset);
|
const expectedState = getOffsetState(expectedOffset);
|
||||||
|
|
||||||
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
|
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { SelectOption } from '../../../common/components/select/Select';
|
||||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
export const scheduleOptions: ViewOption = {
|
export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOption => ({
|
||||||
title: OptionTitle.Schedule,
|
title: OptionTitle.Schedule,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
id: 'filter',
|
||||||
|
title: 'Filter',
|
||||||
|
description: 'Hide events without data in the selected custom field',
|
||||||
|
type: 'option',
|
||||||
|
values: customFieldOptions,
|
||||||
|
defaultValue: 'None',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'stopCycle',
|
id: 'stopCycle',
|
||||||
title: 'Stop cycling through event pages',
|
title: 'Stop cycling through event pages',
|
||||||
@@ -31,9 +40,10 @@ export const scheduleOptions: ViewOption = {
|
|||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
type ScheduleOptions = {
|
type ScheduleOptions = {
|
||||||
|
filter: string | null;
|
||||||
cycleInterval: number;
|
cycleInterval: number;
|
||||||
stopCycle: boolean;
|
stopCycle: boolean;
|
||||||
showExpected: boolean;
|
showExpected: boolean;
|
||||||
@@ -41,6 +51,7 @@ type ScheduleOptions = {
|
|||||||
|
|
||||||
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
|
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
|
||||||
return {
|
return {
|
||||||
|
filter: searchParams.get('filter'),
|
||||||
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
|
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
|
||||||
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
|
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
|
||||||
showExpected: isStringBoolean(searchParams.get('showExpected')),
|
showExpected: isStringBoolean(searchParams.get('showExpected')),
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sub--live {
|
.sub--live {
|
||||||
background-color: $green-700;
|
background-color: $active-green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub--armed {
|
||||||
|
background-color: $gray-1325;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub__binder {
|
.sub__binder {
|
||||||
@@ -137,15 +141,19 @@ $item-height: 3.5rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sub__schedule--delayed {
|
.sub__schedule--delayed {
|
||||||
color: $delay-color;
|
color: $ontime-delay-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub__schedule--strike {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub__schedule--over {
|
.sub__schedule--over {
|
||||||
color: $playback-over
|
color: $playback-over;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub__schedule--under {
|
.sub__schedule--under {
|
||||||
color: $playback-under
|
color: $playback-under;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub__title {
|
.sub__title {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
|||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useClock } from '../../common/hooks/useSocket';
|
import { useClock } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
|
|||||||
return <Countdown {...data} />;
|
return <Countdown {...data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) {
|
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const { subscriptions } = useCountdownOptions();
|
const { subscriptions } = useCountdownOptions();
|
||||||
|
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
// gather rundown data
|
// gather rundown data
|
||||||
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
|
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
|
||||||
|
|
||||||
// gather presentation data
|
// gather presentation data
|
||||||
const hasEvents = playableEvents.length > 0;
|
const hasEvents = playableEvents.length > 0;
|
||||||
@@ -85,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CountdownContentsProps {
|
interface CountdownContentsProps {
|
||||||
playableEvents: OntimeEvent[];
|
playableEvents: ExtendedEntry<OntimeEvent>[];
|
||||||
subscriptions: EntryId[];
|
subscriptions: EntryId[];
|
||||||
goToEditMode: () => void;
|
goToEditMode: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import {
|
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||||
useCountdownSocket,
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
useCurrentDay,
|
|
||||||
usePlayback,
|
|
||||||
useRuntimeOffset,
|
|
||||||
useSelectedEventId,
|
|
||||||
} from '../../common/hooks/useSocket';
|
|
||||||
import { getOffsetState } from '../../common/utils/offset';
|
import { getOffsetState } from '../../common/utils/offset';
|
||||||
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../../common/utils/throttle';
|
||||||
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||||
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
|
||||||
|
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
import {
|
||||||
|
CountdownEvent,
|
||||||
|
extendEventData,
|
||||||
|
getIsLive,
|
||||||
|
isOutsideRange,
|
||||||
|
preferredFormat12,
|
||||||
|
preferredFormat24,
|
||||||
|
useSubscriptionDisplayData,
|
||||||
|
} from './countdown.utils';
|
||||||
|
|
||||||
import './Countdown.scss';
|
import './Countdown.scss';
|
||||||
|
|
||||||
interface CountdownSubscriptionsProps {
|
interface CountdownSubscriptionsProps {
|
||||||
subscribedEvents: OntimeEvent[];
|
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||||||
goToEditMode: () => void;
|
goToEditMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const { selectedEventId } = useSelectedEventId();
|
const { selectedEventId } = useSelectedEventId();
|
||||||
const showFab = useFadeOutOnInactivity(true);
|
const showFab = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
|
const { data: reportData } = useReport();
|
||||||
|
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||||
|
|
||||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -45,6 +53,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
topOffset: 0,
|
topOffset: 0,
|
||||||
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// reset scroll if nothing is selected
|
// reset scroll if nothing is selected
|
||||||
@@ -92,23 +101,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
{subscribedEvents.map((event) => {
|
{subscribedEvents.map((event) => {
|
||||||
const secondaryData = getPropertyValue(event, secondarySource);
|
const secondaryData = getPropertyValue(event, secondarySource);
|
||||||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||||||
|
const isArmed = !isLive && event.id === selectedEventId;
|
||||||
|
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||||
|
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||||
return (
|
return (
|
||||||
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
|
<div
|
||||||
|
key={event.id}
|
||||||
|
ref={isLive ? selectedRef : undefined}
|
||||||
|
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||||||
|
>
|
||||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||||
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
|
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||||
{showExpected ? (
|
<SubscriptionStatus event={countdownEvent} />
|
||||||
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
|
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
|
||||||
→
|
|
||||||
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
|
|
||||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
|
|
||||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -123,63 +128,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpectedScheduleProps {
|
type ScheduleTimeProps = {
|
||||||
timeStart: number;
|
event: CountdownEvent;
|
||||||
timeEnd: number;
|
showExpected: boolean;
|
||||||
delay: number;
|
};
|
||||||
}
|
//TODO: consider relative mode
|
||||||
function ExpectedSchedule(props: ExpectedScheduleProps) {
|
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||||
const { timeStart, timeEnd, delay } = props;
|
const { event, showExpected } = props;
|
||||||
|
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||||
|
|
||||||
const { offset } = useRuntimeOffset();
|
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||||
|
|
||||||
// offset is negative if we are ahead
|
// only show new exacted value if outside range of the planned value
|
||||||
const expectedOffset = offset - delay;
|
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
|
||||||
const expectedState = getOffsetState(expectedOffset);
|
|
||||||
|
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||||
|
|
||||||
|
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||||
|
const plannedEnd = plannedStart + duration + delay;
|
||||||
|
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||||
|
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='sub__schedule'>
|
||||||
<ClockTime
|
<ClockTime
|
||||||
value={timeStart - expectedOffset}
|
value={plannedStart}
|
||||||
className={`sub__schedule--${expectedState}`}
|
preferredFormat12={preferredFormat12}
|
||||||
preferredFormat12='h:mm'
|
preferredFormat24={preferredFormat24}
|
||||||
preferredFormat24='HH:mm'
|
className={plannedStateClass}
|
||||||
/>
|
/>
|
||||||
→
|
{!isExpectedValueShow && (
|
||||||
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
<>
|
||||||
</>
|
→
|
||||||
|
<ClockTime
|
||||||
|
value={plannedEnd}
|
||||||
|
preferredFormat12={preferredFormat12}
|
||||||
|
preferredFormat24={preferredFormat24}
|
||||||
|
className={plannedStateClass}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isExpectedValueShow && (
|
||||||
|
<>
|
||||||
|
<ClockTime
|
||||||
|
value={expectedStart}
|
||||||
|
className={expectedStateClass}
|
||||||
|
preferredFormat12={preferredFormat12}
|
||||||
|
preferredFormat24={preferredFormat24}
|
||||||
|
/>
|
||||||
|
→
|
||||||
|
<ClockTime
|
||||||
|
value={expectedEnd}
|
||||||
|
className={expectedEndClass}
|
||||||
|
preferredFormat12={preferredFormat12}
|
||||||
|
preferredFormat24={preferredFormat24}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: OntimeEvent;
|
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||||
selectedEventId: EntryId | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||||
const { currentDay } = useCurrentDay();
|
|
||||||
const { offset } = useRuntimeOffset();
|
|
||||||
const { showExpected } = useCountdownOptions();
|
|
||||||
const { playback, current, clock } = useCountdownSocket();
|
|
||||||
|
|
||||||
// TODO: use reporter values as in the event block chip
|
|
||||||
const { status, timer } = getSubscriptionDisplayData(
|
|
||||||
current,
|
|
||||||
playback,
|
|
||||||
clock,
|
|
||||||
event,
|
|
||||||
selectedEventId,
|
|
||||||
offset,
|
|
||||||
currentDay,
|
|
||||||
getLocalizedString('common.minutes'),
|
|
||||||
showExpected,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
|
<div className='sub__status'>{statusDisplay}</div>
|
||||||
<div className='sub__timer'>{timer}</div>
|
{status === 'done' ? (
|
||||||
|
<SuperscriptTime className='sub__timer' time={timeDisplay} />
|
||||||
|
) : (
|
||||||
|
<div className='sub__timer'>{timeDisplay}</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
.single-container {
|
.single-container {
|
||||||
height: 100%;
|
margin-top: 7.5vh;
|
||||||
margin-top: 5vh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $view-element-gap;
|
gap: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event__title {
|
.event__title {
|
||||||
@@ -13,13 +12,25 @@
|
|||||||
padding: $view-card-padding;
|
padding: $view-card-padding;
|
||||||
border-radius: $element-border-radius;
|
border-radius: $element-border-radius;
|
||||||
font-size: clamp(40px, 4.5vw, 80px);
|
font-size: clamp(40px, 4.5vw, 80px);
|
||||||
line-height: 1.1em;
|
line-height: 1.1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
border-left: 0.25em solid;
|
||||||
|
border-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
text-align: left;
|
||||||
|
font-size: $title-font-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub__schedule {
|
||||||
|
color: inherit;
|
||||||
|
font-size: $base-font-size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event__status {
|
.event__status {
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
font-size: $header-font-size;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,57 @@
|
|||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
|
import { getExpectedStart } from 'ontime-utils';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
|
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||||
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
|
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
|
import { useSubscriptionDisplayData } from './countdown.utils';
|
||||||
|
import { ScheduleTime } from './CountdownSubscriptions';
|
||||||
|
|
||||||
import './SingleEventCountdown.scss';
|
import './SingleEventCountdown.scss';
|
||||||
|
|
||||||
interface SingleEventCountdownProps {
|
interface SingleEventCountdownProps {
|
||||||
subscribedEvent: OntimeEvent;
|
subscribedEvent: ExtendedEntry<OntimeEvent>;
|
||||||
goToEditMode: () => void;
|
goToEditMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
||||||
|
const { secondarySource, showExpected } = useCountdownOptions();
|
||||||
const showFab = useFadeOutOnInactivity(true);
|
const showFab = useFadeOutOnInactivity(true);
|
||||||
|
const { data: reportData } = useReport();
|
||||||
|
|
||||||
|
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||||
|
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||||
|
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||||
|
currentDay,
|
||||||
|
totalGap,
|
||||||
|
actualStart,
|
||||||
|
plannedStart,
|
||||||
|
isLinkedToLoaded,
|
||||||
|
offset,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
|
||||||
|
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||||
|
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||||
|
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='single-container' data-testid='countdown-event'>
|
<div className='single-container' data-testid='countdown-event'>
|
||||||
<SubscriptionStatus event={subscribedEvent} />
|
<SubscriptionStatus event={countdownEvent} />
|
||||||
<div className='event__title'>{subscribedEvent.title}</div>
|
<div className='event__title' style={{ borderColor: countdownEvent.colour }}>
|
||||||
|
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||||
|
{title}
|
||||||
|
{secondaryData && <div className='secondary'>{secondaryData}</div>}
|
||||||
|
</div>
|
||||||
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
<IoPencil /> Edit
|
<IoPencil /> Edit
|
||||||
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: OntimeEvent;
|
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||||
const { selectedEventId } = useSelectedEventId();
|
|
||||||
const { currentDay } = useCurrentDay();
|
|
||||||
const { offset } = useRuntimeOffset();
|
|
||||||
const { showExpected } = useCountdownOptions();
|
|
||||||
const { playback, current, clock } = useCountdownSocket();
|
|
||||||
|
|
||||||
// TODO: use reporter values as in the event block chip
|
|
||||||
const { status, timer } = getSubscriptionDisplayData(
|
|
||||||
current,
|
|
||||||
playback,
|
|
||||||
clock,
|
|
||||||
event,
|
|
||||||
selectedEventId,
|
|
||||||
offset,
|
|
||||||
currentDay,
|
|
||||||
getLocalizedString('common.minutes'),
|
|
||||||
showExpected,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
|
<div className='event__status'>{statusDisplay}</div>
|
||||||
<div className='event__timer'>{timer}</div>
|
{status === 'done' ? (
|
||||||
|
<SuperscriptTime className='event__timer' time={timeDisplay} />
|
||||||
|
) : (
|
||||||
|
<div className='event__timer'>{timeDisplay}</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export const getCountdownOptions = (
|
|||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
persistedSubscriptions: EntryId[],
|
persistedSubscriptions: EntryId[],
|
||||||
): ViewOption[] => {
|
): ViewOption[] => {
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'note', label: 'Note' },
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||||
@@ -28,7 +31,7 @@ export const getCountdownOptions = (
|
|||||||
description: 'Select the data source for auxiliary text shown in the card',
|
description: 'Select the data source for auxiliary text shown in the card',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: secondaryOptions,
|
values: secondaryOptions,
|
||||||
defaultValue: '',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
|
||||||
import { dayInMs } from 'ontime-utils';
|
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
|
||||||
|
|
||||||
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
|
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||||
import type { TranslationKey } from '../../translation/TranslationProvider';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses string as a title
|
* Parses string as a title
|
||||||
@@ -11,6 +14,9 @@ export function sanitiseTitle(title: string | null) {
|
|||||||
return title ?? '{no title}';
|
return title ?? '{no title}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const preferredFormat12 = 'h:mm a';
|
||||||
|
export const preferredFormat24 = 'HH:mm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the current event is live
|
* Whether the current event is live
|
||||||
*/
|
*/
|
||||||
@@ -18,137 +24,96 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
|
|||||||
return currentId === selectedId && playback !== Playback.Armed;
|
return currentId === selectedId && playback !== Playback.Armed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionTimerDisplayOptions = {
|
export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
|
||||||
removeSeconds: true,
|
type TimerMessage = Record<ProgressStatus, TranslationKey>;
|
||||||
removeLeadingZero: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const subscriptionScheduledTimeDisplayOptions = {
|
|
||||||
removeSeconds: true,
|
|
||||||
removeLeadingZero: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type TimerMessage = Record<string, TranslationKey>;
|
|
||||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
|
|
||||||
|
|
||||||
export const timerProgress: TimerMessage = {
|
export const timerProgress: TimerMessage = {
|
||||||
future: 'countdown.to_start',
|
future: 'countdown.to_start',
|
||||||
due: 'timeline.due',
|
due: 'timeline.due',
|
||||||
live: 'timeline.live',
|
live: 'countdown.running',
|
||||||
|
pending: 'countdown.waiting',
|
||||||
|
loaded: 'countdown.loaded',
|
||||||
done: 'countdown.ended',
|
done: 'countdown.ended',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getFormattedTime(
|
||||||
|
value: MaybeNumber,
|
||||||
|
status: ProgressStatus,
|
||||||
|
minText: string,
|
||||||
|
secText: string,
|
||||||
|
dueText: string,
|
||||||
|
) {
|
||||||
|
if (value === null) return timerPlaceholderMin;
|
||||||
|
if (status === 'future' || status === 'live') {
|
||||||
|
if (value <= 0) return dueText.toUpperCase();
|
||||||
|
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||||
|
.replace('m', `${minText} `)
|
||||||
|
.replace('s', secText);
|
||||||
|
}
|
||||||
|
return removeSeconds(formatTime(value));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a parsed timer and relevant status message
|
* Returns a parsed timer and relevant status message
|
||||||
* Handles events in different days but disregards whether an event has actually played
|
* Handles events in different days but disregards whether an event has actually played
|
||||||
* TODO: get data from reporter and check if the event has played
|
|
||||||
* TODO: get timer data granularly
|
|
||||||
*/
|
*/
|
||||||
export function getSubscriptionDisplayData(
|
export function useSubscriptionDisplayData(
|
||||||
current: MaybeNumber,
|
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
|
||||||
playback: Playback,
|
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
|
||||||
clock: number,
|
const { playback, current, clock } = useCountdownSocket();
|
||||||
subscribedEvent: OntimeEvent,
|
const { getLocalizedString } = useTranslation();
|
||||||
selectedId: EntryId | null,
|
|
||||||
offset: number,
|
|
||||||
currentDay: number,
|
|
||||||
minutesString: string,
|
|
||||||
showExpected = false,
|
|
||||||
): { status: ProgressStatus; timer: string } {
|
|
||||||
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
|
|
||||||
|
|
||||||
if (selectedId === subscribedEvent.id) {
|
const bigDuration = (value: number) => {
|
||||||
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
|
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
|
||||||
|
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||||
|
.replace('m', `${getLocalizedString('common.minutes')} `)
|
||||||
|
.replace('s', getLocalizedString('common.seconds'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subscribedEvent.isLoaded) {
|
||||||
if (playback === Playback.Armed) {
|
if (playback === Playback.Armed) {
|
||||||
// if we are following the event, but it is not running, we show the scheduled start
|
|
||||||
return {
|
return {
|
||||||
status: 'due',
|
status: 'loaded',
|
||||||
timer: getFormattedTimer(
|
statusDisplay: getLocalizedString(timerProgress['loaded']),
|
||||||
subscribedEvent.timeStart + offsetAndDelay,
|
timeDisplay: bigDuration(subscribedEvent.duration),
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionScheduledTimeDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
|
|
||||||
return {
|
return {
|
||||||
status: 'live',
|
status: 'live',
|
||||||
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
|
statusDisplay: getLocalizedString(timerProgress['live']),
|
||||||
|
timeDisplay: bigDuration(current ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (playback === Playback.Stop || playback === Playback.Armed) {
|
||||||
* If the running timer is not the one we are following
|
|
||||||
* we can be in future, due or have ended
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 3. event is the day after, we show a countdown to start
|
|
||||||
if (subscribedEvent.dayOffset > currentDay) {
|
|
||||||
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
|
|
||||||
return {
|
return {
|
||||||
status: 'future',
|
status: 'pending',
|
||||||
timer: getFormattedTimer(
|
statusDisplay: getLocalizedString(timerProgress['pending']),
|
||||||
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay,
|
timeDisplay: ' ',
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionTimerDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. event is the before after, show the scheduled end
|
if (subscribedEvent.isPast) {
|
||||||
// TODO: get the time from the reporter
|
|
||||||
if (subscribedEvent.dayOffset < currentDay) {
|
|
||||||
return {
|
return {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
timer: getFormattedTimer(
|
statusDisplay: getLocalizedString(timerProgress['done']),
|
||||||
subscribedEvent.timeEnd,
|
timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionScheduledTimeDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. if event is in future, we count to the scheduled start
|
if (subscribedEvent.expectedStart - clock <= 0) {
|
||||||
// TODO: get time until
|
|
||||||
if (clock < subscribedEvent.timeStart) {
|
|
||||||
return {
|
return {
|
||||||
status: 'future',
|
status: 'due',
|
||||||
timer: getFormattedTimer(
|
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
|
||||||
subscribedEvent.timeStart - clock - offsetAndDelay,
|
timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionTimerDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. if event has ended, we show the scheduled end
|
|
||||||
// TODO: get the time from the reporter
|
|
||||||
if (clock > subscribedEvent.timeEnd) {
|
|
||||||
return {
|
|
||||||
status: 'done',
|
|
||||||
timer: getFormattedTimer(
|
|
||||||
subscribedEvent.timeEnd,
|
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionScheduledTimeDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// the event here has to be due, we show the countdown the expected start time
|
|
||||||
return {
|
return {
|
||||||
status: 'due',
|
status: 'future',
|
||||||
timer: getFormattedTimer(
|
statusDisplay: getLocalizedString(timerProgress['future']),
|
||||||
subscribedEvent.timeStart + offsetAndDelay,
|
timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
|
||||||
TimerType.CountDown,
|
|
||||||
minutesString,
|
|
||||||
subscriptionScheduledTimeDisplayOptions,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +146,7 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
|
|||||||
* Since the original array is already ordered, we simply filter out the events
|
* Since the original array is already ordered, we simply filter out the events
|
||||||
* which are not in the subscriptions list.
|
* which are not in the subscriptions list.
|
||||||
*/
|
*/
|
||||||
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
|
export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
|
||||||
return playableEvents.filter((event) => subscriptions.includes(event.id));
|
return playableEvents.filter((event) => subscriptions.includes(event.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,3 +177,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOutsideRange(a: number, b: number): boolean {
|
||||||
|
return Math.abs(a - b) > MILLIS_PER_MINUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
|
||||||
|
|
||||||
|
export function extendEventData(
|
||||||
|
event: ExtendedEntry<OntimeEvent>,
|
||||||
|
currentDay: number,
|
||||||
|
actualStart: MaybeNumber,
|
||||||
|
plannedStart: MaybeNumber,
|
||||||
|
offset: number,
|
||||||
|
mode: OffsetMode,
|
||||||
|
reportData: OntimeReport,
|
||||||
|
): CountdownEvent {
|
||||||
|
const { totalGap, isLinkedToLoaded } = event;
|
||||||
|
const expectedStart = getExpectedStart(event, {
|
||||||
|
currentDay,
|
||||||
|
totalGap,
|
||||||
|
actualStart,
|
||||||
|
plannedStart,
|
||||||
|
isLinkedToLoaded,
|
||||||
|
offset,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
const { endedAt } = reportData[event.id] ?? { endedAt: null };
|
||||||
|
return { ...event, expectedStart, endedAt };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
|
|||||||
|
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||||
import useSettings from '../../common/hooks-query/useSettings';
|
import useSettings from '../../common/hooks-query/useSettings';
|
||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
|
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
|
||||||
|
|
||||||
export interface CountdownData {
|
export interface CountdownData {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
events: OntimeEntry[];
|
rundownData: ExtendedEntry<OntimeEntry>[];
|
||||||
projectData: ProjectData;
|
projectData: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
|||||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||||
|
|
||||||
// HTTP API data
|
// HTTP API data
|
||||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||||
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
|||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
customFields,
|
customFields,
|
||||||
events: rundownData,
|
rundownData,
|
||||||
projectData,
|
projectData,
|
||||||
isMirrored,
|
isMirrored,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import {
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import useColumnManager from '../cuesheet-table/useColumnManager';
|
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||||
|
|
||||||
interface CuesheetDndProps {
|
interface CuesheetDndProps {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
||||||
const { columnOrder, saveColumnOrder } = useColumnManager(columns);
|
const { columnOrder, saveColumnOrder } = useColumnOrder(columns);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
|||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import useColumnManager from './useColumnManager';
|
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||||
|
|
||||||
import style from './CuesheetTable.module.scss';
|
import style from './CuesheetTable.module.scss';
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
const { columnOrder, resetColumnOrder } = useColumnOrder(columns);
|
||||||
useColumnManager(columns);
|
const { columnSizing, setColumnSizing } = useColumnSizes();
|
||||||
|
const { columnVisibility, setColumnVisibility } = useColumnVisibility();
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -167,7 +168,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
TableRow: ({ item: _item, ...virtuosoProps }) => {
|
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
|
||||||
// eslint-disable-next-line react/destructuring-assignment
|
// eslint-disable-next-line react/destructuring-assignment
|
||||||
const rowIndex = virtuosoProps['data-index'];
|
const rowIndex = virtuosoProps['data-index'];
|
||||||
const row = rows[rowIndex];
|
const row = rows[rowIndex];
|
||||||
@@ -183,13 +184,16 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowId={row.id}
|
rowId={row.id}
|
||||||
rowIndex={row.index}
|
rowIndex={row.index}
|
||||||
table={table}
|
table={table}
|
||||||
|
injectedStyles={injectedStyles}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeDelay(entry)) {
|
if (isOntimeDelay(entry)) {
|
||||||
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />;
|
return (
|
||||||
|
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeMilestone(entry)) {
|
if (isOntimeMilestone(entry)) {
|
||||||
@@ -204,6 +208,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowId={row.id}
|
rowId={row.id}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
table={table}
|
table={table}
|
||||||
|
injectedStyles={injectedStyles}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -225,6 +230,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowId={row.id}
|
rowId={row.id}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
table={table}
|
table={table}
|
||||||
|
injectedStyles={injectedStyles}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -232,11 +238,14 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
|
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
|
||||||
}}
|
}}
|
||||||
fixedHeaderContent={() => {
|
fixedHeaderContent={() => {
|
||||||
return table
|
return table.getHeaderGroups().map((headerGroup) => {
|
||||||
.getHeaderGroups()
|
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||||
.map((headerGroup) => (
|
? CuesheetHeader
|
||||||
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
|
: SortableCuesheetHeader;
|
||||||
));
|
|
||||||
|
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||||
|
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+46
-3
@@ -7,7 +7,7 @@ import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
|||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
|
||||||
import { SortableCell } from './SortableCell';
|
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
@@ -16,8 +16,9 @@ interface CuesheetHeaderProps {
|
|||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
|
export function SortableCuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
|
||||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
||||||
@@ -43,8 +44,10 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
|
|||||||
return (
|
return (
|
||||||
<SortableCell
|
<SortableCell
|
||||||
key={header.column.columnDef.id}
|
key={header.column.columnDef.id}
|
||||||
header={header}
|
columnId={header.column.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
|
draggable={<Draggable header={header} />}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</SortableCell>
|
</SortableCell>
|
||||||
@@ -54,3 +57,43 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
|
||||||
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
||||||
|
{!hideIndexColumn && (
|
||||||
|
<th className={style.indexColumn} tabIndex={-1}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const customBackground = header.column.columnDef.meta?.colour;
|
||||||
|
const canWrite = header.column.columnDef.meta?.canWrite;
|
||||||
|
|
||||||
|
const customStyles: CSSProperties = {
|
||||||
|
opacity: canWrite ? 1 : 0.6,
|
||||||
|
};
|
||||||
|
if (customBackground) {
|
||||||
|
const customColour = getAccessibleColour(customBackground);
|
||||||
|
customStyles.backgroundColor = customColour.backgroundColor;
|
||||||
|
customStyles.color = customColour.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={header.column.columnDef.id}
|
||||||
|
columnId={header.column.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
|
draggable={<Draggable header={header} />}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { CSSProperties, memo } from 'react';
|
||||||
|
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
@@ -7,9 +7,10 @@ import style from './DelayRow.module.scss';
|
|||||||
|
|
||||||
interface DelayRowProps {
|
interface DelayRowProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
|
injectedStyles?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
|
function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) {
|
||||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||||
|
|
||||||
if (hideDelays || duration === 0) {
|
if (hideDelays || duration === 0) {
|
||||||
@@ -19,7 +20,7 @@ function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
|
|||||||
const delayTime = millisToDelayString(duration, 'expanded');
|
const delayTime = millisToDelayString(duration, 'expanded');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
|
<tr className={style.delayRow} data-testid='cuesheet-delay' style={injectedStyles} {...virtuosoProps}>
|
||||||
<td tabIndex={0}>{delayTime}</td>
|
<td tabIndex={0}>{delayTime}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { CSSProperties, useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||||
@@ -26,6 +26,7 @@ interface EventRowProps {
|
|||||||
parent: EntryId | null;
|
parent: EntryId | null;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||||
|
injectedStyles?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRow({
|
export default function EventRow({
|
||||||
@@ -42,6 +43,7 @@ export default function EventRow({
|
|||||||
parent,
|
parent,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
table,
|
table,
|
||||||
|
injectedStyles,
|
||||||
...virtuosoProps
|
...virtuosoProps
|
||||||
}: EventRowProps) {
|
}: EventRowProps) {
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
@@ -81,6 +83,7 @@ export default function EventRow({
|
|||||||
parent && style.hasParent,
|
parent && style.hasParent,
|
||||||
])}
|
])}
|
||||||
style={{
|
style={{
|
||||||
|
...injectedStyles,
|
||||||
opacity: `${isPast ? '0.2' : '1'}`,
|
opacity: `${isPast ? '0.2' : '1'}`,
|
||||||
'--user-bg': groupColour ?? 'transparent',
|
'--user-bg': groupColour ?? 'transparent',
|
||||||
}}
|
}}
|
||||||
|
|||||||
+4
@@ -4,3 +4,7 @@
|
|||||||
padding-top: 0.25em;
|
padding-top: 0.25em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiline {
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
|
|||||||
+6
-2
@@ -2,6 +2,10 @@ import { PropsWithChildren } from 'react';
|
|||||||
|
|
||||||
import style from './GhostedText.module.scss';
|
import style from './GhostedText.module.scss';
|
||||||
|
|
||||||
export default function GhostedText({ children }: PropsWithChildren) {
|
interface GhostedTextProps {
|
||||||
return <div className={style.ghostedText}>{children}</div>;
|
multiline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GhostedText({ children, multiline }: PropsWithChildren<GhostedTextProps>) {
|
||||||
|
return <div className={`${style.ghostedText} ${multiline ? style.multiline : ''}`}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
@@ -15,9 +16,18 @@ interface GroupRowProps {
|
|||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: Table<ExtendedEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
|
injectedStyles?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
|
export default function GroupRow({
|
||||||
|
groupId,
|
||||||
|
colour,
|
||||||
|
rowId,
|
||||||
|
rowIndex,
|
||||||
|
table,
|
||||||
|
injectedStyles,
|
||||||
|
...virtuosoProps
|
||||||
|
}: GroupRowProps) {
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
cuesheetMode: AppMode.Edit,
|
cuesheetMode: AppMode.Edit,
|
||||||
hideIndexColumn: false,
|
hideIndexColumn: false,
|
||||||
@@ -26,7 +36,12 @@ export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...v
|
|||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group' {...virtuosoProps}>
|
<tr
|
||||||
|
className={style.groupRow}
|
||||||
|
style={{ ...injectedStyles, '--user-bg': colour }}
|
||||||
|
data-testid='cuesheet-group'
|
||||||
|
{...virtuosoProps}
|
||||||
|
>
|
||||||
{cuesheetMode === AppMode.Edit && (
|
{cuesheetMode === AppMode.Edit && (
|
||||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
@@ -20,6 +21,7 @@ interface MilestoneRowProps {
|
|||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: Table<ExtendedEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
|
injectedStyles?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MilestoneRow({
|
export default function MilestoneRow({
|
||||||
@@ -31,6 +33,7 @@ export default function MilestoneRow({
|
|||||||
rowId,
|
rowId,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
table,
|
table,
|
||||||
|
injectedStyles,
|
||||||
...virtuosoProps
|
...virtuosoProps
|
||||||
}: MilestoneRowProps) {
|
}: MilestoneRowProps) {
|
||||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||||
@@ -56,6 +59,7 @@ export default function MilestoneRow({
|
|||||||
<tr
|
<tr
|
||||||
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
||||||
style={{
|
style={{
|
||||||
|
...injectedStyles,
|
||||||
opacity: `${isPast ? '0.2' : '1'}`,
|
opacity: `${isPast ? '0.2' : '1'}`,
|
||||||
'--user-bg': parentBgColour ?? 'transparent',
|
'--user-bg': parentBgColour ?? 'transparent',
|
||||||
}}
|
}}
|
||||||
|
|||||||
+30
-11
@@ -8,16 +8,16 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
|||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface SortableCellProps {
|
interface SortableCellProps {
|
||||||
header: Header<ExtendedEntry, unknown>;
|
columnId: string;
|
||||||
|
colSpan: number;
|
||||||
injectedStyles: CSSProperties;
|
injectedStyles: CSSProperties;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
draggable: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
|
export function SortableCell({ columnId, colSpan, injectedStyles, children, draggable }: SortableCellProps) {
|
||||||
const { column, colSpan } = header;
|
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: column.id,
|
id: columnId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// build drag styles
|
// build drag styles
|
||||||
@@ -34,12 +34,31 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
|
|||||||
<div {...attributes} {...listeners}>
|
<div {...attributes} {...listeners}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{draggable}
|
||||||
onDoubleClick={() => header.column.resetSize()}
|
|
||||||
onMouseDown={header.getResizeHandler()}
|
|
||||||
onTouchStart={header.getResizeHandler()}
|
|
||||||
className={style.resizer}
|
|
||||||
/>
|
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TableCell({ colSpan, injectedStyles, children, draggable }: SortableCellProps) {
|
||||||
|
return (
|
||||||
|
<th style={injectedStyles} colSpan={colSpan} tabIndex={-1}>
|
||||||
|
<div>{children}</div>
|
||||||
|
{draggable}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableProps {
|
||||||
|
header: Header<ExtendedEntry, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Draggable({ header }: DraggableProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDoubleClick={() => header.column.resetSize()}
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
className={style.resizer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
|||||||
|
|
||||||
const canWrite = column.columnDef.meta?.canWrite;
|
const canWrite = column.columnDef.meta?.canWrite;
|
||||||
if (!canWrite) {
|
if (!canWrite) {
|
||||||
return <GhostedText>{initialValue}</GhostedText>;
|
return <GhostedText multiline>{initialValue}</GhostedText>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { debounce } from '../../../common/utils/debounce';
|
import { debounce } from '../../../common/utils/debounce';
|
||||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||||
@@ -14,17 +14,8 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
|
|||||||
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
|
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
|
export function useColumnSizes() {
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({
|
const [columnSizing, setColumnSizingState] = useState<Record<string, number>>(() => {
|
||||||
key: tableHiddenKey,
|
|
||||||
defaultValue: {},
|
|
||||||
});
|
|
||||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
|
||||||
key: tableOrderKey,
|
|
||||||
defaultValue: columns.map((col) => col.id as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [columnSizing, setColumnSizingState] = useState(() => {
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(tableSizesKey);
|
const stored = localStorage.getItem(tableSizesKey);
|
||||||
return stored ? JSON.parse(stored) : {};
|
return stored ? JSON.parse(stored) : {};
|
||||||
@@ -38,10 +29,22 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
|
|||||||
saveSizesToStorage(columnSizing);
|
saveSizesToStorage(columnSizing);
|
||||||
}, [columnSizing]);
|
}, [columnSizing]);
|
||||||
|
|
||||||
const setColumnSizing = useCallback((sizes: typeof columnSizing) => {
|
const setColumnSizing = useCallback((sizesOrUpdater: Updater<ColumnSizingState>) => {
|
||||||
setColumnSizingState(sizes);
|
setColumnSizingState(sizesOrUpdater);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnSizing,
|
||||||
|
setColumnSizing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[]) {
|
||||||
|
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||||
|
key: tableOrderKey,
|
||||||
|
defaultValue: columns.map((col) => col.id as string),
|
||||||
|
});
|
||||||
|
|
||||||
// update column order if columns change
|
// update column order if columns change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newColumns = columns.map((col) => col.id as string);
|
const newColumns = columns.map((col) => col.id as string);
|
||||||
@@ -55,12 +58,20 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
|
|||||||
}, [columns, saveColumnOrder]);
|
}, [columns, saveColumnOrder]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnVisibility,
|
|
||||||
columnOrder,
|
columnOrder,
|
||||||
columnSizing,
|
|
||||||
resetColumnOrder,
|
|
||||||
setColumnVisibility,
|
|
||||||
saveColumnOrder,
|
saveColumnOrder,
|
||||||
setColumnSizing,
|
resetColumnOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColumnVisibility() {
|
||||||
|
const [columnVisibility, setColumnVisibility] = useLocalStorage({
|
||||||
|
key: tableHiddenKey,
|
||||||
|
defaultValue: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnVisibility,
|
||||||
|
setColumnVisibility,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,4 +66,5 @@
|
|||||||
.scrollContainer {
|
.scrollContainer {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -71,8 +71,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
results.map((entry, index) => {
|
results.map((entry, index) => {
|
||||||
const isSelected = selected === index;
|
const isSelected = selected === index;
|
||||||
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
||||||
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
|
const displayCue = 'cue' in entry ? entry.cue : '';
|
||||||
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -83,7 +82,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
<div className={style.data}>
|
<div className={style.data}>
|
||||||
<div className={style.index} style={{ '--color': colour }}>
|
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||||
{displayIndex}
|
{displayIndex}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.cue}>{displayCue}</div>
|
<div className={style.cue}>{displayCue}</div>
|
||||||
@@ -99,7 +98,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
footerElements={
|
footerElements={
|
||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
|
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
|
||||||
<span className={style.em}>title</span> to filter search
|
<span className={style.em}>title</span> to filter search.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
import { EntryId, isOntimeEvent, isOntimeGroup, MaybeString, SupportedEntry } from 'ontime-types';
|
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
@@ -9,14 +9,15 @@ const maxResults = 12;
|
|||||||
|
|
||||||
type FilterableGroup = {
|
type FilterableGroup = {
|
||||||
type: SupportedEntry.Group;
|
type: SupportedEntry.Group;
|
||||||
id: string;
|
id: EntryId;
|
||||||
index: number;
|
index: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
colour: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterableEvent = {
|
type FilterableEvent = {
|
||||||
type: SupportedEntry.Event;
|
type: SupportedEntry.Event;
|
||||||
id: string;
|
id: EntryId;
|
||||||
index: number;
|
index: number;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -25,7 +26,17 @@ type FilterableEvent = {
|
|||||||
parent: MaybeString;
|
parent: MaybeString;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterableEntry = FilterableGroup | FilterableEvent;
|
type FilterableMilestone = {
|
||||||
|
type: SupportedEntry.Milestone;
|
||||||
|
id: EntryId;
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
cue: string;
|
||||||
|
colour: string;
|
||||||
|
parent: MaybeString;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
||||||
|
|
||||||
export default function useFinder() {
|
export default function useFinder() {
|
||||||
const { data, rundownId } = useFlatRundown();
|
const { data, rundownId } = useFlatRundown();
|
||||||
@@ -59,7 +70,7 @@ export default function useFinder() {
|
|||||||
lastSearchString.current = searchValue;
|
lastSearchString.current = searchValue;
|
||||||
|
|
||||||
if (searchValue.startsWith('index ')) {
|
if (searchValue.startsWith('index ')) {
|
||||||
const searchString = searchValue.replace('index ', '').trim();
|
const searchString = searchValue.slice('index '.length).trim();
|
||||||
const { results, error } = searchByIndex(searchString);
|
const { results, error } = searchByIndex(searchString);
|
||||||
setResults(results);
|
setResults(results);
|
||||||
setError(error);
|
setError(error);
|
||||||
@@ -67,14 +78,14 @@ export default function useFinder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (searchValue.startsWith('cue ')) {
|
if (searchValue.startsWith('cue ')) {
|
||||||
const searchString = searchValue.replace('cue ', '').trim();
|
const searchString = searchValue.slice('cue '.length).trim();
|
||||||
const { results, error } = searchByCue(searchString);
|
const { results, error } = searchByCue(searchString);
|
||||||
setResults(results);
|
setResults(results);
|
||||||
setError(error);
|
setError(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchString = searchValue.replace('title ', '').trim();
|
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
|
||||||
const { results, error } = searchByTitle(searchString);
|
const { results, error } = searchByTitle(searchString);
|
||||||
setResults(results);
|
setResults(results);
|
||||||
setError(error);
|
setError(error);
|
||||||
@@ -162,33 +173,46 @@ export default function useFinder() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = data[i];
|
const entry = data[i];
|
||||||
if (isOntimeEvent(event)) {
|
if (isOntimeEvent(entry)) {
|
||||||
if (event.title.toLowerCase().includes(searchString)) {
|
if (entry.title.toLowerCase().includes(searchString)) {
|
||||||
remaining--;
|
remaining--;
|
||||||
results.push({
|
results.push({
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
id: event.id,
|
id: entry.id,
|
||||||
index: i,
|
index: i,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
title: event.title,
|
title: entry.title,
|
||||||
cue: event.cue,
|
cue: entry.cue,
|
||||||
colour: event.colour,
|
colour: entry.colour,
|
||||||
parent: event.parent,
|
parent: entry.parent,
|
||||||
} satisfies FilterableEvent);
|
} satisfies FilterableEvent);
|
||||||
}
|
}
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
}
|
} else if (isOntimeGroup(entry)) {
|
||||||
if (isOntimeGroup(event)) {
|
if (entry.title.toLowerCase().includes(searchString)) {
|
||||||
if (event.title.toLowerCase().includes(searchString)) {
|
|
||||||
remaining--;
|
remaining--;
|
||||||
results.push({
|
results.push({
|
||||||
type: SupportedEntry.Group,
|
type: SupportedEntry.Group,
|
||||||
id: event.id,
|
id: entry.id,
|
||||||
index: i,
|
index: i,
|
||||||
title: event.title,
|
title: entry.title,
|
||||||
|
colour: entry.colour,
|
||||||
} satisfies FilterableGroup);
|
} satisfies FilterableGroup);
|
||||||
}
|
}
|
||||||
|
} else if (isOntimeMilestone(entry)) {
|
||||||
|
if (entry.title.toLowerCase().includes(searchString)) {
|
||||||
|
remaining--;
|
||||||
|
results.push({
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
id: entry.id,
|
||||||
|
index: i,
|
||||||
|
title: entry.title,
|
||||||
|
cue: entry.cue,
|
||||||
|
colour: entry.colour,
|
||||||
|
parent: entry.parent,
|
||||||
|
} satisfies FilterableMilestone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { results, error: null };
|
return { results, error: null };
|
||||||
@@ -200,7 +224,7 @@ export default function useFinder() {
|
|||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(selectedEvent: FilterableEntry) => {
|
(selectedEvent: FilterableEntry) => {
|
||||||
// First expand the parent group if this is an event inside a group
|
// First expand the parent group if this is an event inside a group
|
||||||
if (selectedEvent.type === SupportedEntry.Event && selectedEvent.parent !== null) {
|
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
|
||||||
// Try direct state update instead of using callback
|
// Try direct state update instead of using callback
|
||||||
const currentGroups = [...new Set(collapsedGroups)];
|
const currentGroups = [...new Set(collapsedGroups)];
|
||||||
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
|
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
|||||||
}
|
}
|
||||||
|
|
||||||
.delay {
|
.delay {
|
||||||
color: $delay-color;
|
color: $ontime-delay-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeOverview {
|
.timeOverview {
|
||||||
@@ -117,7 +117,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
|||||||
.cross {
|
.cross {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
text-decoration-color: $delay-color;
|
text-decoration-color: $ontime-delay-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separeLeft {
|
.separeLeft {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeStri
|
|||||||
* Utility function calculates time to start
|
* Utility function calculates time to start
|
||||||
*/
|
*/
|
||||||
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
|
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
|
||||||
return start + delay - now - offset;
|
return start + delay - now + offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineLayout {
|
interface TimelineLayout {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
|
|||||||
hidePhase,
|
hidePhase,
|
||||||
font,
|
font,
|
||||||
keyColour,
|
keyColour,
|
||||||
textColour,
|
timerColour,
|
||||||
} = useTimerOptions();
|
} = useTimerOptions();
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -131,11 +131,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
|
|||||||
);
|
);
|
||||||
|
|
||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const timerColour = getTimerColour(viewSettings, textColour, showWarning, showDanger);
|
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||||
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
|
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
|
||||||
const userStyles = {
|
const userStyles = {
|
||||||
...(keyColour && { '--timer-bg': keyColour }),
|
...(keyColour && { '--timer-bg': keyColour }),
|
||||||
...(textColour && { '--timer-colour': timerColour }),
|
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
||||||
...(font && { '--timer-font': font }),
|
...(font && { '--timer-font': font }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ const timerDisplayOptions: SelectOption[] = [
|
|||||||
|
|
||||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
const mainOptions = makeOptionsFromCustomFields(customFields, [
|
const mainOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'title', label: 'Title' },
|
{ value: 'title', label: 'Title' },
|
||||||
{ value: 'note', label: 'Note' },
|
{ value: 'note', label: 'Note' },
|
||||||
]);
|
]);
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'title', label: 'Title' },
|
{ value: 'title', label: 'Title' },
|
||||||
{ value: 'note', label: 'Note' },
|
{ value: 'note', label: 'Note' },
|
||||||
]);
|
]);
|
||||||
@@ -84,7 +86,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
|||||||
description: 'Select the data source for the main text',
|
description: 'Select the data source for the main text',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: mainOptions,
|
values: mainOptions,
|
||||||
defaultValue: 'Title',
|
defaultValue: 'title',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'secondary-src',
|
id: 'secondary-src',
|
||||||
@@ -92,7 +94,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
|||||||
description: 'Select the data source for the secondary text',
|
description: 'Select the data source for the secondary text',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: secondaryOptions,
|
values: secondaryOptions,
|
||||||
defaultValue: '',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -163,9 +165,9 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
|||||||
defaultValue: '101010',
|
defaultValue: '101010',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'textColour',
|
id: 'timerColour',
|
||||||
title: 'Text Colour',
|
title: 'Timer Colour',
|
||||||
description: 'Text colour. Default: #f6f6f6',
|
description: 'Timer colour. Default: #f6f6f6',
|
||||||
type: 'colour',
|
type: 'colour',
|
||||||
defaultValue: 'f6f6f6',
|
defaultValue: 'f6f6f6',
|
||||||
},
|
},
|
||||||
@@ -191,7 +193,7 @@ type TimerOptions = {
|
|||||||
hidePhase: boolean;
|
hidePhase: boolean;
|
||||||
font?: string;
|
font?: string;
|
||||||
keyColour?: string;
|
keyColour?: string;
|
||||||
textColour?: string;
|
timerColour?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,7 +228,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
|
|
||||||
font: getValue('font') ?? undefined,
|
font: getValue('font') ?? undefined,
|
||||||
keyColour: makeColourString(getValue('keyColour')),
|
keyColour: makeColourString(getValue('keyColour')),
|
||||||
textColour: makeColourString(getValue('textColour')),
|
timerColour: makeColourString(getValue('timerColour')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ const fontSizeMap: { [key: number]: number } = {
|
|||||||
4: 28, // 9:01
|
4: 28, // 9:01
|
||||||
5: 28, // -9:01, 10:01, 9 min
|
5: 28, // -9:01, 10:01, 9 min
|
||||||
6: 25, // -10:01, 10 min
|
6: 25, // -10:01, 10 min
|
||||||
8: 20, // 23:01:01
|
8: 18, // 23:01:01
|
||||||
9: 20, // -23:01:01
|
9: 18, // -23:01:01
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"preserveConstEnums": true,
|
"preserveConstEnums": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"baseUrl": "src",
|
"baseUrl": "src",
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"outDir": "build"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.0.0-alpha.6",
|
"version": "4.0.0-beta.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.tgz
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Ontime Resolver
|
||||||
|
|
||||||
|
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
|
||||||
|
|
||||||
|
The Resolver is an attempt to expose our ontime's api so it is easier to integrate with
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- [Ontime's repository](https://github.com/cpvalente/ontime)
|
||||||
|
- [Ontime's documentation](https://docs.getontime.no/)
|
||||||
|
- [Ontime's website](https://getontime.no/)
|
||||||
|
|
||||||
|
## Sponsoring
|
||||||
|
You can help the development of this project or say thank you with a one time donation. \
|
||||||
|
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/cpvalente)
|
||||||
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@getontime/resolver",
|
||||||
|
"version": "4.0.0-beta.3",
|
||||||
|
"type": "module",
|
||||||
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
"types": "./dist/main.d.ts",
|
||||||
|
"main": "./dist/main.js",
|
||||||
|
"private": true,
|
||||||
|
"description": "shared typings for ontime",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint . --quiet",
|
||||||
|
"prebuild": "pnpm rimraf ./dist",
|
||||||
|
"build": "tsup && pnpm rimraf ./dist/index.js",
|
||||||
|
"postbuild": "pnpm rimraf ./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["ontime", "resolver", "parser"],
|
||||||
|
"author": "",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"devDependencies": {
|
||||||
|
"@sprout2000/esbuild-copy-plugin": "^1.1.19",
|
||||||
|
"@typescript-eslint/parser": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"tsup": "^8.5.0",
|
||||||
|
"rimraf": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"ontime-types": "workspace:^4.0.0"
|
||||||
|
},
|
||||||
|
"files": ["dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// api
|
||||||
|
export { MessageTag, RefetchKey } from 'ontime-types';
|
||||||
|
export type { ApiAction, ApiActionTag, ApiResponse } from 'ontime-types';
|
||||||
|
export type { WsPacketToClient, WsPacketToServer } from 'ontime-types';
|
||||||
|
|
||||||
|
// stores
|
||||||
|
export type { RuntimeStore, TimerState, MessageState, RundownState, Offset } from 'ontime-types';
|
||||||
|
export { TimerPhase, Playback, runtimeStorePlaceholder, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
|
// aux timer
|
||||||
|
export type { SimpleTimerState } from 'ontime-types';
|
||||||
|
export { SimplePlayback, SimpleDirection } from 'ontime-types';
|
||||||
|
|
||||||
|
// entries
|
||||||
|
export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types';
|
||||||
|
export { SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeDelay, isOntimeMilestone } from 'ontime-types';
|
||||||
|
|
||||||
|
// functions
|
||||||
|
export { isWsPacketToClient } from './websocket.js';
|
||||||
|
export type { SocketSender } from './websocket.js';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiAction, ApiActionTag, MessageTag, WsPacketToClient, WsPacketToServer } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper type for sending correct websocket messages to ontime
|
||||||
|
*/
|
||||||
|
export type SocketSender = <T extends MessageTag | ApiActionTag>(
|
||||||
|
tag: T,
|
||||||
|
payload: T extends MessageTag
|
||||||
|
? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload']
|
||||||
|
: Pick<ApiAction & { tag: T }, 'payload'>['payload'],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a soft type guard for WS packets
|
||||||
|
*/
|
||||||
|
export function isWsPacketToClient(data: unknown): data is WsPacketToClient {
|
||||||
|
return typeof data === 'object' && data !== null && 'tag' in data && 'payload' in data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"preserveConstEnums": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": "src",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
import copyPlugin from '@sprout2000/esbuild-copy-plugin';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clean: false, //we can't use clean as i dose so after the copy plugin runs
|
||||||
|
entry: ['src/main.ts'],
|
||||||
|
outDir: 'dist',
|
||||||
|
bundle: true,
|
||||||
|
|
||||||
|
dts: { resolve: true },
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext',
|
||||||
|
platform: 'node',
|
||||||
|
esbuildPlugins: [
|
||||||
|
copyPlugin.copyPlugin({
|
||||||
|
src: './node_modules/ontime-types/dist',
|
||||||
|
dest: './dist',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.0.0-alpha.6",
|
"version": "4.0.0-beta.3",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"server-timing": "^3.3.3",
|
"server-timing": "^3.3.3",
|
||||||
"shx": "^0.3.4",
|
"shx": "^0.3.4",
|
||||||
"ts-essentials": "^10.0.3",
|
"ts-essentials": "catalog:",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const dataFromExcelTemplate = [
|
|||||||
'Timer type',
|
'Timer type',
|
||||||
'Count to end',
|
'Count to end',
|
||||||
'Skip',
|
'Skip',
|
||||||
'Notes',
|
'Note',
|
||||||
't0',
|
't0',
|
||||||
'Test1',
|
'Test1',
|
||||||
'test2',
|
'test2',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('generateAuthenticatedUrl()', () => {
|
|||||||
authenticate: true,
|
authenticate: true,
|
||||||
hash: '1234',
|
hash: '1234',
|
||||||
});
|
});
|
||||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1');
|
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?n=1&token=1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a link to an unlocked preset', () => {
|
it('generates a link to an unlocked preset', () => {
|
||||||
@@ -69,6 +69,17 @@ describe('generateAuthenticatedUrl()', () => {
|
|||||||
});
|
});
|
||||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
|
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates a link for companion', () => {
|
||||||
|
const withAuth = generateShareUrl('http://192.168.10.173:4001', '<<companion>>', {
|
||||||
|
lockConfig: false,
|
||||||
|
lockNav: false,
|
||||||
|
authenticate: true,
|
||||||
|
preset: undefined,
|
||||||
|
hash: '1234',
|
||||||
|
});
|
||||||
|
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/?token=1234');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('for ontime-cloud URLs', () => {
|
describe('for ontime-cloud URLs', () => {
|
||||||
@@ -100,7 +111,7 @@ describe('generateAuthenticatedUrl()', () => {
|
|||||||
prefix: 'prefix',
|
prefix: 'prefix',
|
||||||
hash: '1234',
|
hash: '1234',
|
||||||
});
|
});
|
||||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1');
|
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1&token=1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a link to an unlocked preset', () => {
|
it('generates a link to an unlocked preset', () => {
|
||||||
|
|||||||
@@ -64,18 +64,21 @@ export function generateShareUrl(
|
|||||||
): URL {
|
): URL {
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
|
|
||||||
// if the config is locked and we are in a preset, we hide the canonical path
|
// companion links point to the root
|
||||||
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
|
if (canonicalPath !== '<<companion>>') {
|
||||||
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
|
// if the config is locked and we are in a preset, we hide the canonical path
|
||||||
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
|
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
|
||||||
|
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
|
||||||
|
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
|
||||||
|
|
||||||
|
if (lockNav) {
|
||||||
|
url.searchParams.append('n', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (authenticate && hash) {
|
if (authenticate && hash) {
|
||||||
url.searchParams.append('token', hash);
|
url.searchParams.append('token', hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lockNav) {
|
|
||||||
url.searchParams.append('n', '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const validateGenerateUrl = [
|
|||||||
body('lockConfig').isBoolean(),
|
body('lockConfig').isBoolean(),
|
||||||
body('lockNav').isBoolean(),
|
body('lockNav').isBoolean(),
|
||||||
body('preset').optional().isString().trim().notEmpty(),
|
body('preset').optional().isString().trim().notEmpty(),
|
||||||
body('prefix').optional().isString().trim().notEmpty(),
|
|
||||||
body('hash').optional().isString().trim().notEmpty(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { body, checkExact } from 'express-validator';
|
import { body } from 'express-validator';
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,11 +8,10 @@ export const validateWelcomeDialog = [body('show').isBoolean(), requestValidatio
|
|||||||
|
|
||||||
const pinValidator = (key: string) => {
|
const pinValidator = (key: string) => {
|
||||||
return body(key)
|
return body(key)
|
||||||
.isString()
|
.optional()
|
||||||
.trim()
|
|
||||||
.isLength({ min: 0, max: 4 })
|
.isLength({ min: 0, max: 4 })
|
||||||
.customSanitizer((input) => {
|
.customSanitizer((input) => {
|
||||||
if (input.length === 0) {
|
if (input === null || input.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return input;
|
return input;
|
||||||
@@ -28,8 +27,6 @@ export const validateSettings = [
|
|||||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||||
body('language').isString().trim().notEmpty(),
|
body('language').isString().trim().notEmpty(),
|
||||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||||
checkExact(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
// TODO: dont allow other keys
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
ApiAction,
|
ApiActionTag,
|
||||||
MessageState,
|
MessageState,
|
||||||
OffsetMode,
|
OffsetMode,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
@@ -30,7 +30,7 @@ let lastRequest: Date | null = null;
|
|||||||
|
|
||||||
export function dispatchFromAdapter(tag: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
export function dispatchFromAdapter(tag: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
||||||
const action = tag.toLowerCase();
|
const action = tag.toLowerCase();
|
||||||
const handler = actionHandlers[action as ApiAction];
|
const handler = actionHandlers[action as ApiActionTag];
|
||||||
lastRequest = new Date();
|
lastRequest = new Date();
|
||||||
|
|
||||||
if (handler) {
|
if (handler) {
|
||||||
@@ -46,7 +46,7 @@ export function getLastRequest() {
|
|||||||
|
|
||||||
type ActionHandler = (payload: unknown) => { payload: unknown };
|
type ActionHandler = (payload: unknown) => { payload: unknown };
|
||||||
|
|
||||||
const actionHandlers: Record<ApiAction, ActionHandler> = {
|
const actionHandlers: Record<ApiActionTag, ActionHandler> = {
|
||||||
/* General */
|
/* General */
|
||||||
version: () => ({ payload: ONTIME_VERSION }),
|
version: () => ({ payload: ONTIME_VERSION }),
|
||||||
poll: () => ({
|
poll: () => ({
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import { populateTranslation } from './setup/loadTranslations.js';
|
|||||||
import { populateStyles } from './setup/loadStyles.js';
|
import { populateStyles } from './setup/loadStyles.js';
|
||||||
import { eventStore } from './stores/EventStore.js';
|
import { eventStore } from './stores/EventStore.js';
|
||||||
import { runtimeService } from './services/runtime-service/runtime.service.js';
|
import { runtimeService } from './services/runtime-service/runtime.service.js';
|
||||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
import { restoreService } from './services/restore-service/restore.service.js';
|
||||||
|
import type { RestorePoint } from './services/restore-service/restore.type.js';
|
||||||
import * as messageService from './services/message-service/message.service.js';
|
import * as messageService from './services/message-service/message.service.js';
|
||||||
import { getState } from './stores/runtimeState.js';
|
import { getState } from './stores/runtimeState.js';
|
||||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
alias: 'minimal',
|
alias: 'minimal',
|
||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search:
|
search:
|
||||||
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&textcolour=ffffff',
|
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
customFields: {
|
customFields: {
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
|
|
||||||
|
|
||||||
import { JSONFile } from 'lowdb/node';
|
|
||||||
import { deepEqual } from 'fast-equals';
|
|
||||||
|
|
||||||
import { publicFiles } from '../setup/index.js';
|
|
||||||
|
|
||||||
export type RestorePoint = {
|
|
||||||
playback: Playback;
|
|
||||||
selectedEventId: MaybeString;
|
|
||||||
startedAt: MaybeNumber;
|
|
||||||
addedTime: number;
|
|
||||||
pausedAt: MaybeNumber;
|
|
||||||
firstStart: MaybeNumber;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility validates a RestorePoint
|
|
||||||
* @param obj
|
|
||||||
* @return boolean
|
|
||||||
*/
|
|
||||||
export function isRestorePoint(obj: unknown): obj is RestorePoint {
|
|
||||||
if (!obj) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const restorePoint = obj as RestorePoint;
|
|
||||||
|
|
||||||
if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof restorePoint.addedTime !== 'number') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof restorePoint.firstStart !== 'number' && restorePoint.firstStart !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility interface to allow dependency injection during test
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service manages saving of application state
|
|
||||||
* that can then be restored when reopening
|
|
||||||
*/
|
|
||||||
export class RestoreService {
|
|
||||||
private readonly filePath: MaybeString;
|
|
||||||
private readonly file: JSONFile<RestorePoint | null>;
|
|
||||||
private failedCreateAttempts: number;
|
|
||||||
private savedState: RestorePoint | null;
|
|
||||||
|
|
||||||
constructor(filePath: string) {
|
|
||||||
this.filePath = filePath;
|
|
||||||
|
|
||||||
this.savedState = null;
|
|
||||||
this.file = new JSONFile(this.filePath);
|
|
||||||
this.failedCreateAttempts = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility, reads from file
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private async read() {
|
|
||||||
return this.file.read();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility writes payload to file
|
|
||||||
* @throws
|
|
||||||
* @param stringifiedState
|
|
||||||
*/
|
|
||||||
private async write(data: RestorePoint) {
|
|
||||||
await this.file.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves runtime data to restore file
|
|
||||||
* @param newState RestorePoint
|
|
||||||
*/
|
|
||||||
async save(newState: RestorePoint) {
|
|
||||||
// after three failed attempts, mark the service as unavailable
|
|
||||||
if (this.failedCreateAttempts > 3) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deepEqual(newState, this.savedState)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.write(newState);
|
|
||||||
this.savedState = { ...newState };
|
|
||||||
this.failedCreateAttempts = 0;
|
|
||||||
} catch (_error) {
|
|
||||||
this.failedCreateAttempts += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts reading a restore point from a given file path
|
|
||||||
* Returns null if none found, restore point otherwise
|
|
||||||
*/
|
|
||||||
async load(): Promise<RestorePoint | null> {
|
|
||||||
try {
|
|
||||||
const maybeRestorePoint = await this.read();
|
|
||||||
if (isRestorePoint(maybeRestorePoint)) {
|
|
||||||
return maybeRestorePoint;
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// no need to notify the user
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the restore file
|
|
||||||
*/
|
|
||||||
async clear() {
|
|
||||||
try {
|
|
||||||
await this.file.write(null);
|
|
||||||
} catch (_error) {
|
|
||||||
// nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const restoreService = new RestoreService(publicFiles.restoreFile);
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { Playback } from 'ontime-types';
|
|
||||||
|
|
||||||
import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js';
|
|
||||||
|
|
||||||
describe('isRestorePoint()', () => {
|
|
||||||
it('validates a well defined object', () => {
|
|
||||||
let restorePoint: RestorePoint = {
|
|
||||||
playback: Playback.Roll,
|
|
||||||
selectedEventId: '123',
|
|
||||||
startedAt: 1,
|
|
||||||
addedTime: 2,
|
|
||||||
pausedAt: 3,
|
|
||||||
firstStart: 1,
|
|
||||||
};
|
|
||||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
|
||||||
|
|
||||||
restorePoint = {
|
|
||||||
playback: Playback.Roll,
|
|
||||||
selectedEventId: '123',
|
|
||||||
startedAt: null,
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
firstStart: 1,
|
|
||||||
};
|
|
||||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rejects a badly formatted file', () => {
|
|
||||||
it('with invalid playback value', () => {
|
|
||||||
const restorePoint = {
|
|
||||||
playback: 'unknown',
|
|
||||||
selectedEventId: '123',
|
|
||||||
startedAt: null,
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
groupStartAt: 10,
|
|
||||||
};
|
|
||||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
|
||||||
});
|
|
||||||
it('with missing playback value', () => {
|
|
||||||
const restorePoint = {
|
|
||||||
selectedEventId: '123',
|
|
||||||
startedAt: null,
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
groupStartAt: 10,
|
|
||||||
};
|
|
||||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
|
||||||
});
|
|
||||||
it('with incorrect value', () => {
|
|
||||||
const restorePoint = {
|
|
||||||
playback: Playback.Roll,
|
|
||||||
selectedEventId: '123',
|
|
||||||
startedAt: 'testing',
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
groupStartAt: 10,
|
|
||||||
};
|
|
||||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RestoreService()', () => {
|
|
||||||
describe('load()', () => {
|
|
||||||
it('loads working file with times', async () => {
|
|
||||||
const expected: RestorePoint = {
|
|
||||||
playback: Playback.Play,
|
|
||||||
selectedEventId: 'da5b4',
|
|
||||||
startedAt: 1234,
|
|
||||||
addedTime: 5678,
|
|
||||||
pausedAt: 9087,
|
|
||||||
firstStart: 1234,
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreService = new RestoreService('/path/to/restore/file');
|
|
||||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
|
||||||
|
|
||||||
const testLoad = await restoreService.load();
|
|
||||||
expect(testLoad).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads working file without times', async () => {
|
|
||||||
const expected: RestorePoint = {
|
|
||||||
playback: Playback.Stop,
|
|
||||||
selectedEventId: null,
|
|
||||||
startedAt: null,
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
firstStart: 1234,
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreService = new RestoreService('/path/to/restore/file');
|
|
||||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
|
||||||
|
|
||||||
const testLoad = await restoreService.load();
|
|
||||||
expect(testLoad).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not load wrong play state', async () => {
|
|
||||||
const expected = {
|
|
||||||
playback: 'does-not-exist',
|
|
||||||
selectedEventId: 'da5b4',
|
|
||||||
startedAt: 1234,
|
|
||||||
addedTime: 1234,
|
|
||||||
pausedAt: 1234,
|
|
||||||
firstStart: 1234,
|
|
||||||
groupStartAt: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreService = new RestoreService('/path/to/restore/file');
|
|
||||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
|
||||||
|
|
||||||
const testLoad = await restoreService.load();
|
|
||||||
expect(testLoad).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('save()', () => {
|
|
||||||
it('saves data to file', async () => {
|
|
||||||
const testData: RestorePoint = {
|
|
||||||
playback: Playback.Play,
|
|
||||||
selectedEventId: '1234',
|
|
||||||
startedAt: 1234,
|
|
||||||
addedTime: 1234,
|
|
||||||
pausedAt: 1234,
|
|
||||||
firstStart: 1234,
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreService = new RestoreService('/path/to/restore/file');
|
|
||||||
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
|
|
||||||
await restoreService.save(testData);
|
|
||||||
expect(writeSpy).toHaveBeenCalledWith(testData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { dayInMs, millisToString } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils';
|
||||||
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
findDayOffset,
|
||||||
getCurrent,
|
getCurrent,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
@@ -726,6 +727,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 150,
|
startedAt: 150,
|
||||||
@@ -738,7 +740,10 @@ describe('getRuntimeOffset()', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 150,
|
actualStart: 150,
|
||||||
plannedStart: 100,
|
plannedStart: 100,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
clock: 150,
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
@@ -750,6 +755,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 150, // we started 50ms delayed
|
startedAt: 150, // we started 50ms delayed
|
||||||
@@ -762,7 +768,9 @@ describe('getRuntimeOffset()', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 150,
|
actualStart: 150,
|
||||||
plannedStart: 100,
|
plannedStart: 100,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
@@ -775,6 +783,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
timeEnd: 140,
|
timeEnd: 140,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 100, // we started ontime
|
startedAt: 100, // we started ontime
|
||||||
@@ -787,7 +796,9 @@ describe('getRuntimeOffset()', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 100,
|
actualStart: 100,
|
||||||
plannedStart: 100,
|
plannedStart: 100,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
@@ -800,6 +811,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
timeEnd: 150,
|
timeEnd: 150,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
clock: 150,
|
clock: 150,
|
||||||
timer: {
|
timer: {
|
||||||
@@ -813,7 +825,9 @@ describe('getRuntimeOffset()', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 100,
|
actualStart: 100,
|
||||||
plannedStart: 100,
|
plannedStart: 100,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
@@ -830,6 +844,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
duration: 3600000,
|
duration: 3600000,
|
||||||
timeStrategy: 'lock-duration',
|
timeStrategy: 'lock-duration',
|
||||||
linkStart: false,
|
linkStart: false,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
rundown: {
|
rundown: {
|
||||||
selectedEventIndex: 0,
|
selectedEventIndex: 0,
|
||||||
@@ -837,6 +852,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
plannedStart: 77400000,
|
plannedStart: 77400000,
|
||||||
plannedEnd: 84600000,
|
plannedEnd: 84600000,
|
||||||
actualStart: null,
|
actualStart: null,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
absolute: -77400000,
|
absolute: -77400000,
|
||||||
@@ -852,6 +868,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
secondaryTimer: null,
|
secondaryTimer: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
@@ -874,6 +891,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
countToEnd: true,
|
countToEnd: true,
|
||||||
|
dayOffset: 0,
|
||||||
skip: false,
|
skip: false,
|
||||||
note: '',
|
note: '',
|
||||||
colour: '',
|
colour: '',
|
||||||
@@ -890,6 +908,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
plannedStart: 77400000, // 21:30:00
|
plannedStart: 77400000, // 21:30:00
|
||||||
plannedEnd: 81000000, // 22:30:00
|
plannedEnd: 81000000, // 22:30:00
|
||||||
actualStart: 78000000, // 21:40:00
|
actualStart: 78000000, // 21:40:00
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
absolute: 0,
|
absolute: 0,
|
||||||
@@ -905,6 +924,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
secondaryTimer: null,
|
secondaryTimer: null,
|
||||||
startedAt: 78000000,
|
startedAt: 78000000,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
@@ -922,6 +942,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
timeStart: 77400000, // 21:30:00
|
timeStart: 77400000, // 21:30:00
|
||||||
timeEnd: 81000000, // 22:30:00
|
timeEnd: 81000000, // 22:30:00
|
||||||
duration: 3600000, // 01:00:00
|
duration: 3600000, // 01:00:00
|
||||||
|
dayOffset: 0,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: false,
|
linkStart: false,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
@@ -943,6 +964,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
plannedStart: 77400000, // 21:30:00
|
plannedStart: 77400000, // 21:30:00
|
||||||
plannedEnd: 81000000, // 22:30:00
|
plannedEnd: 81000000, // 22:30:00
|
||||||
actualStart: 78000000, // 21:40:00
|
actualStart: 78000000, // 21:40:00
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
absolute: 0,
|
absolute: 0,
|
||||||
@@ -958,6 +980,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
secondaryTimer: null,
|
secondaryTimer: null,
|
||||||
startedAt: 78000000,
|
startedAt: 78000000,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
@@ -978,6 +1001,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
countToEnd: true,
|
countToEnd: true,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
rundown: {
|
rundown: {
|
||||||
selectedEventIndex: 0,
|
selectedEventIndex: 0,
|
||||||
@@ -985,6 +1009,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
plannedStart: 77400000, // 21:30:00
|
plannedStart: 77400000, // 21:30:00
|
||||||
plannedEnd: 81000000, // 22:30:00
|
plannedEnd: 81000000, // 22:30:00
|
||||||
actualStart: 82000000, // 22:46:40 <--- started now
|
actualStart: 82000000, // 22:46:40 <--- started now
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
absolute: 0,
|
absolute: 0,
|
||||||
@@ -1000,6 +1025,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
secondaryTimer: null,
|
secondaryTimer: null,
|
||||||
startedAt: 82000000, // <--- started now
|
startedAt: 82000000, // <--- started now
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
_timer: { pausedAt: null },
|
_timer: { pausedAt: null },
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
@@ -1017,6 +1043,7 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 150,
|
timeStart: 150,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 150,
|
startedAt: 150,
|
||||||
@@ -1029,7 +1056,9 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 150,
|
actualStart: 150,
|
||||||
plannedStart: 150,
|
plannedStart: 150,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute, relative } = getRuntimeOffset(state);
|
const { absolute, relative } = getRuntimeOffset(state);
|
||||||
@@ -1041,6 +1070,7 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 150,
|
startedAt: 150,
|
||||||
@@ -1053,7 +1083,9 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 150,
|
actualStart: 150,
|
||||||
plannedStart: 100,
|
plannedStart: 100,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute, relative } = getRuntimeOffset(state);
|
const { absolute, relative } = getRuntimeOffset(state);
|
||||||
@@ -1065,6 +1097,7 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
id: '1',
|
id: '1',
|
||||||
timeStart: 150,
|
timeStart: 150,
|
||||||
|
dayOffset: 0,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
startedAt: 100,
|
startedAt: 100,
|
||||||
@@ -1077,7 +1110,9 @@ describe('getRuntimeOffset() relative', () => {
|
|||||||
rundown: {
|
rundown: {
|
||||||
actualStart: 100,
|
actualStart: 100,
|
||||||
plannedStart: 150,
|
plannedStart: 150,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
|
_startDayOffset: 0,
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute, relative } = getRuntimeOffset(state);
|
const { absolute, relative } = getRuntimeOffset(state);
|
||||||
@@ -1258,3 +1293,21 @@ describe('getTimerPhase()', () => {
|
|||||||
expect(phase).toBe(TimerPhase.Pending);
|
expect(phase).toBe(TimerPhase.Pending);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findDay()', () => {
|
||||||
|
test('finds dayOffset', () => {
|
||||||
|
//both have 1 hour offset but the clock are on different days
|
||||||
|
expect(findDayOffset(0, 23 * MILLIS_PER_HOUR)).toBe(-1); // -> 23
|
||||||
|
expect(findDayOffset(0, 13 * MILLIS_PER_HOUR)).toBe(-1); // -> 13
|
||||||
|
expect(findDayOffset(0, 12 * MILLIS_PER_HOUR)).toBe(-1); // -> 12
|
||||||
|
expect(findDayOffset(0, 11 * MILLIS_PER_HOUR)).toBe(0); // -> 11
|
||||||
|
expect(findDayOffset(1 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -1
|
||||||
|
|
||||||
|
//both have 1 hour offset but the clock are on different days
|
||||||
|
expect(findDayOffset(23 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -23
|
||||||
|
expect(findDayOffset(13 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -13
|
||||||
|
expect(findDayOffset(12 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -12
|
||||||
|
expect(findDayOffset(11 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -11
|
||||||
|
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isRestorePoint } from '../restore.parser.js';
|
||||||
|
import { RestorePoint } from '../restore.type.js';
|
||||||
|
|
||||||
|
describe('isRestorePoint()', () => {
|
||||||
|
it('validates a well defined object', () => {
|
||||||
|
let restorePoint: RestorePoint = {
|
||||||
|
playback: Playback.Roll,
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: 1,
|
||||||
|
addedTime: 2,
|
||||||
|
pausedAt: 3,
|
||||||
|
firstStart: 1,
|
||||||
|
startEpoch: 1,
|
||||||
|
};
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||||
|
|
||||||
|
restorePoint = {
|
||||||
|
playback: Playback.Roll,
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: null,
|
||||||
|
addedTime: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
firstStart: 1,
|
||||||
|
startEpoch: 1,
|
||||||
|
};
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rejects a badly formatted file', () => {
|
||||||
|
it('with invalid playback value', () => {
|
||||||
|
const restorePoint = {
|
||||||
|
playback: 'unknown',
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: null,
|
||||||
|
addedTime: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
groupStartAt: 10,
|
||||||
|
startEpoch: 1,
|
||||||
|
};
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||||
|
});
|
||||||
|
it('with missing playback value', () => {
|
||||||
|
const restorePoint = {
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: null,
|
||||||
|
addedTime: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
groupStartAt: 10,
|
||||||
|
};
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||||
|
});
|
||||||
|
it('with incorrect value', () => {
|
||||||
|
const restorePoint = {
|
||||||
|
playback: Playback.Roll,
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: 'testing',
|
||||||
|
addedTime: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
groupStartAt: 10,
|
||||||
|
startEpoch: 1,
|
||||||
|
};
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { RestorePoint } from '../restore.type.js';
|
||||||
|
import { restoreService } from '../restore.service.js';
|
||||||
|
|
||||||
|
describe('restoreService', () => {
|
||||||
|
describe('load()', () => {
|
||||||
|
it('loads working file with times', async () => {
|
||||||
|
const expected: RestorePoint = {
|
||||||
|
playback: Playback.Play,
|
||||||
|
selectedEventId: 'da5b4',
|
||||||
|
startedAt: 1234,
|
||||||
|
addedTime: 5678,
|
||||||
|
pausedAt: 9087,
|
||||||
|
firstStart: 1234,
|
||||||
|
startEpoch: 1234,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const testLoad = await restoreService.load(mockRead);
|
||||||
|
expect(testLoad).toStrictEqual(expected);
|
||||||
|
expect(mockRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads working file without times', async () => {
|
||||||
|
const expected: RestorePoint = {
|
||||||
|
playback: Playback.Stop,
|
||||||
|
selectedEventId: null,
|
||||||
|
startedAt: null,
|
||||||
|
addedTime: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
firstStart: 1234,
|
||||||
|
startEpoch: 1234,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const testLoad = await restoreService.load(mockRead);
|
||||||
|
expect(testLoad).toStrictEqual(expected);
|
||||||
|
expect(mockRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not load wrong play state', async () => {
|
||||||
|
const expected = {
|
||||||
|
// Missing required field 'firstStart' to make validation fail
|
||||||
|
playback: 'does-not-exist',
|
||||||
|
selectedEventId: 'da5b4',
|
||||||
|
startedAt: 1234,
|
||||||
|
addedTime: 1234,
|
||||||
|
pausedAt: 1234,
|
||||||
|
groupStartAt: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const testLoad = await restoreService.load(mockRead);
|
||||||
|
// Should return null because isRestorePoint validation fails
|
||||||
|
expect(testLoad).toBe(null);
|
||||||
|
expect(mockRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when file read fails', async () => {
|
||||||
|
const mockRead = vi.fn().mockRejectedValue(new Error('File not found'));
|
||||||
|
|
||||||
|
const testLoad = await restoreService.load(mockRead);
|
||||||
|
expect(testLoad).toBe(null);
|
||||||
|
expect(mockRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save()', () => {
|
||||||
|
it('saves data to file', async () => {
|
||||||
|
const testData: RestorePoint = {
|
||||||
|
playback: Playback.Play,
|
||||||
|
selectedEventId: '1234',
|
||||||
|
startedAt: 1234,
|
||||||
|
addedTime: 1234,
|
||||||
|
pausedAt: 1234,
|
||||||
|
firstStart: 1234,
|
||||||
|
startEpoch: 1234,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWrite = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await restoreService.save(testData, mockWrite);
|
||||||
|
expect(mockWrite).toHaveBeenCalledWith(testData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles write failures gracefully', async () => {
|
||||||
|
const testData: RestorePoint = {
|
||||||
|
playback: Playback.Pause,
|
||||||
|
selectedEventId: '5678',
|
||||||
|
startedAt: 5678,
|
||||||
|
addedTime: 5678,
|
||||||
|
pausedAt: 5678,
|
||||||
|
firstStart: 5678,
|
||||||
|
startEpoch: 5678,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWrite = vi.fn().mockRejectedValue(new Error('Write failed'));
|
||||||
|
|
||||||
|
// Should not throw, and should still call write
|
||||||
|
await expect(restoreService.save(testData, mockWrite)).resolves.toBeUndefined();
|
||||||
|
expect(mockWrite).toHaveBeenCalledWith(testData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear()', () => {
|
||||||
|
it('clears the restore file', async () => {
|
||||||
|
const mockWrite = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await restoreService.clear(mockWrite);
|
||||||
|
expect(mockWrite).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles clear failures gracefully', async () => {
|
||||||
|
const mockWrite = vi.fn().mockRejectedValue(new Error('Clear failed'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(restoreService.clear(mockWrite)).resolves.toBeUndefined();
|
||||||
|
expect(mockWrite).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
|
||||||
|
import { is } from '../../utils/is.js';
|
||||||
|
|
||||||
|
import type { RestorePoint } from './restore.type.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility validates a RestorePoint
|
||||||
|
*/
|
||||||
|
export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePoint {
|
||||||
|
if (!is.object(restorePoint)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!is.objectWithKeys(restorePoint, [
|
||||||
|
'playback',
|
||||||
|
'selectedEventId',
|
||||||
|
'startedAt',
|
||||||
|
'addedTime',
|
||||||
|
'pausedAt',
|
||||||
|
'firstStart',
|
||||||
|
'startEpoch',
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.string(restorePoint.playback) && !Object.values(Playback).includes(restorePoint.playback as Playback)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.string(restorePoint.selectedEventId) && restorePoint.selectedEventId !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.number(restorePoint.startedAt) && restorePoint.startedAt !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.number(restorePoint.addedTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.number(restorePoint.pausedAt) && restorePoint.pausedAt !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.number(restorePoint.startEpoch) && restorePoint.startEpoch !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { JSONFile } from 'lowdb/node';
|
||||||
|
import { deepEqual } from 'fast-equals';
|
||||||
|
|
||||||
|
import { publicFiles } from '../../setup/index.js';
|
||||||
|
|
||||||
|
import { isRestorePoint } from './restore.parser.js';
|
||||||
|
import type { RestorePoint } from './restore.type.js';
|
||||||
|
|
||||||
|
let failedCreateAttempts = 0;
|
||||||
|
let savedState: RestorePoint | null = null;
|
||||||
|
let fileRef: JSONFile<RestorePoint | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service manages saving snapshot of application state
|
||||||
|
* that can then be restored when reopening
|
||||||
|
*/
|
||||||
|
export const restoreService = {
|
||||||
|
save,
|
||||||
|
load,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a restore point
|
||||||
|
* @param [writeFn=write] - allows overriding the write function for testing
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async function save(data: RestorePoint, writeFn = write) {
|
||||||
|
// after three failed attempts, mark the service as unavailable
|
||||||
|
if (failedCreateAttempts > 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deepEqual(data, savedState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFn(data);
|
||||||
|
savedState = { ...data };
|
||||||
|
failedCreateAttempts = 0;
|
||||||
|
} catch (_error) {
|
||||||
|
failedCreateAttempts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts reading a restore point from a given file path
|
||||||
|
* Returns null if none found, restore point otherwise
|
||||||
|
* @param [readFn=read] - allows overriding the read function for testing
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async function load(readFn = read): Promise<RestorePoint | null> {
|
||||||
|
try {
|
||||||
|
const maybeRestorePoint = await readFn();
|
||||||
|
if (isRestorePoint(maybeRestorePoint)) {
|
||||||
|
return maybeRestorePoint;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// no need to notify the user
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the restore file
|
||||||
|
* @param [writeFn=write] - allows overriding the write function for testing
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async function clear(writeFn = write) {
|
||||||
|
try {
|
||||||
|
await writeFn(null);
|
||||||
|
} catch (_error) {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialised file reference
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async function init(): Promise<JSONFile<RestorePoint | null>> {
|
||||||
|
if (fileRef) return fileRef;
|
||||||
|
|
||||||
|
fileRef = new JSONFile<RestorePoint | null>(publicFiles.restoreFile);
|
||||||
|
return fileRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads from an intialized file reference
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async function read(): Promise<RestorePoint | null> {
|
||||||
|
const file = await init();
|
||||||
|
return file.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes to an intialized file reference
|
||||||
|
* @throws - if writing fails
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async function write(data: RestorePoint | null) {
|
||||||
|
const file = await init();
|
||||||
|
return file.write(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
|
export type RestorePoint = {
|
||||||
|
playback: Playback;
|
||||||
|
selectedEventId: MaybeString;
|
||||||
|
startedAt: MaybeNumber;
|
||||||
|
addedTime: number;
|
||||||
|
pausedAt: MaybeNumber;
|
||||||
|
firstStart: MaybeNumber;
|
||||||
|
startEpoch: MaybeNumber;
|
||||||
|
};
|
||||||
@@ -25,7 +25,8 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
|
|||||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
|
||||||
import { EventTimer } from '../EventTimer.js';
|
import { EventTimer } from '../EventTimer.js';
|
||||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||||
|
import { restoreService } from '../restore-service/restore.service.js';
|
||||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -635,6 +636,8 @@ const eventTimer = new EventTimer({
|
|||||||
});
|
});
|
||||||
export const runtimeService = new RuntimeService(eventTimer);
|
export const runtimeService = new RuntimeService(eventTimer);
|
||||||
|
|
||||||
|
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorator manages side effects from updating the runtime
|
* Decorator manages side effects from updating the runtime
|
||||||
* This should only be applied to functions that are exposed for consumption
|
* This should only be applied to functions that are exposed for consumption
|
||||||
@@ -671,21 +674,30 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
// combine all big changes
|
// combine all big changes
|
||||||
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
|
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
|
||||||
|
|
||||||
// clock has changed by a second or more
|
/**
|
||||||
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
* if any values have changed.
|
||||||
if (updateClock) {
|
* values that have the possibility to tick are updated when the seconds roll over
|
||||||
batch.add('clock', state.clock);
|
*/
|
||||||
RuntimeService.previousState.clock = state.clock;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if any values have changed, values that have the possibility to tick are updated when the seconds roll over
|
|
||||||
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||||
if (updateTimer) {
|
if (updateTimer) {
|
||||||
batch.add('timer', state.timer);
|
batch.add('timer', state.timer);
|
||||||
RuntimeService.previousState.timer = { ...state.timer };
|
RuntimeService.previousState.timer = { ...state.timer };
|
||||||
}
|
}
|
||||||
|
|
||||||
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate`
|
/**
|
||||||
|
* clock has changed by a second or more.
|
||||||
|
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||||
|
*/
|
||||||
|
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||||
|
if (updateClock) {
|
||||||
|
batch.add('clock', state.clock);
|
||||||
|
RuntimeService.previousState.clock = state.clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if any values have changed.
|
||||||
|
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||||
|
*/
|
||||||
const updateRuntime = getShouldOffsetUpdate(
|
const updateRuntime = getShouldOffsetUpdate(
|
||||||
RuntimeService.previousState?.offset,
|
RuntimeService.previousState?.offset,
|
||||||
state.offset,
|
state.offset,
|
||||||
@@ -696,16 +708,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
RuntimeService.previousState.offset = structuredClone(state.offset);
|
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if any values have changed
|
/**
|
||||||
|
* if any values have changed.
|
||||||
|
*/
|
||||||
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||||
if (updateRundownData) {
|
if (updateRundownData) {
|
||||||
batch.add('rundown', state.rundown);
|
batch.add('rundown', state.rundown);
|
||||||
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMaybeEntryIfChanged<
|
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||||
K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
|
|
||||||
>(key: K) {
|
|
||||||
const previousEntry = RuntimeService.previousState[key];
|
const previousEntry = RuntimeService.previousState[key];
|
||||||
const currentEntry = state[key];
|
const currentEntry = state[key];
|
||||||
|
|
||||||
@@ -731,6 +743,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
addedTime: state.timer.addedTime,
|
addedTime: state.timer.addedTime,
|
||||||
pausedAt: state._timer.pausedAt,
|
pausedAt: state._timer.pausedAt,
|
||||||
firstStart: state.rundown.actualStart,
|
firstStart: state.rundown.actualStart,
|
||||||
|
startEpoch: state._startEpoch,
|
||||||
})
|
})
|
||||||
.catch((_e) => {
|
.catch((_e) => {
|
||||||
//we don't do anything with the error here
|
//we don't do anything with the error here
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function isNewSecond(
|
|||||||
/**
|
/**
|
||||||
* Checks whether we should update the clock value
|
* Checks whether we should update the clock value
|
||||||
* - we have rolled into a new seconds unit
|
* - we have rolled into a new seconds unit
|
||||||
* this is different from the timer update as it looks at the clock as counting up
|
|
||||||
*/
|
*/
|
||||||
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
|
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
|
||||||
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp);
|
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp);
|
||||||
@@ -32,8 +31,10 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether we should update the timer value
|
* Checks whether we should update the timer values
|
||||||
* - we have rolled into a new seconds unit
|
* - `current` and `secondaryTimer` trigger on seconds roll over
|
||||||
|
* - the rest trigger on any change
|
||||||
|
* - `elapsed` and `expectedFinish` is not checked
|
||||||
*/
|
*/
|
||||||
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
||||||
if (previousValue === undefined) return true;
|
if (previousValue === undefined) return true;
|
||||||
@@ -53,6 +54,11 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether we should update the offset values
|
||||||
|
* - `mode` triggers update
|
||||||
|
* - `absolute`, `relative`, `expected**End` are ticked with `didDependencyUpdate`
|
||||||
|
*/
|
||||||
export function getShouldOffsetUpdate(
|
export function getShouldOffsetUpdate(
|
||||||
previousValue: Offset | undefined,
|
previousValue: Offset | undefined,
|
||||||
currentValue: Offset,
|
currentValue: Offset,
|
||||||
@@ -60,7 +66,6 @@ export function getShouldOffsetUpdate(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (previousValue === undefined) return true;
|
if (previousValue === undefined) return true;
|
||||||
if (previousValue.mode !== currentValue.mode) return true;
|
if (previousValue.mode !== currentValue.mode) return true;
|
||||||
// absolute, relative, expected*End are ticked with `didDependencyUpdate`
|
|
||||||
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
|
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types';
|
import {
|
||||||
|
AuthenticationStatus,
|
||||||
|
CustomFields,
|
||||||
|
DatabaseModel,
|
||||||
|
isOntimeEvent,
|
||||||
|
isOntimeMilestone,
|
||||||
|
LogOrigin,
|
||||||
|
MaybeString,
|
||||||
|
OntimeGroup,
|
||||||
|
Rundown,
|
||||||
|
SupportedEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
import { ImportMap, getErrorMessage } from 'ontime-utils';
|
import { ImportMap, getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
||||||
@@ -348,6 +359,21 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
|
const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
|
|
||||||
|
const sheetOrder: string[] = [];
|
||||||
|
let prevGroup: string | null = null;
|
||||||
|
for (const id of rundown.flatOrder) {
|
||||||
|
const entry = rundown.entries[id];
|
||||||
|
|
||||||
|
if (isOntimeEvent(entry) || isOntimeMilestone(entry)) {
|
||||||
|
if (prevGroup && entry.parent === null) {
|
||||||
|
// if we were in a group and are now not insert a group end
|
||||||
|
sheetOrder.push(`group-end-${prevGroup}`);
|
||||||
|
}
|
||||||
|
prevGroup = entry.parent;
|
||||||
|
}
|
||||||
|
sheetOrder.push(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
const titleMetadata = Object.values(sheetMetadata)[0];
|
const titleMetadata = Object.values(sheetMetadata)[0];
|
||||||
if (titleMetadata === undefined) {
|
if (titleMetadata === undefined) {
|
||||||
throw new Error(`Sheet read failed: failed to find title row`);
|
throw new Error(`Sheet read failed: failed to find title row`);
|
||||||
@@ -380,17 +406,25 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
range: {
|
range: {
|
||||||
dimension: 'ROWS',
|
dimension: 'ROWS',
|
||||||
startIndex: titleRow + 1,
|
startIndex: titleRow + 1,
|
||||||
endIndex: titleRow + rundown.order.length,
|
endIndex: titleRow + sheetOrder.length,
|
||||||
sheetId: worksheetId,
|
sheetId: worksheetId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the corresponding row with event data
|
try {
|
||||||
rundown.order.forEach((entryId, index) => {
|
// update the corresponding row with event data
|
||||||
const entry = rundown.entries[entryId];
|
sheetOrder.forEach((entryId, index) => {
|
||||||
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
const isGroupEnd = entryId.startsWith('group-end-');
|
||||||
});
|
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||||
|
const entry = isGroupEnd
|
||||||
|
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||||
|
: structuredClone(rundown.entries[id]);
|
||||||
|
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`)
|
||||||
|
}
|
||||||
|
|
||||||
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
||||||
spreadsheetId: sheetId,
|
spreadsheetId: sheetId,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { isOntimeGroup, isOntimeEvent, OntimeEvent, OntimeEntry, RGBColour } from 'ontime-types';
|
import {
|
||||||
|
OntimeEntry,
|
||||||
|
RGBColour,
|
||||||
|
isOntimeDelay,
|
||||||
|
OntimeEntryCommonKeys,
|
||||||
|
isOntimeGroup,
|
||||||
|
isOntimeMilestone,
|
||||||
|
} from 'ontime-types';
|
||||||
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
|
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
|
||||||
|
|
||||||
import type { sheets_v4 } from '@googleapis/sheets';
|
import type { sheets_v4 } from '@googleapis/sheets';
|
||||||
@@ -70,64 +77,67 @@ export function getA1Notation(row: number, column: number): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description - creates updateCells request from ontime event
|
* @description - creates updateCells request from ontime event
|
||||||
* @param {OntimeEntry} event
|
* @param {OntimeEntry} entry
|
||||||
* @param {number} index - index of the event
|
* @param {number} index - index of the event
|
||||||
* @param {number} worksheetId
|
* @param {number} worksheetId
|
||||||
* @param {object} metadata - object with all the cell positions of the title of each attribute
|
* @param {object} metadata - object with all the cell positions of the title of each attribute
|
||||||
* @returns {sheets_v4.Schema} - list of update requests
|
* @returns {sheets_v4.Schema} - list of update requests
|
||||||
*/
|
*/
|
||||||
export function cellRequestFromEvent(
|
export function cellRequestFromEvent(
|
||||||
event: OntimeEntry,
|
entry: OntimeEntry,
|
||||||
index: number,
|
index: number,
|
||||||
worksheetId: number,
|
worksheetId: number,
|
||||||
metadata: object,
|
metadata: object,
|
||||||
): sheets_v4.Schema$Request {
|
): sheets_v4.Schema$Request {
|
||||||
const rowData = Object.entries(metadata)
|
const rowData = Object.entries(metadata) // check what headings are available in the sheet
|
||||||
.filter(([_, value]) => value !== undefined)
|
.filter(([_, value]) => value !== undefined) // drop anything that is undefined
|
||||||
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [keyof OntimeEvent | 'blank', { col: number; row: number }][];
|
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [
|
||||||
|
OntimeEntryCommonKeys | 'blank',
|
||||||
const titleCol = rowData[0][1].col;
|
{ col: number; row: number },
|
||||||
|
][]; // sort the array by the column index
|
||||||
|
|
||||||
|
// inset blank data is there is spacing between relevant ontime columns
|
||||||
for (const [index, e] of rowData.entries()) {
|
for (const [index, e] of rowData.entries()) {
|
||||||
if (index !== 0) {
|
if (index === 0) continue;
|
||||||
const prevCol = rowData[index - 1][1].col;
|
const prevCol = rowData[index - 1][1].col;
|
||||||
const thisCol = e[1].col;
|
const thisCol = e[1].col;
|
||||||
const diff = thisCol - prevCol;
|
const diff = thisCol - prevCol;
|
||||||
if (diff > 1) {
|
if (diff > 1) {
|
||||||
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
|
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
|
||||||
rowData.splice(index, 0, ...fillArr);
|
rowData.splice(index, 0, ...fillArr);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = isOntimeEvent(event) || isOntimeGroup(event) ? getAccessibleColour(event.colour) : undefined;
|
const colours = 'colour' in entry ? getAccessibleColour(entry.colour) : undefined;
|
||||||
const cellColor: sheets_v4.Schema$CellData = !colors
|
const cellColor: sheets_v4.Schema$CellData = !colours
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
userEnteredFormat: {
|
userEnteredFormat: {
|
||||||
backgroundColor: toSheetColourLevel(colors.background),
|
backgroundColor: toSheetColourLevel(colours.background),
|
||||||
textFormat: {
|
textFormat: {
|
||||||
foregroundColor: toSheetColourLevel(colors.text),
|
foregroundColor: toSheetColourLevel(colours.text),
|
||||||
},
|
},
|
||||||
borders: {
|
borders: {
|
||||||
bottom: {
|
bottom: {
|
||||||
style: 'SOLID',
|
style: 'SOLID',
|
||||||
color: toSheetColourLevel(colors.border),
|
color: toSheetColourLevel(colours.border),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
|
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
|
||||||
return { ...getCellData(key, event), ...cellColor };
|
return { ...getCellData(key, entry), ...cellColor };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const headerLocation = rowData[0][1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateCells: {
|
updateCells: {
|
||||||
start: {
|
start: {
|
||||||
sheetId: worksheetId,
|
sheetId: worksheetId,
|
||||||
rowIndex: index + rowData[0][1]['row'] + 1,
|
rowIndex: index + headerLocation.row + 1,
|
||||||
columnIndex: titleCol,
|
columnIndex: headerLocation.col,
|
||||||
},
|
},
|
||||||
fields: 'userEnteredValue,userEnteredFormat',
|
fields: 'userEnteredValue,userEnteredFormat',
|
||||||
rows: [
|
rows: [
|
||||||
@@ -139,40 +149,44 @@ export function cellRequestFromEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) {
|
function getCellData(key: OntimeEntryCommonKeys | 'blank', entry: OntimeEntry) {
|
||||||
if (isOntimeEvent(event)) {
|
if (isOntimeDelay(entry) || key === 'blank') {
|
||||||
if (key === 'blank') {
|
return {};
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (key === 'colour') {
|
|
||||||
return { userEnteredValue: { stringValue: event[key] } };
|
|
||||||
}
|
|
||||||
if (key.startsWith('custom')) {
|
|
||||||
const customKey = key.split(':')[1];
|
|
||||||
return { userEnteredValue: { stringValue: event.custom[customKey] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof event[key] === 'number') {
|
|
||||||
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
|
|
||||||
}
|
|
||||||
if (typeof event[key] === 'string') {
|
|
||||||
return { userEnteredValue: { stringValue: event[key] } };
|
|
||||||
}
|
|
||||||
if (typeof event[key] === 'boolean') {
|
|
||||||
return { userEnteredValue: { boolValue: event[key] } };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(event)) {
|
// we need to remap the event type to timer type in the case of groups and milestones
|
||||||
if (key === 'title') {
|
if (key === 'timerType') {
|
||||||
return { userEnteredValue: { stringValue: event[key] } };
|
if (isOntimeGroup(entry))
|
||||||
}
|
return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
|
||||||
if (key === 'timerType') {
|
if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
|
||||||
return { userEnteredValue: { stringValue: 'group' } };
|
return { userEnteredValue: { stringValue: entry.timerType } };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
// all other data is not relevant for the group end entry
|
||||||
|
if (entry.id.startsWith('group-end')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to flatten the milestones
|
||||||
|
if (key.startsWith('custom')) {
|
||||||
|
const customKey = key.split(':')[1];
|
||||||
|
return { userEnteredValue: { stringValue: entry.custom[customKey] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// typescript cannot guarantee that the key exists for every entry
|
||||||
|
// so we check for the key existence and assert the type
|
||||||
|
if (!(key in entry)) return {};
|
||||||
|
const value = entry[key as keyof OntimeEntry];
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return { userEnteredValue: { stringValue: millisToString(value) } };
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return { userEnteredValue: { stringValue: value } };
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return { userEnteredValue: { boolValue: value } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type googleSheetCellColour = {
|
type googleSheetCellColour = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, isPlaybackActive } from 'ontime-utils';
|
import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,14 +114,14 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
|
|||||||
* Negative offset is under time / ahead of schedule
|
* Negative offset is under time / ahead of schedule
|
||||||
*/
|
*/
|
||||||
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
|
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
|
||||||
const { eventNow, clock } = state;
|
const { eventNow, clock, _startDayOffset } = state;
|
||||||
const { addedTime, current, startedAt } = state.timer;
|
const { addedTime, current, startedAt } = state.timer;
|
||||||
// nothing to calculate if there are no loaded events or if we havent started
|
// nothing to calculate if there are no loaded events or if we havent started
|
||||||
if (eventNow === null || startedAt === null) {
|
if (eventNow === null || startedAt === null || _startDayOffset === null) {
|
||||||
return { absolute: 0, relative: 0 };
|
return { absolute: 0, relative: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { countToEnd, timeStart } = eventNow;
|
const { countToEnd, timeStart, dayOffset } = eventNow;
|
||||||
const { plannedStart, actualStart } = state.rundown;
|
const { plannedStart, actualStart } = state.rundown;
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||||
@@ -131,8 +132,8 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
|||||||
if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
|
if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
|
||||||
}
|
}
|
||||||
|
|
||||||
// difference between planned event start and actual event start (will be positive if we stared behind )
|
// difference between planned event start and actual event start (will be positive if we started behind)
|
||||||
const eventStartOffset = startedAt - timeStart;
|
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
|
||||||
|
|
||||||
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
|
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
|
||||||
const overtime = Math.abs(Math.min(current, 0));
|
const overtime = Math.abs(Math.min(current, 0));
|
||||||
@@ -140,10 +141,11 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
|||||||
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
|
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
|
||||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||||
|
|
||||||
|
// absolute offset is difference between schedule and playback time
|
||||||
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
||||||
|
|
||||||
// the relative offset i the same as the absolute offset but adjusted relative to the actual start time
|
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||||
const relative = absolute + plannedStart - actualStart;
|
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||||
|
|
||||||
// in case of count to end, the absolute offset is just the overtime
|
// in case of count to end, the absolute offset is just the overtime
|
||||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
||||||
@@ -180,3 +182,14 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
|||||||
|
|
||||||
return TimerPhase.Default;
|
return TimerPhase.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the day offset relative to an event start
|
||||||
|
* used byt the runtimeState on first start to get correct offsets
|
||||||
|
*/
|
||||||
|
export function findDayOffset(plannedStart: number, clock: number): number {
|
||||||
|
const distance = clock - plannedStart;
|
||||||
|
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
|
||||||
|
if (distance < -12 * MILLIS_PER_HOUR) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const baseState: RuntimeState = {
|
|||||||
plannedStart: 0,
|
plannedStart: 0,
|
||||||
plannedEnd: 0,
|
plannedEnd: 0,
|
||||||
actualStart: null,
|
actualStart: null,
|
||||||
|
actualGroupStart: null,
|
||||||
|
currentDay: 0,
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
absolute: 0,
|
absolute: 0,
|
||||||
@@ -46,6 +48,8 @@ const baseState: RuntimeState = {
|
|||||||
_group: null,
|
_group: null,
|
||||||
_end: null,
|
_end: null,
|
||||||
_flag: null,
|
_flag: null,
|
||||||
|
_startDayOffset: null,
|
||||||
|
_startEpoch: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user