Compare commits

...

37 Commits

Author SHA1 Message Date
arc-alex 6397d33eaa feat: option to select all events for countdown 2025-09-16 16:59:29 +02:00
arc-alex 9288566a7f chore: remove unused code 2025-09-16 16:38:11 +02:00
Alex Christoffer Rasmussen 7fd9f83fb0 fix docker ignore (#1780) 2025-09-16 16:27:00 +02:00
Carlos Valente e621f1386e fix(cuesheet): infinite render loop on resizing columns 2025-09-16 06:43:19 +02:00
Carlos Valente b51e7cbd2d fix: maintain multiline in cuesheet cells 2025-09-14 14:19:18 +02:00
Carlos Valente 7cd92ce5f7 docs: add contribution guidelines 2025-09-14 14:19:18 +02:00
Carlos Valente f6a02abf36 fix: skip nested events 2025-09-14 14:19:18 +02:00
Carlos Valente e4b0df42cf feat: allow finding milestones 2025-09-14 14:19:18 +02:00
Carlos Valente ded8bddb2d refactor: improve automated following 2025-09-14 14:19:18 +02:00
Carlos Valente 8d3ce46c56 fix: allow moving a group after another 2025-09-14 14:19:18 +02:00
Carlos Valente 578cb2b244 refactor: improve pin styling 2025-09-14 14:19:18 +02:00
Carlos Valente c79ee193c9 fix: prevent layout reflow on different entry types 2025-09-14 14:19:18 +02:00
Carlos Valente 5810ae0d54 refactor: add default value to secondary source 2025-09-14 14:19:18 +02:00
Carlos Valente bfbf8574e3 fix: phase style overrides in timer 2025-09-14 14:19:18 +02:00
Alex Christoffer Rasmussen 490e429f44 cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-13 16:31:56 +02:00
Alex Christoffer Rasmussen 2e950965e0 fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-08 09:49:01 +02:00
arc-alex cc34db775a chore: bump version 2025-09-08 06:51:07 +02:00
Carlos Valente 0057b4a3f1 refactor: tweaks to countdown styles 2025-09-08 06:23:39 +02:00
Carlos Valente ac8c1e333e bump version to 4.0.0-beta.2 2025-09-08 06:09:31 +02:00
Carlos Valente 321297b685 fix: redirect to preset 2025-09-08 06:09:00 +02:00
Alex Christoffer Rasmussen 67e67d6a5d feat: propper timeuntil calc in countdown view (#1765)
* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
2025-09-07 21:49:45 +02:00
Carlos Valente 4385e0b9c9 refactor: ensure clocks timer and clock are in sync (#1772)
* refactor: ensure clocks timer and clock are in sync

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-07 21:49:45 +02:00
Carlos Valente 02243242f1 fix: redirect to preset 2025-09-07 21:49:45 +02:00
arc-alex 1af42b766d fix: insert group-end where needed 2025-09-07 21:49:45 +02:00
arc-alex 9d6ec3758b chore: clarifying comments 2025-09-07 21:49:45 +02:00
Carlos Valente 9e78ff4d1c refactor: export data for all entry types 2025-09-07 21:49:45 +02:00
Carlos Valente 2037aef080 fix: prevent clipping on bold font sizes 2025-09-07 21:49:45 +02:00
Carlos Valente 48b9cf042f refactor: lax validation for settings
with this changes we allow the client and server to share the same data
shape. even if some of it is useless.
we also get an extra version property in the payload, if we want to save
the transfer cost, we would rather remove it from the sent payload
2025-09-07 21:49:45 +02:00
Carlos Valente 1866e5e78d refactor: simplify restore point 2025-09-07 21:49:45 +02:00
Carlos Valente bc1b3800c8 refactor: op clock uses time format string 2025-09-07 21:49:45 +02:00
Carlos Valente a2505c838b refactor: add none option to optional select 2025-09-07 21:49:45 +02:00
Carlos Valente bffa863fb4 fix: multi select infinite loop 2025-09-07 21:49:45 +02:00
Carlos Valente d00d7677bc fix: merge virtuoso and rundown styles 2025-09-07 21:49:45 +02:00
Carlos Valente 3ae73be562 refactor: prevent emitting errors from user pages 2025-09-07 21:49:45 +02:00
Carlos Valente 763080cd4d fix: generate link for companion 2025-09-07 21:49:45 +02:00
Carlos Valente 6ff06b21a8 fix: hide fade out elements by default 2025-09-07 21:49:45 +02:00
Carlos Valente adebde1b43 chore: upgrade client packages 2025-09-07 21:49:45 +02:00
83 changed files with 1614 additions and 1209 deletions
+2 -1
View File
@@ -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
+13
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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) {
@@ -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,
};
}
+3 -1
View File
@@ -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),
}; };
@@ -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)) {
@@ -15,3 +15,7 @@
cursor: text ; cursor: text ;
user-select: text; user-select: text;
} }
.self {
background-color: $blue-1100;
}
@@ -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',
@@ -179,8 +179,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 }) {
+7 -2
View File
@@ -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;
} }
@@ -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}`,
+1 -1
View File
@@ -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;
@@ -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',
@@ -17,8 +17,11 @@ export const getBackstageOptions = (
customFields: CustomFields, customFields: CustomFields,
projectData: ProjectData, projectData: ProjectData,
): ViewOption[] => { ): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]); const secondaryOptions = makeOptionsFromCustomFields(customFields, [
const projectDataOptions = makeProjectDataOptions(projectData); { 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,7 +35,7 @@ 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',
}, },
], ],
}, },
@@ -47,7 +50,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',
}, },
], ],
}, },
+11 -3
View File
@@ -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 {
@@ -140,12 +144,16 @@ $item-height: 3.5rem;
color: $delay-color; color: $delay-color;
} }
.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 {
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types'; import { isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
@@ -9,13 +9,14 @@ 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';
import Loader from '../common/loader/Loader'; import Loader from '../common/loader/Loader';
import { getCountdownOptions, useCountdownOptions } from './countdown.options'; import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils'; import { CountdownSubscription, getOrderedSubscriptions } from './countdown.utils';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions'; import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown'; import SingleEventCountdown from './SingleEventCountdown';
@@ -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;
@@ -58,6 +59,8 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
[defaultFormat, customFields, subscriptions], [defaultFormat, customFields, subscriptions],
); );
console.log(subscriptions)
return ( return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'> <div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor target={OntimeView.Countdown} viewOptions={countdownOptions} /> <ViewParamsEditor target={OntimeView.Countdown} viewOptions={countdownOptions} />
@@ -85,8 +88,8 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
} }
interface CountdownContentsProps { interface CountdownContentsProps {
playableEvents: OntimeEvent[]; playableEvents: ExtendedEntry<OntimeEvent>[];
subscriptions: EntryId[]; subscriptions: CountdownSubscription;
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5'; import { IoArrowBack, IoClose, IoSaveOutline, IoAlbumsOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { EntryId, OntimeEvent } from 'ontime-types'; import { EntryId, OntimeEvent } from 'ontime-types';
@@ -7,18 +7,19 @@ import Button from '../../common/components/buttons/Button';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime'; import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { makeSubscriptionsUrl } from './countdown.utils'; import { CountdownSubscription, makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: OntimeEvent[]; events: OntimeEvent[];
subscriptions: EntryId[]; subscriptions: CountdownSubscription;
disableEdit: () => void; disableEdit: () => void;
} }
export default function CountdownSelect({ events, subscriptions, disableEdit }: CountdownSelectProps) { export default function CountdownSelect({ events, subscriptions, disableEdit }: CountdownSelectProps) {
const [selected, setSelected] = useState<EntryId[]>(subscriptions); const maybeAllSubscriptions: EntryId[] = subscriptions === 'all' ? events.map((event) => event.id) : subscriptions;
const [selected, setSelected] = useState<EntryId[]>(maybeAllSubscriptions);
const navigate = useNavigate(); const navigate = useNavigate();
/** /**
@@ -47,6 +48,18 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
navigate(url.search.toString()); navigate(url.search.toString());
}; };
/**
* Creates a URL with all
* and navigates to it
*/
const applyAll = () => {
// we remove events that no longer exist to avoid stale subscriptions
const url = makeSubscriptionsUrl(window.location.href, 'all');
disableEdit();
setSelected([]);
navigate(url.search.toString());
};
// make a copy of the selected array for quick lookup // make a copy of the selected array for quick lookup
const selectedIds = new Set(selected); const selectedIds = new Set(selected);
@@ -86,6 +99,10 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
<Button variant='subtle' size='xlarge' onClick={disableEdit}> <Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back <IoArrowBack /> Go back
</Button> </Button>
<Button variant='subtle' size='xlarge' onClick={applyAll}>
{/* TODO: icon ??? */}
<IoAlbumsOutline /> Use All
</Button>
<Button variant='subtle' size='xlarge' onClick={() => setSelected([])} disabled={selected.length === 0}> <Button variant='subtle' size='xlarge' onClick={() => setSelected([])} disabled={selected.length === 0}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
@@ -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>
)}
</> </>
); );
} }
@@ -8,13 +8,17 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../features/viewers/common/viewUtils';
import { CountdownSubscription } from './countdown.utils';
export const getCountdownOptions = ( export const getCountdownOptions = (
timeFormat: string, timeFormat: string,
customFields: CustomFields, customFields: CustomFields,
persistedSubscriptions: EntryId[], persistedSubscriptions: CountdownSubscription,
): 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 +32,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',
}, },
], ],
}, },
@@ -52,7 +56,7 @@ export const getCountdownOptions = (
id: 'sub', id: 'sub',
title: 'Event subscription', title: 'Event subscription',
description: 'The events to follow', description: 'The events to follow',
values: persistedSubscriptions, values: persistedSubscriptions === 'all' ? ['all'] : persistedSubscriptions,
type: 'persist', type: 'persist',
}, },
], ],
@@ -61,7 +65,7 @@ export const getCountdownOptions = (
}; };
type CountdownOptions = { type CountdownOptions = {
subscriptions: EntryId[]; subscriptions: CountdownSubscription;
secondarySource: keyof OntimeEvent | null; secondarySource: keyof OntimeEvent | null;
showExpected: boolean; showExpected: boolean;
}; };
@@ -82,8 +86,10 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return searchParams.getAll(key) as EntryId[]; return searchParams.getAll(key) as EntryId[];
}; };
const subscriptions = getArrayValues('sub');
return { return {
subscriptions: getArrayValues('sub'), subscriptions: subscriptions.at(0) === 'all' ? 'all' : subscriptions,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null, secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
showExpected: isStringBoolean(getValue('showExpected')), showExpected: isStringBoolean(getValue('showExpected')),
}; };
+107 -131
View File
@@ -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,11 @@ export function sanitiseTitle(title: string | null) {
return title ?? '{no title}'; return title ?? '{no title}';
} }
export type CountdownSubscription = EntryId[] | 'all';
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/** /**
* Whether the current event is live * Whether the current event is live
*/ */
@@ -18,144 +26,103 @@ 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,
),
}; };
} }
/** /**
* Adds a set of subscriptions to the URL parameters * Adds a set of subscriptions to the URL parameters
*/ */
export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) { export function makeSubscriptionsUrl(urlRef: string, subscriptions: CountdownSubscription) {
const url = new URL(urlRef); const url = new URL(urlRef);
const newParams = new URLSearchParams(); const newParams = new URLSearchParams();
@@ -166,10 +133,14 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
} }
} }
// add new subscriptions if (subscriptions === 'all') {
subscriptions.forEach((id) => { newParams.append('sub', 'all');
newParams.append('sub', id); } else {
}); // add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
}
url.search = newParams.toString(); url.search = newParams.toString();
@@ -181,34 +152,39 @@ 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: CountdownSubscription,
playableEvents: T[],
): T[] {
if (subscriptions === 'all') return playableEvents;
return playableEvents.filter((event) => subscriptions.includes(event.id)); return playableEvents.filter((event) => subscriptions.includes(event.id));
} }
/** export function isOutsideRange(a: number, b: number): boolean {
* Checks through the rundown whether the current event is linked to the loaded event return Math.abs(a - b) > MILLIS_PER_MINUTE;
*/ }
export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId | null, currentId: EntryId): boolean {
// if nothing is loaded, we return true to simplify the logic export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
if (!loadedId) {
return true; export function extendEventData(
} event: ExtendedEntry<OntimeEvent>,
currentDay: number,
const loadedIndex = events.findIndex((event) => event.id === loadedId); actualStart: MaybeNumber,
if (loadedIndex === -1) { plannedStart: MaybeNumber,
return true; offset: number,
} mode: OffsetMode,
reportData: OntimeReport,
for (let i = loadedIndex; i < events.length; i++) { ): CountdownEvent {
const event = events[i]; const { totalGap, isLinkedToLoaded } = event;
if (event.id === currentId) { const expectedStart = getExpectedStart(event, {
return true; currentDay,
} totalGap,
actualStart,
if (event.linkStart === null) { plannedStart,
return false; isLinkedToLoaded,
} offset,
} mode,
});
return true; 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} />;
});
}} }}
/> />
@@ -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,3 +4,7 @@
padding-top: 0.25em; padding-top: 0.25em;
width: 100%; width: 100%;
} }
.multiline {
white-space: break-spaces;
}
@@ -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',
}} }}
@@ -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}
/>
);
}
@@ -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);
+3 -3
View File
@@ -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 }),
}; };
+9 -7
View File
@@ -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')),
}; };
} }
+2 -2
View File
@@ -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
}; };
/** /**
+2 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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
+2 -1
View File
@@ -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';
+1 -1
View File
@@ -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: {
-146
View File
@@ -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);
});
});
});
@@ -0,0 +1,63 @@
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,
};
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);
});
});
});
@@ -0,0 +1,124 @@
/* 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,
};
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,
};
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,
};
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,
};
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,53 @@
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',
])
) {
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;
}
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,10 @@
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
export type RestorePoint = {
playback: Playback;
selectedEventId: MaybeString;
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: 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];
@@ -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,16 +406,20 @@ 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 // update the corresponding row with event data
rundown.order.forEach((entryId, index) => { sheetOrder.forEach((entryId, index) => {
const entry = rundown.entries[entryId]; const isGroupEnd = entryId.startsWith('group-end-');
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); 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));
}); });
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
@@ -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,39 @@ 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 flatten the milestones
if (key === 'title') { if (key.startsWith('custom')) {
return { userEnteredValue: { stringValue: event[key] } }; const customKey = key.split(':')[1];
} return { userEnteredValue: { stringValue: entry.custom[customKey] } };
if (key === 'timerType') {
return { userEnteredValue: { stringValue: 'group' } };
}
} }
return {}; // we need to remap the event type to timer type in the case of groups and milestones
if (key === 'timerType') {
if (isOntimeGroup(entry))
return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
return { userEnteredValue: { stringValue: entry.timerType } };
}
// 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 -1
View File
@@ -24,7 +24,7 @@ import {
} from 'ontime-utils'; } from 'ontime-utils';
import { timeNow } from '../utils/time.js'; import { timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/RestoreService.js'; import type { RestorePoint } from '../services/restore-service/restore.type.js';
import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js'; import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js'; import { timerConfig } from '../setup/config.js';
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "4.0.0-alpha.6", "version": "4.0.0-beta.3",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
+2
View File
@@ -1,6 +1,7 @@
export const langEn = { export const langEn = {
'common.expected_finish': 'Expected Finish', 'common.expected_finish': 'Expected Finish',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Now', 'common.now': 'Now',
'common.next': 'Next', 'common.next': 'Next',
'common.scheduled_start': 'Scheduled start', 'common.scheduled_start': 'Scheduled start',
@@ -13,6 +14,7 @@ export const langEn = {
'common.no_data': 'No data', 'common.no_data': 'No data',
'countdown.ended': 'Event ended at', 'countdown.ended': 'Event ended at',
'countdown.running': 'Event running', 'countdown.running': 'Event running',
'countdown.loaded': 'Event loaded',
'countdown.select_event': 'Select an event to follow', 'countdown.select_event': 'Select an event to follow',
'countdown.to_start': 'Time to start', 'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start', 'countdown.waiting': 'Waiting for event start',
@@ -14,7 +14,7 @@ export const defaultImportMap = {
title: 'title', title: 'title',
countToEnd: 'count to end', countToEnd: 'count to end',
skip: 'skip', skip: 'skip',
note: 'notes', note: 'note',
colour: 'colour', colour: 'colour',
endAction: 'end action', endAction: 'end action',
timerType: 'timer type', timerType: 'timer type',
+377 -320
View File
File diff suppressed because it is too large Load Diff