Compare commits

..

3 Commits

Author SHA1 Message Date
Claude 8ce4627121 chore: upgrade react-router to 8.3.0
Patch/minor bump within the existing ^8 range. Includes the RFC 3986
path-segment encoding change, optional-segment param extraction fix and
route ranking fix for dynamic params with static suffixes.

Lockfile also picks up incidental transitive drift (acorn, fs-extra,
jsonfile, @jridgewell/trace-mapping) from re-resolution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1tN6RYPVBX7FxxbnuqxC2
2026-08-02 07:59:12 +00:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
55 changed files with 458 additions and 900 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-router": "^8.3.0",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
},
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: var(--secondary-color-override, $viewer-secondary-color);
.empty {
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
}
.text {
display: block;
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
}
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -0,0 +1,11 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -0,0 +1,19 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
justify-content: center;
padding-block: 5rem;
}
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div>
);
}
@@ -15,9 +15,4 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -154,9 +154,6 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
/** Shared timer for the running group, null unless the group opted in */
export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
@@ -1,11 +1,11 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
@@ -22,7 +22,6 @@ const secondarySourceLabels: Record<string, string> = {
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const isGroupTimerActive = useGroupTimer() !== null;
const main = (() => {
if (showTimerMessage) return 'Message';
@@ -106,14 +105,6 @@ export default function TimerPreview() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text={`Timer views are showing the ${isGroupTimerActive ? 'shared group timer' : 'event timer'}`}
render={<span />}
className={style.statusIcon}
data-active={isGroupTimerActive}
>
<IoFolderOutline />
</Tooltip>
</div>
</div>
);
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
{!hasEvents ? (
<EmptyFill text={getLocalizedString('common.no_data')} />
) : (
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
return (
<OperatorEvent
key={entry.id}
title={entry.title}
id={entry.id}
colour={entry.colour}
count={entry.entries.length}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
);
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
@@ -1,19 +1,24 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !rundownMetadata;
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) {
return <Empty text='Connecting to server' />;
if (status === 'error') {
return <EmptyFill text={getLocalizedString('common.no_data')} />;
}
return (
@@ -5,8 +5,6 @@ import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
@@ -97,22 +95,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
submitHandler={handleSubmit}
/>
</div>
<div>
<Tooltip
text='Timer views count the scheduled duration of the whole group instead of the running event'
render={<Editor.Label htmlFor='useGroupTimer' />}
>
Group timer
</Tooltip>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
@@ -15,7 +15,6 @@ import {
import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
@@ -190,13 +189,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
{data.useGroupTimer && (
<div className={style.metaEntry}>
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
<Tag>Group timer</Tag>
</Tooltip>
</div>
)}
</div>
</div>
</div>
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid;
place-items: center;
background-color: var(--background-color-override, $viewer-background-color);
height: 100vh;
height: 100dvh;
}
.ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size;
border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) {
left: $dot-size;
animation: lds-ellipsis1 0.6s infinite;
animation: lds-ellipsis1 1s infinite;
}
&:nth-child(2) {
left: $dot-size;
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(3) {
left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite;
animation: lds-ellipsis3 1s infinite;
}
}
}
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
gap: 1.5rem;
}
.list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.all_have_finished')} />
</div>
);
}
@@ -2,7 +2,6 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -34,40 +33,34 @@ function CuesheetTableWrapper({
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
</CuesheetDnd>
);
}
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +19,7 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -64,6 +65,7 @@ export default function CuesheetTable({
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
});
}, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending';
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoading) {
return <EmptyPage text='Loading...' />;
if (status === 'error') {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
}
return (
@@ -68,18 +68,6 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 2.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, $ui-white);
}
}
.secondary {
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
@@ -10,8 +10,9 @@ import {
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowsTimerValue,
resolveTimerDisplay,
getShowModifiers,
getShowProgressBar,
getTotalTime,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
@@ -23,28 +24,23 @@ interface PipTimerProps {
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const groupTimer = useGroupTimer();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
const { showFinished, showWarning, showDanger } = timerDisplay;
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const isPlaying = getIsPlaying(time.playback);
const showsTimerValue = getShowsTimerValue(timerTypeNow);
const showProgressBar = showsTimerValue;
const showProgressBar = getShowProgressBar(timerTypeNow);
// gather timer data
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
@@ -84,11 +80,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={timerDisplay.phase}
data-phase={time.phase}
>
{display}
</div>
{timerDisplay.isGroup && showsTimerValue && <div className='group-indicator'>group</div>}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
@@ -99,12 +94,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={timerDisplay.source.current}
complete={timerDisplay.total}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={timerDisplay.warning}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={timerDisplay.danger}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
max-height: 100%;
width: $content-width;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
}
.info__custom {
display: flex;
gap: 1rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
flex: 0 0 min(192px, 25%);
}
.info__image {
display: block;
max-width: 100%;
max-height: 100%;
height: auto;
object-fit: contain;
}
.link.info__value {
display: flex;
gap: $view-element-gap;
display: inline-flex;
gap: 0.35em;
align-items: center;
color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
<EmptyPage text={getLocalizedString('common.no_data')} />
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'>
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.url && (
<div>
<div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{info.url ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
@@ -1,6 +1,7 @@
import { OntimeView } from 'ontime-types';
import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
<EmptyFill text={getLocalizedString('common.no_data')} />
)}
</div>
);
-12
View File
@@ -131,18 +131,6 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 1.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, var(--timer-color-override, $ui-white));
}
}
.secondary {
+18 -25
View File
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
@@ -25,8 +25,9 @@ import {
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowsTimerValue,
resolveTimerDisplay,
getShowModifiers,
getShowProgressBar,
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
@@ -42,7 +43,7 @@ export default function TimerLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
@@ -71,29 +72,23 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
timeformat,
} = useTimerOptions();
const groupTimer = useGroupTimer();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
freezeOvertime,
freezeMessage,
hidePhase,
});
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
);
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showsTimerValue = getShowsTimerValue(viewTimerType);
const showProgressBar = !hideProgress && showsTimerValue;
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
@@ -107,7 +102,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
);
// gather timer data
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
@@ -182,14 +178,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={timerDisplay.phase}
data-phase={time.phase}
>
{display}
</div>
)}
{timerDisplay.isGroup && showsTimerValue && !showEndMessage && (
<div className='group-indicator'>group</div>
)}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondaryContent}
@@ -200,12 +193,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={timerDisplay.source.current}
complete={timerDisplay.total}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={timerDisplay.warning}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={timerDisplay.danger}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -1,110 +0,0 @@
import { GroupTimerState, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { getShowsTimerValue, resolveTimerDisplay } from '../timer.utils';
const makeTime = (patch: Partial<TimerState> = {}): TimerState => ({
addedTime: 0,
current: 10000,
duration: 60000,
elapsed: 50000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: null,
...patch,
});
const makeGroupTimer = (patch: Partial<GroupTimerState> = {}): GroupTimerState => ({
current: 100000,
elapsed: 80000,
duration: 180000,
...patch,
});
const resolve = (time: TimerState, groupTimer: GroupTimerState | null) =>
resolveTimerDisplay({
time,
groupTimer,
event: { timeWarning: 120000, timeDanger: 60000 },
timerType: TimerType.CountDown,
countToEnd: false,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
describe('resolveTimerDisplay()', () => {
it('uses the event timer when there is no group timer', () => {
const time = makeTime();
const result = resolve(time, null);
expect(result.isGroup).toBe(false);
expect(result.source).toBe(time);
expect(result.total).toBe(60000);
expect(result.warning).toBe(120000);
expect(result.danger).toBe(60000);
});
it('adds the time added to an event into the event total', () => {
expect(resolve(makeTime({ addedTime: 30000 }), null).total).toBe(90000);
});
it('uses the group timer when one is present', () => {
const groupTimer = makeGroupTimer();
const result = resolve(makeTime(), groupTimer);
expect(result.isGroup).toBe(true);
expect(result.source).toBe(groupTimer);
expect(result.total).toBe(180000);
});
it('drops the event thresholds while showing the group', () => {
const result = resolve(makeTime({ phase: TimerPhase.Danger }), makeGroupTimer());
expect(result.warning).toBeUndefined();
expect(result.danger).toBeUndefined();
});
it.each([TimerPhase.Warning, TimerPhase.Danger])(
'reports the group as running while the event is in %s',
(phase) => {
const result = resolve(makeTime({ phase }), makeGroupTimer());
expect(result.phase).toBe(TimerPhase.Default);
expect(result.showWarning).toBe(false);
expect(result.showDanger).toBe(false);
expect(result.showFinished).toBe(false);
},
);
it('reports overtime only once the group itself has run out of time', () => {
const overrunningEvent = makeTime({ current: -5000, phase: TimerPhase.Overtime });
// the event is over, but the group still has time left
const stillRunning = resolve(overrunningEvent, makeGroupTimer({ current: 60000 }));
expect(stillRunning.phase).toBe(TimerPhase.Default);
expect(stillRunning.showFinished).toBe(false);
const overtime = resolve(overrunningEvent, makeGroupTimer({ current: -1000 }));
expect(overtime.phase).toBe(TimerPhase.Overtime);
expect(overtime.showFinished).toBe(true);
});
it.each([TimerPhase.Pending, TimerPhase.None])('keeps the %s playback phase on the group', (phase) => {
// these describe the playback state rather than a threshold, so they are true of the group too
expect(resolve(makeTime({ phase }), makeGroupTimer()).phase).toBe(phase);
});
});
describe('getShowsTimerValue()', () => {
it('is true for timer types which render the running timer', () => {
expect(getShowsTimerValue(TimerType.CountDown)).toBe(true);
expect(getShowsTimerValue(TimerType.CountUp)).toBe(true);
});
it('is false for timer types which do not reflect what is loaded', () => {
expect(getShowsTimerValue(TimerType.Clock)).toBe(false);
expect(getShowsTimerValue(TimerType.None)).toBe(false);
});
});
+2 -59
View File
@@ -1,5 +1,4 @@
import {
GroupTimerState,
MaybeNumber,
MessageState,
OntimeEvent,
@@ -7,7 +6,6 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
@@ -36,11 +34,9 @@ export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): num
}
/**
* Whether this timer type renders a value derived from the running timer.
* Clock shows the time of day and none shows nothing, so neither reflects what is loaded,
* which also means neither can carry a progress bar or a group timer indicator
* Whether the progress bar should be shown for this timer type
*/
export function getShowsTimerValue(timerType: TimerType) {
export function getShowProgressBar(timerType: TimerType) {
return timerType !== TimerType.None && timerType !== TimerType.Clock;
}
@@ -118,59 +114,6 @@ export function getShowModifiers(
};
}
interface ResolveTimerDisplayOptions {
time: TimerState;
/** when present, views show the group instead of the running event */
groupTimer: GroupTimerState | null;
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | null;
timerType: TimerType;
countToEnd: boolean;
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
}
/**
* Resolves which timer a view should display, and the modifiers that go with it.
*
* A group has no warning or danger thresholds of its own, so it only ever reports as
* running or overtime. Feeding that phase through `getShowModifiers` means the warning
* and danger states are suppressed as a consequence of what a group is, rather than
* every view having to remember to special case them.
*/
export function resolveTimerDisplay({
time,
groupTimer,
event,
timerType,
countToEnd,
freezeOvertime,
freezeMessage,
hidePhase,
}: ResolveTimerDisplayOptions) {
const isGroup = groupTimer !== null;
const phase = (() => {
if (!isGroup) return time.phase;
// pending and none describe the playback state rather than a threshold,
// they are just as true of the group as they are of the event
if (time.phase === TimerPhase.Pending || time.phase === TimerPhase.None) return time.phase;
return groupTimer.current <= 0 ? TimerPhase.Overtime : TimerPhase.Default;
})();
return {
isGroup,
phase,
/** the values to render, shaped for getTimerByType */
source: isGroup ? groupTimer : time,
/** the target of a progress bar */
total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime),
// thresholds belong to the event, they carry no meaning against a group duration
warning: isGroup ? undefined : event?.timeWarning,
danger: isGroup ? undefined : event?.timeDanger,
...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase),
};
}
/**
* What, if anything, should be displayed in the secondary field
*/
@@ -411,7 +411,6 @@ export function migrateRundown(
targetDuration: null,
colour: '', //leave default colour
custom: {}, // leave empty
useGroupTimer: false, // opt-in feature, off for migrated projects
// !==== RUNTIME METADATA ====! //
revision: -1,
timeStart: null,
@@ -316,7 +316,6 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
event2: {
id: 'event2',
@@ -393,7 +392,6 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
delay: {
type: SupportedEntry.Delay,
@@ -301,23 +301,6 @@ describe('parseRundown()', () => {
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
it('normalises groups from projects made before the group timer existed', () => {
const rundown = {
id: 'test',
title: '',
order: ['group'],
flatOrder: ['group'],
entries: {
// a group as it would have been persisted by a previous version
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
},
revision: 1,
} as unknown as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
});
});
describe('sanitiseCustomFields()', () => {
@@ -15,7 +15,6 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -611,25 +610,3 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
describe('createGroupPatch()', () => {
it('preserves useGroupTimer when it is not part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
const patched = createGroupPatch(original, { title: 'Morning sessions' });
expect(patched.title).toBe('Morning sessions');
expect(patched.useGroupTimer).toBe(true);
});
it('updates useGroupTimer when it is part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
});
it('ignores non boolean values for useGroupTimer', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
// @ts-expect-error -- testing a value coming from an unvalidated request
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
});
});
@@ -179,8 +179,6 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
colour: makeString(patchGroup.colour, originalGroup.colour),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
timeEnd: originalGroup.timeEnd,
-1
View File
@@ -147,7 +147,6 @@ There are four entry types discriminated by \`type\`:
note: string
entries: EntryId[]
targetDuration: number | null
useGroupTimer: boolean // views show a shared timer for the whole group
custom: { [key: string]: string }
timeStart: number | null // calculated from nested entries (runtime)
timeEnd: number | null // calculated from nested entries (runtime)
+1 -4
View File
@@ -54,9 +54,7 @@ export type EventFieldArgs = Partial<
>;
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
export type GroupFieldArgs = Partial<
Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom' | 'useGroupTimer'>
>;
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
export type TargetRundownArgs = { rundownId?: string };
@@ -172,7 +170,6 @@ function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGro
if (args.colour !== undefined) patch.colour = args.colour;
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
if (args.custom !== undefined) patch.custom = args.custom;
if (args.useGroupTimer !== undefined) patch.useGroupTimer = args.useGroupTimer;
return Object.keys(patch).length > 1 ? patch : null;
}
-1
View File
@@ -207,7 +207,6 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({
clock: state.clock,
timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message },
offset: state.offset,
rundown: state.rundown,
-11
View File
@@ -39,7 +39,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -173,7 +172,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -221,7 +219,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -363,7 +360,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -480,7 +476,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -593,7 +588,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -640,7 +634,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -775,7 +768,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -859,7 +851,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -968,7 +959,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1039,7 +1029,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -1,16 +1,4 @@
import {
EndAction,
MaybeNumber,
OntimeEvent,
OntimeGroup,
Playback,
Rundown,
SupportedEntry,
TimeOfDay,
TimeStrategy,
TimerPhase,
TimerType,
} from 'ontime-types';
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import type { RuntimeState } from '../../stores/runtimeState.js';
@@ -19,8 +7,6 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -1466,138 +1452,3 @@ describe('findDay()', () => {
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
});
});
describe('getGroupTiming()', () => {
const makeEvent = (id: string, duration: number, patch: object = {}) =>
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
const makeEntries = (...entries: OntimeEvent[]) =>
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
it('splits the scheduled content around the loaded event', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
});
it('accounts for the gaps between events', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
// the gap before the loaded event has already passed, so it counts as time spent
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
});
it('splits into values which add up to the group duration', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
const groupDuration = 10 + 20 + 5 + 30;
for (const id of ['1', '2', '3']) {
const timing = getGroupTiming(group, entries, id)!;
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
}
});
it('skips entries which are not playable events', () => {
const group = makeGroup(['1', '2', '3', '4']);
const entries = {
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
} as Rundown['entries'];
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
});
it('returns null when the loaded event is not part of the group', () => {
const group = makeGroup(['1', '2']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
});
});
describe('getGroupTimer()', () => {
const makeState = (patch: {
useGroupTimer?: boolean;
duration?: number;
timing?: { before: number; after: number } | null;
current?: MaybeNumber;
elapsed?: MaybeNumber;
addedTime?: number;
}) =>
({
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
timer: {
current: patch.current ?? null,
elapsed: patch.elapsed ?? null,
addedTime: patch.addedTime ?? 0,
},
}) as RuntimeState;
it('is null when there is no group running', () => {
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
});
it('is null when the group has not opted in', () => {
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
});
it('is null when the loaded event is not part of the group', () => {
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
});
it('offsets the event timer by the content scheduled around it', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
});
it('keeps elapsed and current adding up to the total', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('grows the total with the time added to the running event', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 160,
elapsed: 0,
addedTime: 60,
});
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.duration).toBe(160);
// elapsed keeps counting up rather than clamping at zero once time is added
expect(groupTimer.elapsed).toBe(0);
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('reports elapsed time while the group is in credit from added time', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 150,
elapsed: 10,
addedTime: 60,
});
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
});
it('goes negative when the group runs into overtime', () => {
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
expect(getGroupTimer(state)?.current).toBe(-15);
});
});
@@ -709,18 +709,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
/**
* the group timer is the event timer offset by a constant, so it changes exactly when
* the event timer does and can share its throttling.
* entry changes are also considered since loading an event can move us between groups
*/
if (updateTimer || entryChanged) {
if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
+2 -86
View File
@@ -1,18 +1,7 @@
import {
Day,
EntryId,
GroupTimerState,
MaybeNumber,
OntimeGroup,
Rundown,
TimeOfDay,
TimerPhase,
isOntimeEvent,
isPlayableEvent,
} from 'ontime-types';
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
import type { RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -242,76 +231,3 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
/**
* Splits a group's scheduled content around the loaded event.
*
* The result is stable for as long as the same event is loaded, so it is calculated
* when the group is loaded rather than on every update.
* The aggregation mirrors the group duration calculated in the rundown
* (see rundown.dao.ts): non playable entries are skipped and the gap is
* accounted for in every entry other than the first.
*/
export function getGroupTiming(
group: OntimeGroup,
entries: Rundown['entries'],
currentEventId: EntryId,
): GroupTiming | null {
const currentIndex = group.entries.indexOf(currentEventId);
if (currentIndex === -1) {
return null;
}
let before = 0;
let after = 0;
for (let i = 0; i < group.entries.length; i++) {
const entry = entries[group.entries[i]];
if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) {
continue;
}
// the first entry of the group has no gap to account for,
// any other entry could be preceded by idle time
const gap = i > 0 ? entry.gap : 0;
if (i < currentIndex) {
before += gap + entry.duration;
} else if (i === currentIndex) {
// the loaded event contributes its own duration through the event timer,
// but the idle time before it has already passed
before += gap;
} else {
after += gap + entry.duration;
}
}
return { before, after };
}
/**
* Derives the shared group timer from the event timer.
*
* Keeping this relative to the event timer is what makes the group behave as if it were
* a single event containing all its children: pause, added time, overtime, roll and
* midnight rollovers are all inherited instead of being recalculated.
*/
export function getGroupTimer(state: RuntimeState): GroupTimerState | null {
const { groupNow, _groupTiming } = state;
if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) {
return null;
}
const { current, elapsed, addedTime } = state.timer;
if (current === null) {
return null;
}
return {
current: current + _groupTiming.after,
elapsed: (elapsed ?? 0) + _groupTiming.before,
// mirrors the total time of an event, which grows with the time added to it
duration: groupNow.duration + addedTime,
};
}
@@ -9,8 +9,6 @@ const baseState: RuntimeState = {
eventNext: null,
eventFlag: null,
groupNow: null,
groupTimer: null,
_groupTiming: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
-23
View File
@@ -16,7 +16,6 @@ import {
TimeOfDay,
TimerPhase,
TimerState,
GroupTimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
@@ -40,8 +39,6 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -54,12 +51,6 @@ type ExpectedMetadata = {
isLinkedToLoaded: boolean;
} | null;
/** scheduled content of the running group, split around the loaded event */
export type GroupTiming = {
before: number;
after: number;
};
export type RuntimeState = {
clock: TimeOfDay;
groupNow: OntimeGroup | null;
@@ -68,8 +59,6 @@ export type RuntimeState = {
eventFlag: PlayableEvent | null;
offset: Offset;
timer: TimerState;
/** derived from the timer, only refreshed on the getState() projection */
groupTimer: GroupTimerState | null;
rundown: RundownState;
// private properties of the timer calculations
_timer: {
@@ -83,7 +72,6 @@ export type RuntimeState = {
totalDelay: number; // this value comes from rundown service
};
_group: ExpectedMetadata;
_groupTiming: GroupTiming | null;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
@@ -98,7 +86,6 @@ const runtimeState: RuntimeState = {
eventFlag: null,
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
groupTimer: null,
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: {
forceFinish: null,
@@ -111,7 +98,6 @@ const runtimeState: RuntimeState = {
totalDelay: 0,
},
_group: null,
_groupTiming: null,
_flag: null,
_end: null,
_startEpoch: null,
@@ -129,8 +115,6 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
// derived here so it can never drift from the timer values it is built on
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
@@ -172,7 +156,6 @@ export function clearState() {
runtimeState.groupNow = null;
runtimeState._group = null;
runtimeState._groupTiming = null;
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
@@ -914,7 +897,6 @@ export function loadGroupFlagAndEnd(
const previousGroup = state.groupNow?.id;
state.groupNow = null;
state._group = null;
state._groupTiming = null;
state.eventFlag = null;
state._flag = null;
state._end = null;
@@ -938,11 +920,6 @@ export function loadGroupFlagAndEnd(
state.rundown.actualGroupStart = null;
}
// the split is stable while the same event is loaded, so we only calculate it here
if (state.groupNow !== null) {
state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id);
}
// if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent;
// if we don't have a last event for the group there is no need to find its end time
@@ -46,8 +46,6 @@ export type OntimeGroup = OntimeBaseEvent & {
targetDuration: MaybeNumber;
colour: string;
custom: EntryCustomFields;
/** whether views should display a shared timer for the group instead of the event timer */
useGroupTimer: boolean;
// !==== RUNTIME METADATA ====! //
revision: number;
timeStart: MaybeNumber; // calculated at runtime
@@ -17,7 +17,6 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null, // derived from the timer, null unless the running group opted in
message: {
timer: {
text: '',
@@ -3,14 +3,12 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Offset } from './Offset.type.js';
import type { RundownState } from './RundownState.type.js';
import type { GroupTimerState, TimerState } from './TimerState.type.js';
import type { TimerState } from './TimerState.type.js';
export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
/** shared timer for the running group, null unless the group opted in */
groupTimer: GroupTimerState | null;
// messages service
message: MessageState;
@@ -11,22 +11,6 @@ export enum TimerPhase {
Pending = 'pending',
}
/**
* Timer for the running group, present when the group opted into a shared timer.
*
* The group is treated as a single event containing all its children: the values are
* derived from the running event timer plus the content scheduled around it, so pause,
* added time, overtime and roll are inherited from the event timer rather than recalculated.
*/
export type GroupTimerState = {
/** Time remaining in the group */
current: number;
/** Time elapsed since the group started */
elapsed: number;
/** Total time in the group, includes time added to the running event */
duration: number;
};
/**
* Gathers the current running timer state
*/
+1 -1
View File
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
export { OffsetMode } from './definitions/runtime/Offset.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -54,7 +54,6 @@ export const group: Omit<OntimeGroup, 'id'> = {
targetDuration: null,
colour: '',
custom: {},
useGroupTimer: false,
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
timeStart: null, // calculated at runtime
@@ -69,7 +69,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
revision: 0,
timeStart: null,
timeEnd: null,
+40 -11
View File
@@ -137,8 +137,8 @@ importers:
specifier: 5.6.0
version: 5.6.0(react@19.2.7)
react-router:
specifier: ^8.0.1
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: ^8.3.0
version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-virtuoso:
specifier: ^4.18.7
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1508,6 +1508,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
@@ -2972,8 +2975,8 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -3726,6 +3729,10 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fs-extra@11.4.0:
resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
engines: {node: '>=14.14'}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
@@ -4049,6 +4056,9 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
@@ -4595,8 +4605,8 @@ packages:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
react-router@8.0.1:
resolution: {integrity: sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==}
react-router@8.3.0:
resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==}
engines: {node: '>=22.22.0'}
peerDependencies:
react: '>=19.2.7'
@@ -6303,7 +6313,7 @@ snapshots:
dependencies:
cross-dirname: 0.1.0
debug: 4.4.3
fs-extra: 11.3.4
fs-extra: 11.4.0
minimist: 1.2.8
postject: 1.0.0-alpha.6
transitivePeerDependencies:
@@ -6627,7 +6637,7 @@ snapshots:
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.5': {}
@@ -6636,6 +6646,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.1': {}
'@lezer/css@1.3.3':
@@ -7741,7 +7756,7 @@ snapshots:
mime-types: 3.0.1
negotiator: 1.0.0
acorn@8.16.0: {}
acorn@8.18.0: {}
adler-32@1.3.1: {}
@@ -8644,6 +8659,13 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@11.4.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
optional: true
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
@@ -9025,6 +9047,13 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
optional: true
jwa@2.0.0:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -9512,7 +9541,7 @@ snapshots:
react-refresh@0.18.0: {}
react-router@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
cookie-es: 3.1.1
react: 19.2.7
@@ -9928,7 +9957,7 @@ snapshots:
terser@5.46.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
acorn: 8.18.0
commander: 2.20.3
source-map-support: 0.5.21