mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fec1651e39 | |||
| 1fad99f505 | |||
| 636b2e7c3e |
@@ -30,7 +30,7 @@
|
|||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.18.7",
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
|
||||||
|
|
||||||
.emptyContainer {
|
.emptyContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: $white-10;
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(100%, 14rem);
|
width: min(100%, 24rem);
|
||||||
margin: 0 auto -1.5rem;
|
margin-inline: auto;
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
font-size: clamp(1rem, 1.55vw, 1.5rem);
|
font-size: 2em;
|
||||||
line-height: 1.35;
|
max-width: min(100%, 600px);
|
||||||
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,5 +1,4 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { IoWarningOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
@@ -10,18 +9,12 @@ interface EmptyProps {
|
|||||||
text?: string;
|
text?: string;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
|
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||||
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
|
|
||||||
style={injectedStyles}
|
|
||||||
role={variant === 'error' ? 'alert' : undefined}
|
|
||||||
>
|
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
|
|
||||||
{text && <span className={style.text}>{text}</span>}
|
{text && <span className={style.text}>{text}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 */
|
box-sizing: border-box; /* reset */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%; /* restrict the page width to viewport */
|
width: 100%; /* restrict the page width to viewport */
|
||||||
height: 100dvh;
|
height: 100vh;
|
||||||
|
|
||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
@@ -16,6 +16,5 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding-top: 5rem;
|
||||||
padding-block: 5rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
|
|||||||
interface EmptyPageProps {
|
interface EmptyPageProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
variant?: 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
|
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.page}>
|
<div className={style.page}>
|
||||||
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
|
<Empty text={text} injectedStyles={injectedStyles} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,9 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
|||||||
<tbody className={style.emptyContainer}>
|
<tbody className={style.emptyContainer}>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={99} className={style.emptyCell}>
|
<td colSpan={99} className={style.emptyCell}>
|
||||||
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
|
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||||
|
<span className={style.text}>{text}</span>
|
||||||
{handleAddNew && (
|
{handleAddNew && (
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
|
|||||||
...state.timer,
|
...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) => ({
|
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||||
id: state.eventFlag?.id ?? null,
|
id: state.eventFlag?.id ?? null,
|
||||||
expectedStart: state.offset.expectedFlagStart,
|
expectedStart: state.offset.expectedFlagStart,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||||
@@ -22,6 +22,7 @@ const secondarySourceLabels: Record<string, string> = {
|
|||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
const isGroupTimerActive = useGroupTimer() !== null;
|
||||||
|
|
||||||
const main = (() => {
|
const main = (() => {
|
||||||
if (showTimerMessage) return 'Message';
|
if (showTimerMessage) return 'Message';
|
||||||
@@ -105,6 +106,14 @@ export default function TimerPreview() {
|
|||||||
>
|
>
|
||||||
<LuArrowDownToLine />
|
<LuArrowDownToLine />
|
||||||
</Tooltip>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
|
|||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../../common/utils/throttle';
|
||||||
import { getDefaultFormat } from '../../common/utils/time';
|
import { getDefaultFormat } from '../../common/utils/time';
|
||||||
import { isTouchDevice } from '../../externals';
|
import { isTouchDevice } from '../../externals';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
|
||||||
import Loader from '../../views/common/loader/Loader';
|
import Loader from '../../views/common/loader/Loader';
|
||||||
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Operator {...data} />;
|
return <Operator {...data} />;
|
||||||
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
|
|||||||
|
|
||||||
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||||
|
|
||||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||||
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
||||||
|
|
||||||
const canEdit = shouldEdit && subscribe.length;
|
const canEdit = shouldEdit && subscribe.length;
|
||||||
const hasEvents = rundown.order.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||||
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasEvents ? (
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
{rundown.order.map((entryId) => {
|
||||||
) : (
|
const entry = rundown.entries[entryId];
|
||||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
if (isOntimeEvent(entry)) {
|
||||||
{rundown.order.map((entryId) => {
|
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||||
const entry = rundown.entries[entryId];
|
// hide past events (if setting) and skipped events
|
||||||
if (isOntimeEvent(entry)) {
|
if ((hidePast && isPast) || entry.skip) {
|
||||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
return null;
|
||||||
// hide past events (if setting) and skipped events
|
}
|
||||||
if ((hidePast && isPast) || entry.skip) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||||
entry,
|
entry,
|
||||||
mainSource,
|
mainSource,
|
||||||
secondarySource,
|
secondarySource,
|
||||||
subscribe,
|
subscribe,
|
||||||
customFields,
|
customFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OperatorEvent
|
<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
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
id={entry.id}
|
title={entry.title}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
cue={entry.cue}
|
count={entry.entries.length}
|
||||||
main={mainField}
|
|
||||||
secondary={secondaryField}
|
|
||||||
timeStart={entry.timeStart}
|
|
||||||
duration={entry.duration}
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||||
const { isPast } = rundownMetadata[entry.id];
|
|
||||||
|
|
||||||
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
// hide past events (if setting) and skipped events
|
||||||
|
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (hidePast && isPast && !isCurrentParent) {
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||||
return null;
|
nestedEntry,
|
||||||
}
|
mainSource,
|
||||||
|
secondarySource,
|
||||||
|
subscribe,
|
||||||
|
customFields,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<OperatorEvent
|
||||||
<OperatorGroup
|
key={nestedEntry.id}
|
||||||
key={entry.id}
|
id={nestedEntry.id}
|
||||||
title={entry.title}
|
colour={nestedEntry.colour}
|
||||||
colour={entry.colour}
|
cue={nestedEntry.cue}
|
||||||
count={entry.entries.length}
|
main={mainField}
|
||||||
duration={entry.duration}
|
secondary={secondaryField}
|
||||||
/>
|
timeStart={nestedEntry.timeStart}
|
||||||
{entry.entries.map((nestedEntryId) => {
|
duration={nestedEntry.duration}
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
delay={nestedEntry.delay}
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
dayOffset={nestedEntry.dayOffset}
|
||||||
return null;
|
isLinkedToLoaded={isLinkedToLoaded}
|
||||||
}
|
isSelected={isLoaded}
|
||||||
|
isPast={isPast}
|
||||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
groupColour={entry.colour}
|
||||||
|
selectedRef={isLoaded ? selectedRef : undefined}
|
||||||
// hide past events (if setting) and skipped events
|
showStart={showStart}
|
||||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
subscribed={subscribedData}
|
||||||
return null;
|
totalGap={totalGap}
|
||||||
}
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||||
|
/>
|
||||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
);
|
||||||
nestedEntry,
|
})}
|
||||||
mainSource,
|
</Fragment>
|
||||||
secondarySource,
|
);
|
||||||
subscribe,
|
}
|
||||||
customFields,
|
return null;
|
||||||
);
|
})}
|
||||||
|
</div>
|
||||||
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} />
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
|
||||||
import Rundown from './Rundown';
|
import Rundown from './Rundown';
|
||||||
|
|
||||||
export default memo(RundownList);
|
export default memo(RundownList);
|
||||||
function RundownList() {
|
function RundownList() {
|
||||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||||
const featureData = useRundownEditor();
|
const featureData = useRundownEditor();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
|
|
||||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
||||||
if (status === 'pending') {
|
|
||||||
return <EmptyFill text='Loading…' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'error') {
|
if (isLoading) {
|
||||||
return <EmptyFill text={getLocalizedString('common.no_data')} />;
|
return <Empty text='Connecting to server' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useCallback } from 'react';
|
|||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
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 { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
|||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import Tag from '../../../common/components/tag/Tag';
|
import Tag from '../../../common/components/tag/Tag';
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useEffect, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
|
|||||||
|
|
||||||
export default memo(RundownTable);
|
export default memo(RundownTable);
|
||||||
function RundownTable() {
|
function RundownTable() {
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
const source = useLoadedRundownSource();
|
const source = useLoadedRundownSource();
|
||||||
@@ -31,10 +32,16 @@ function RundownTable() {
|
|||||||
|
|
||||||
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
||||||
|
|
||||||
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={actions}>
|
<EntryActionsProvider actions={actions}>
|
||||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
{isLoading ? (
|
||||||
|
<EmptyPage text='Loading...' />
|
||||||
|
) : (
|
||||||
|
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||||
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
</EntryActionsProvider>
|
</EntryActionsProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Backstage {...data} />;
|
return <Backstage {...data} />;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background-color: var(--background-color-override, $viewer-background-color);
|
background-color: var(--background-color-override, $viewer-background-color);
|
||||||
height: 100dvh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
|
|||||||
height: $dot-size;
|
height: $dot-size;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--accent-color-override, $ontime-color);
|
background-color: var(--accent-color-override, $ontime-color);
|
||||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
left: $dot-size;
|
left: $dot-size;
|
||||||
animation: lds-ellipsis1 1s infinite;
|
animation: lds-ellipsis1 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
left: $dot-size;
|
left: $dot-size;
|
||||||
animation: lds-ellipsis2 1s infinite;
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
left: calc($dot-size + $dot-spacing);
|
left: calc($dot-size + $dot-spacing);
|
||||||
animation: lds-ellipsis2 1s infinite;
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(4) {
|
&:nth-child(4) {
|
||||||
left: calc($dot-size + 2 * $dot-spacing);
|
left: calc($dot-size + 2 * $dot-spacing);
|
||||||
animation: lds-ellipsis3 1s infinite;
|
animation: lds-ellipsis3 0.6s infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-container {
|
.list-container {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Countdown {...data} />;
|
return <Countdown {...data} />;
|
||||||
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
|||||||
|
|
||||||
{!hasEvents && (
|
{!hasEvents && (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('common.no_data')} />
|
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (subscriptions.length === 0) {
|
if (subscriptions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.select_event')} />
|
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
<IoAdd /> Add
|
<IoAdd /> Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (subscribedEvents.length === 0) {
|
if (subscribedEvents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.select_event')} />
|
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||||
<IoAdd /> Add
|
<IoAdd /> Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
|||||||
if (eventsToShow.length === 0) {
|
if (eventsToShow.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='empty-state'>
|
<div className='empty-state'>
|
||||||
<Empty text={getLocalizedString('countdown.all_have_finished')} />
|
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
|
|||||||
import { memo, use, useMemo } from 'react';
|
import { memo, use, useMemo } from 'react';
|
||||||
|
|
||||||
import Select from '../../common/components/select/Select';
|
import Select from '../../common/components/select/Select';
|
||||||
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||||
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
|
|||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||||
[customFields, cuesheetMode, preset],
|
[customFields, cuesheetMode, preset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CuesheetDnd columns={columns}>
|
<CuesheetDnd columns={columns}>
|
||||||
<CuesheetTable
|
{isLoading ? (
|
||||||
columns={columns}
|
<EmptyPage text='Loading...' />
|
||||||
source={source}
|
) : (
|
||||||
cuesheetMode={cuesheetMode}
|
<CuesheetTable
|
||||||
tableRoot='cuesheet'
|
columns={columns}
|
||||||
setCuesheetMode={setCuesheetMode}
|
source={source}
|
||||||
isCurrentRundown={isCurrentRundown}
|
cuesheetMode={cuesheetMode}
|
||||||
insertElement={
|
tableRoot='cuesheet'
|
||||||
<>
|
setCuesheetMode={setCuesheetMode}
|
||||||
<RundownSelect
|
isCurrentRundown={isCurrentRundown}
|
||||||
cuesheetMode={cuesheetMode}
|
insertElement={
|
||||||
selectedRundownId={selectedRundownId}
|
<>
|
||||||
loadedRundownId={loadedRundownId}
|
<RundownSelect
|
||||||
setSelectedRundownId={setSelectedRundownId}
|
cuesheetMode={cuesheetMode}
|
||||||
projectRundowns={projectRundowns}
|
selectedRundownId={selectedRundownId}
|
||||||
/>
|
loadedRundownId={loadedRundownId}
|
||||||
</>
|
setSelectedRundownId={setSelectedRundownId}
|
||||||
}
|
projectRundowns={projectRundowns}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
|
|
||||||
@include rows.cuesheet-row-columns($table-header-font-size);
|
@include rows.cuesheet-row-columns($table-header-font-size);
|
||||||
|
|
||||||
.tableLoading {
|
|
||||||
grid-area: table;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cuesheet {
|
.cuesheet {
|
||||||
font-size: $table-font-size;
|
font-size: $table-font-size;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TableVirtuosoHandle,
|
TableVirtuosoHandle,
|
||||||
} from 'react-virtuoso';
|
} from 'react-virtuoso';
|
||||||
|
|
||||||
import EmptyFill from '../../../common/components/state/EmptyFill';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
@@ -19,7 +19,6 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
|||||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||||
@@ -65,7 +64,6 @@ export default function CuesheetTable({
|
|||||||
}: CuesheetTableProps) {
|
}: CuesheetTableProps) {
|
||||||
const { flatRundown, status, selectedEventId } = source;
|
const { flatRundown, status, selectedEventId } = source;
|
||||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||||
|
|
||||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||||
@@ -230,13 +228,10 @@ export default function CuesheetTable({
|
|||||||
});
|
});
|
||||||
}, [cuesheetMode, hideIndexColumn, table]);
|
}, [cuesheetMode, hideIndexColumn, table]);
|
||||||
|
|
||||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
const isLoading = !flatRundown || status === 'pending';
|
||||||
if (status === 'pending') {
|
|
||||||
return <EmptyFill text='Loading…' className={style.tableLoading} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'error') {
|
if (isLoading) {
|
||||||
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
|
return <EmptyPage text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -68,6 +68,18 @@
|
|||||||
opacity: 0;
|
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 {
|
.secondary {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
|
|||||||
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import { useTimerSocket } from '../../../common/hooks/useSocket';
|
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||||
import {
|
import {
|
||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
getIsPlaying,
|
getIsPlaying,
|
||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowMessage,
|
getShowMessage,
|
||||||
getShowModifiers,
|
getShowsTimerValue,
|
||||||
getShowProgressBar,
|
resolveTimerDisplay,
|
||||||
getTotalTime,
|
|
||||||
} from '../../timer/timer.utils';
|
} from '../../timer/timer.utils';
|
||||||
import { getTimerColour } from '../../utils/presentation.utils';
|
import { getTimerColour } from '../../utils/presentation.utils';
|
||||||
|
|
||||||
@@ -24,23 +23,28 @@ interface PipTimerProps {
|
|||||||
|
|
||||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||||
|
const groupTimer = useGroupTimer();
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
const { showFinished, showWarning, showDanger } = getShowModifiers(
|
const timerDisplay = resolveTimerDisplay({
|
||||||
timerTypeNow,
|
time,
|
||||||
countToEndNow,
|
groupTimer,
|
||||||
time.phase,
|
event: eventNow,
|
||||||
false,
|
timerType: timerTypeNow,
|
||||||
'',
|
countToEnd: countToEndNow,
|
||||||
false,
|
freezeOvertime: false,
|
||||||
);
|
freezeMessage: '',
|
||||||
|
hidePhase: false,
|
||||||
|
});
|
||||||
|
const { showFinished, showWarning, showDanger } = timerDisplay;
|
||||||
|
|
||||||
const isPlaying = getIsPlaying(time.playback);
|
const isPlaying = getIsPlaying(time.playback);
|
||||||
const showProgressBar = getShowProgressBar(timerTypeNow);
|
const showsTimerValue = getShowsTimerValue(timerTypeNow);
|
||||||
|
const showProgressBar = showsTimerValue;
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
|
||||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
|
||||||
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
||||||
removeSeconds: false,
|
removeSeconds: false,
|
||||||
removeLeadingZero: false,
|
removeLeadingZero: false,
|
||||||
@@ -80,10 +84,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
<div
|
<div
|
||||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: `${timerFontSize}vw` }}
|
||||||
data-phase={time.phase}
|
data-phase={timerDisplay.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</div>
|
</div>
|
||||||
|
{timerDisplay.isGroup && showsTimerValue && <div className='group-indicator'>group</div>}
|
||||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||||
<FitText mode='multi' min={12} max={256}>
|
<FitText mode='multi' min={12} max={256}>
|
||||||
{secondaryContent}
|
{secondaryContent}
|
||||||
@@ -94,12 +99,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
{showProgressBar && (
|
{showProgressBar && (
|
||||||
<MultiPartProgressBar
|
<MultiPartProgressBar
|
||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={timerDisplay.source.current}
|
||||||
complete={totalTime}
|
complete={timerDisplay.total}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={timerDisplay.warning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={timerDisplay.danger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
$content-width: min(100%, 1100px);
|
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
box-sizing: border-box; /* reset */
|
box-sizing: border-box; /* reset */
|
||||||
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.logo {
|
||||||
max-width: min(200px, 30vw);
|
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 {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: $content-width;
|
max-height: 100%;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
width: min(calc(100vw - 4rem), 960px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
gap: $view-element-gap;
|
gap: $view-element-gap;
|
||||||
|
|
||||||
padding-block: $view-element-gap;
|
|
||||||
padding-bottom: 10vh;
|
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 {
|
.info__label {
|
||||||
font-size: $timer-label-size;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__value {
|
.info__value {
|
||||||
white-space: break-spaces;
|
white-space: break-spaces;
|
||||||
line-height: 1.35;
|
}
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
.info__custom {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image-container {
|
.info__image-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 0 0 min(192px, 25%);
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info__image {
|
.info__image {
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link.info__value {
|
.link.info__value {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
gap: 0.35em;
|
gap: $view-element-gap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
|
|
||||||
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
|
|||||||
/* =================== MOBILE ===================*/
|
/* =================== MOBILE ===================*/
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.project {
|
.project {
|
||||||
.project-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.logo img {
|
.logo img {
|
||||||
height: min(50px, 10vh);
|
height: min(50px, 10vh);
|
||||||
}
|
}
|
||||||
|
.info__image-container {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
import { type ReactNode, useState } from 'react';
|
|
||||||
import { IoOpenOutline } from 'react-icons/io5';
|
import { IoOpenOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProjectInfo {...data} />;
|
return <ProjectInfo {...data} />;
|
||||||
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<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 (
|
return (
|
||||||
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||||
{hasHeader && (
|
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||||
<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'>
|
<div className='info'>
|
||||||
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
|
{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.url && (
|
{projectData.url && (
|
||||||
<div className='info__card'>
|
<div>
|
||||||
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
||||||
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
||||||
{projectData.url}
|
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||||
<IoOpenOutline style={{ fontSize: '1em' }} />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projectData.custom.map((info, idx) => {
|
{projectData.custom.map((info, idx) => {
|
||||||
|
const hasUrl = Boolean(info.url);
|
||||||
return (
|
return (
|
||||||
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
// 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__card'>
|
<div key={`${info.title}-${idx}`} className='info__custom'>
|
||||||
{info.title && <div className='info__label'>{info.title}</div>}
|
{hasUrl && (
|
||||||
{info.url ? (
|
<div className='info__image-container'>
|
||||||
<div className='info__media'>
|
<img className='info__image' src={info.url} loading='lazy' />
|
||||||
<InfoImage src={info.url} />
|
|
||||||
{info.value && <div className='info__value'>{info.value}</div>}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function StudioLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Studio {...data} />;
|
return <Studio {...data} />;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
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';
|
||||||
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TimelinePage {...data} />;
|
return <TimelinePage {...data} />;
|
||||||
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
<EmptyPage text={getLocalizedString('common.no_data')} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,6 +131,18 @@
|
|||||||
opacity: 0;
|
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 {
|
.secondary {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
|||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
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 { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
@@ -25,9 +25,8 @@ import {
|
|||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowClock,
|
getShowClock,
|
||||||
getShowMessage,
|
getShowMessage,
|
||||||
getShowModifiers,
|
getShowsTimerValue,
|
||||||
getShowProgressBar,
|
resolveTimerDisplay,
|
||||||
getTotalTime,
|
|
||||||
} from './timer.utils';
|
} from './timer.utils';
|
||||||
import { TimerData, useTimerData } from './useTimerData';
|
import { TimerData, useTimerData } from './useTimerData';
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ export default function TimerLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Timer {...data} />;
|
return <Timer {...data} />;
|
||||||
@@ -72,23 +71,29 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
timeformat,
|
timeformat,
|
||||||
} = useTimerOptions();
|
} = useTimerOptions();
|
||||||
|
|
||||||
|
const groupTimer = useGroupTimer();
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const localisedMinutes = getLocalizedString('common.minutes');
|
const localisedMinutes = getLocalizedString('common.minutes');
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const viewTimerType = timerType ?? timerTypeNow;
|
const viewTimerType = timerType ?? timerTypeNow;
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
|
const timerDisplay = resolveTimerDisplay({
|
||||||
timerTypeNow,
|
time,
|
||||||
countToEndNow,
|
groupTimer,
|
||||||
time.phase,
|
event: eventNow,
|
||||||
|
timerType: timerTypeNow,
|
||||||
|
countToEnd: countToEndNow,
|
||||||
freezeOvertime,
|
freezeOvertime,
|
||||||
freezeMessage,
|
freezeMessage,
|
||||||
hidePhase,
|
hidePhase,
|
||||||
);
|
});
|
||||||
|
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
|
||||||
const isPlaying = getIsPlaying(time.playback);
|
const isPlaying = getIsPlaying(time.playback);
|
||||||
const showClock = !hideClock && getShowClock(viewTimerType);
|
const showClock = !hideClock && getShowClock(viewTimerType);
|
||||||
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
|
const showsTimerValue = getShowsTimerValue(viewTimerType);
|
||||||
|
const showProgressBar = !hideProgress && showsTimerValue;
|
||||||
|
|
||||||
// gather card data
|
// gather card data
|
||||||
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
||||||
@@ -102,8 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
);
|
);
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
|
||||||
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
|
|
||||||
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||||
removeSeconds: hideTimerSeconds,
|
removeSeconds: hideTimerSeconds,
|
||||||
removeLeadingZero: removeLeadingZeros,
|
removeLeadingZero: removeLeadingZeros,
|
||||||
@@ -178,11 +182,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: `${timerFontSize}vw` }}
|
||||||
data-type={viewTimerType}
|
data-type={viewTimerType}
|
||||||
data-phase={time.phase}
|
data-phase={timerDisplay.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{timerDisplay.isGroup && showsTimerValue && !showEndMessage && (
|
||||||
|
<div className='group-indicator'>group</div>
|
||||||
|
)}
|
||||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||||
<FitText mode='multi' min={64} max={256}>
|
<FitText mode='multi' min={64} max={256}>
|
||||||
{secondaryContent}
|
{secondaryContent}
|
||||||
@@ -193,12 +200,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
{showProgressBar && (
|
{showProgressBar && (
|
||||||
<MultiPartProgressBar
|
<MultiPartProgressBar
|
||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={timerDisplay.source.current}
|
||||||
complete={totalTime}
|
complete={timerDisplay.total}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={timerDisplay.warning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={timerDisplay.danger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
GroupTimerState,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
MessageState,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
RundownEntries,
|
RundownEntries,
|
||||||
TimerMessage,
|
TimerMessage,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
|
TimerState,
|
||||||
TimerType,
|
TimerType,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
@@ -34,9 +36,11 @@ export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): num
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the progress bar should be shown for this timer type
|
* 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
|
||||||
*/
|
*/
|
||||||
export function getShowProgressBar(timerType: TimerType) {
|
export function getShowsTimerValue(timerType: TimerType) {
|
||||||
return timerType !== TimerType.None && timerType !== TimerType.Clock;
|
return timerType !== TimerType.None && timerType !== TimerType.Clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +118,59 @@ 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
|
* What, if anything, should be displayed in the secondary field
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ export function migrateRundown(
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
colour: '', //leave default colour
|
colour: '', //leave default colour
|
||||||
custom: {}, // leave empty
|
custom: {}, // leave empty
|
||||||
|
useGroupTimer: false, // opt-in feature, off for migrated projects
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: -1,
|
revision: -1,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
useGroupTimer: false,
|
||||||
},
|
},
|
||||||
event2: {
|
event2: {
|
||||||
id: 'event2',
|
id: 'event2',
|
||||||
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
useGroupTimer: false,
|
||||||
},
|
},
|
||||||
delay: {
|
delay: {
|
||||||
type: SupportedEntry.Delay,
|
type: SupportedEntry.Delay,
|
||||||
|
|||||||
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
|
|||||||
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
|
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
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()', () => {
|
describe('sanitiseCustomFields()', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
|
|||||||
import { parseRundown } from '../rundown.parser.js';
|
import { parseRundown } from '../rundown.parser.js';
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
|
createGroupPatch,
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
|
|||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
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,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
|||||||
entries: patchGroup.entries ?? originalGroup.entries,
|
entries: patchGroup.entries ?? originalGroup.entries,
|
||||||
targetDuration: maybeTargetDuration(),
|
targetDuration: maybeTargetDuration(),
|
||||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||||
|
useGroupTimer:
|
||||||
|
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||||
revision: originalGroup.revision,
|
revision: originalGroup.revision,
|
||||||
timeStart: originalGroup.timeStart,
|
timeStart: originalGroup.timeStart,
|
||||||
timeEnd: originalGroup.timeEnd,
|
timeEnd: originalGroup.timeEnd,
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
|
|||||||
note: string
|
note: string
|
||||||
entries: EntryId[]
|
entries: EntryId[]
|
||||||
targetDuration: number | null
|
targetDuration: number | null
|
||||||
|
useGroupTimer: boolean // views show a shared timer for the whole group
|
||||||
custom: { [key: string]: string }
|
custom: { [key: string]: string }
|
||||||
timeStart: number | null // calculated from nested entries (runtime)
|
timeStart: number | null // calculated from nested entries (runtime)
|
||||||
timeEnd: number | null // calculated from nested entries (runtime)
|
timeEnd: number | null // calculated from nested entries (runtime)
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export type EventFieldArgs = Partial<
|
|||||||
>;
|
>;
|
||||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
||||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
||||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
export type GroupFieldArgs = Partial<
|
||||||
|
Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom' | 'useGroupTimer'>
|
||||||
|
>;
|
||||||
|
|
||||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
||||||
export type TargetRundownArgs = { rundownId?: string };
|
export type TargetRundownArgs = { rundownId?: string };
|
||||||
@@ -170,6 +172,7 @@ function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGro
|
|||||||
if (args.colour !== undefined) patch.colour = args.colour;
|
if (args.colour !== undefined) patch.colour = args.colour;
|
||||||
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
||||||
if (args.custom !== undefined) patch.custom = args.custom;
|
if (args.custom !== undefined) patch.custom = args.custom;
|
||||||
|
if (args.useGroupTimer !== undefined) patch.useGroupTimer = args.useGroupTimer;
|
||||||
|
|
||||||
return Object.keys(patch).length > 1 ? patch : null;
|
return Object.keys(patch).length > 1 ? patch : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
|||||||
eventStore.init({
|
eventStore.init({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
timer: state.timer,
|
timer: state.timer,
|
||||||
|
groupTimer: state.groupTimer,
|
||||||
message: { ...runtimeStorePlaceholder.message },
|
message: { ...runtimeStorePlaceholder.message },
|
||||||
offset: state.offset,
|
offset: state.offset,
|
||||||
rundown: state.rundown,
|
rundown: state.rundown,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['0aaa7d'],
|
entries: ['0aaa7d'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#A790F5',
|
colour: '#A790F5',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0301'],
|
entries: ['bs0301'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0101', 'br0102', 'br0103'],
|
entries: ['br0101', 'br0102', 'br0103'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#ED3333',
|
colour: '#ED3333',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0301', 'br0302'],
|
entries: ['br0301', 'br0302'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0401', 'br0402', 'br0403'],
|
entries: ['br0401', 'br0402', 'br0403'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import {
|
||||||
|
EndAction,
|
||||||
|
MaybeNumber,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeGroup,
|
||||||
|
Playback,
|
||||||
|
Rundown,
|
||||||
|
SupportedEntry,
|
||||||
|
TimeOfDay,
|
||||||
|
TimeStrategy,
|
||||||
|
TimerPhase,
|
||||||
|
TimerType,
|
||||||
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||||
@@ -7,6 +19,8 @@ import {
|
|||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
|
getGroupTimer,
|
||||||
|
getGroupTiming,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
@@ -1452,3 +1466,138 @@ describe('findDay()', () => {
|
|||||||
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
|
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,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
RuntimeService.previousState.timer = { ...state.timer };
|
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.
|
* clock has changed by a second or more.
|
||||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
import {
|
||||||
|
Day,
|
||||||
|
EntryId,
|
||||||
|
GroupTimerState,
|
||||||
|
MaybeNumber,
|
||||||
|
OntimeGroup,
|
||||||
|
Rundown,
|
||||||
|
TimeOfDay,
|
||||||
|
TimerPhase,
|
||||||
|
isOntimeEvent,
|
||||||
|
isPlayableEvent,
|
||||||
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* handle events that span over midnight
|
* handle events that span over midnight
|
||||||
@@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
|
|||||||
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
||||||
return 0 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,6 +9,8 @@ const baseState: RuntimeState = {
|
|||||||
eventNext: null,
|
eventNext: null,
|
||||||
eventFlag: null,
|
eventFlag: null,
|
||||||
groupNow: null,
|
groupNow: null,
|
||||||
|
groupTimer: null,
|
||||||
|
_groupTiming: null,
|
||||||
rundown: {
|
rundown: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null,
|
||||||
numEvents: 0,
|
numEvents: 0,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TimeOfDay,
|
TimeOfDay,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
|
GroupTimerState,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
runtimeStorePlaceholder,
|
runtimeStorePlaceholder,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
|
getGroupTimer,
|
||||||
|
getGroupTiming,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
@@ -51,6 +54,12 @@ type ExpectedMetadata = {
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
/** scheduled content of the running group, split around the loaded event */
|
||||||
|
export type GroupTiming = {
|
||||||
|
before: number;
|
||||||
|
after: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeState = {
|
export type RuntimeState = {
|
||||||
clock: TimeOfDay;
|
clock: TimeOfDay;
|
||||||
groupNow: OntimeGroup | null;
|
groupNow: OntimeGroup | null;
|
||||||
@@ -59,6 +68,8 @@ export type RuntimeState = {
|
|||||||
eventFlag: PlayableEvent | null;
|
eventFlag: PlayableEvent | null;
|
||||||
offset: Offset;
|
offset: Offset;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
|
/** derived from the timer, only refreshed on the getState() projection */
|
||||||
|
groupTimer: GroupTimerState | null;
|
||||||
rundown: RundownState;
|
rundown: RundownState;
|
||||||
// private properties of the timer calculations
|
// private properties of the timer calculations
|
||||||
_timer: {
|
_timer: {
|
||||||
@@ -72,6 +83,7 @@ export type RuntimeState = {
|
|||||||
totalDelay: number; // this value comes from rundown service
|
totalDelay: number; // this value comes from rundown service
|
||||||
};
|
};
|
||||||
_group: ExpectedMetadata;
|
_group: ExpectedMetadata;
|
||||||
|
_groupTiming: GroupTiming | null;
|
||||||
_flag: ExpectedMetadata;
|
_flag: ExpectedMetadata;
|
||||||
_end: ExpectedMetadata;
|
_end: ExpectedMetadata;
|
||||||
_startEpoch: Maybe<Instant>;
|
_startEpoch: Maybe<Instant>;
|
||||||
@@ -86,6 +98,7 @@ const runtimeState: RuntimeState = {
|
|||||||
eventFlag: null,
|
eventFlag: null,
|
||||||
offset: { ...runtimeStorePlaceholder.offset },
|
offset: { ...runtimeStorePlaceholder.offset },
|
||||||
timer: { ...runtimeStorePlaceholder.timer },
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
|
groupTimer: null,
|
||||||
rundown: { ...runtimeStorePlaceholder.rundown },
|
rundown: { ...runtimeStorePlaceholder.rundown },
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
@@ -98,6 +111,7 @@ const runtimeState: RuntimeState = {
|
|||||||
totalDelay: 0,
|
totalDelay: 0,
|
||||||
},
|
},
|
||||||
_group: null,
|
_group: null,
|
||||||
|
_groupTiming: null,
|
||||||
_flag: null,
|
_flag: null,
|
||||||
_end: null,
|
_end: null,
|
||||||
_startEpoch: null,
|
_startEpoch: null,
|
||||||
@@ -115,6 +129,8 @@ export function getState(): Readonly<RuntimeState> {
|
|||||||
offset: { ...runtimeState.offset },
|
offset: { ...runtimeState.offset },
|
||||||
rundown: { ...runtimeState.rundown },
|
rundown: { ...runtimeState.rundown },
|
||||||
timer: { ...runtimeState.timer },
|
timer: { ...runtimeState.timer },
|
||||||
|
// derived here so it can never drift from the timer values it is built on
|
||||||
|
groupTimer: getGroupTimer(runtimeState),
|
||||||
_timer: { ...runtimeState._timer },
|
_timer: { ...runtimeState._timer },
|
||||||
_rundown: { ...runtimeState._rundown },
|
_rundown: { ...runtimeState._rundown },
|
||||||
};
|
};
|
||||||
@@ -156,6 +172,7 @@ export function clearState() {
|
|||||||
|
|
||||||
runtimeState.groupNow = null;
|
runtimeState.groupNow = null;
|
||||||
runtimeState._group = null;
|
runtimeState._group = null;
|
||||||
|
runtimeState._groupTiming = null;
|
||||||
|
|
||||||
runtimeState.rundown.actualStart = null;
|
runtimeState.rundown.actualStart = null;
|
||||||
runtimeState.rundown.selectedEventIndex = null;
|
runtimeState.rundown.selectedEventIndex = null;
|
||||||
@@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
const previousGroup = state.groupNow?.id;
|
const previousGroup = state.groupNow?.id;
|
||||||
state.groupNow = null;
|
state.groupNow = null;
|
||||||
state._group = null;
|
state._group = null;
|
||||||
|
state._groupTiming = null;
|
||||||
state.eventFlag = null;
|
state.eventFlag = null;
|
||||||
state._flag = null;
|
state._flag = null;
|
||||||
state._end = null;
|
state._end = null;
|
||||||
@@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd(
|
|||||||
state.rundown.actualGroupStart = null;
|
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
|
// if we don't have a any flags in the rundown then no need to look for it
|
||||||
let foundFlag = !flagsPresent;
|
let foundFlag = !flagsPresent;
|
||||||
// if we don't have a last event for the group there is no need to find its end time
|
// if we don't have a last event for the group there is no need to find its end time
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
|
|||||||
targetDuration: MaybeNumber;
|
targetDuration: MaybeNumber;
|
||||||
colour: string;
|
colour: string;
|
||||||
custom: EntryCustomFields;
|
custom: EntryCustomFields;
|
||||||
|
/** whether views should display a shared timer for the group instead of the event timer */
|
||||||
|
useGroupTimer: boolean;
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: number;
|
revision: number;
|
||||||
timeStart: MaybeNumber; // calculated at runtime
|
timeStart: MaybeNumber; // calculated at runtime
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
|||||||
secondaryTimer: null, // change on every update
|
secondaryTimer: null, // change on every update
|
||||||
startedAt: null, // change can only be initiated by user
|
startedAt: null, // change can only be initiated by user
|
||||||
},
|
},
|
||||||
|
groupTimer: null, // derived from the timer, null unless the running group opted in
|
||||||
message: {
|
message: {
|
||||||
timer: {
|
timer: {
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
|
|||||||
import type { MessageState } from './MessageControl.type.js';
|
import type { MessageState } from './MessageControl.type.js';
|
||||||
import type { Offset } from './Offset.type.js';
|
import type { Offset } from './Offset.type.js';
|
||||||
import type { RundownState } from './RundownState.type.js';
|
import type { RundownState } from './RundownState.type.js';
|
||||||
import type { TimerState } from './TimerState.type.js';
|
import type { GroupTimerState, TimerState } from './TimerState.type.js';
|
||||||
|
|
||||||
export type RuntimeStore = {
|
export type RuntimeStore = {
|
||||||
// timer data
|
// timer data
|
||||||
clock: number;
|
clock: number;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
|
/** shared timer for the running group, null unless the group opted in */
|
||||||
|
groupTimer: GroupTimerState | null;
|
||||||
|
|
||||||
// messages service
|
// messages service
|
||||||
message: MessageState;
|
message: MessageState;
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export enum TimerPhase {
|
|||||||
Pending = 'pending',
|
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
|
* Gathers the current running timer state
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
|
|||||||
export { OffsetMode } from './definitions/runtime/Offset.type.js';
|
export { OffsetMode } from './definitions/runtime/Offset.type.js';
|
||||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||||
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
||||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||||
|
|
||||||
// ---> Extra Timer
|
// ---> Extra Timer
|
||||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
colour: '',
|
colour: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
|
useGroupTimer: false,
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: 0, // calculated at runtime
|
revision: 0, // calculated at runtime
|
||||||
timeStart: null, // calculated at runtime
|
timeStart: null, // calculated at runtime
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
|||||||
targetDuration: patch.targetDuration ?? null,
|
targetDuration: patch.targetDuration ?? null,
|
||||||
colour: makeString(patch.colour, ''),
|
colour: makeString(patch.colour, ''),
|
||||||
custom: patch.custom ?? {},
|
custom: patch.custom ?? {},
|
||||||
|
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
|
|||||||
Generated
+11
-40
@@ -137,8 +137,8 @@ importers:
|
|||||||
specifier: 5.6.0
|
specifier: 5.6.0
|
||||||
version: 5.6.0(react@19.2.7)
|
version: 5.6.0(react@19.2.7)
|
||||||
react-router:
|
react-router:
|
||||||
specifier: ^8.3.0
|
specifier: ^8.0.1
|
||||||
version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
react-virtuoso:
|
react-virtuoso:
|
||||||
specifier: ^4.18.7
|
specifier: ^4.18.7
|
||||||
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -1508,9 +1508,6 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.30':
|
'@jridgewell/trace-mapping@0.3.30':
|
||||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
|
||||||
|
|
||||||
'@lezer/common@1.5.1':
|
'@lezer/common@1.5.1':
|
||||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||||
|
|
||||||
@@ -2975,8 +2972,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
acorn@8.18.0:
|
acorn@8.16.0:
|
||||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -3729,10 +3726,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fs-extra@11.4.0:
|
|
||||||
resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
|
|
||||||
engines: {node: '>=14.14'}
|
|
||||||
|
|
||||||
fs-extra@7.0.1:
|
fs-extra@7.0.1:
|
||||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||||
engines: {node: '>=6 <7 || >=8'}
|
engines: {node: '>=6 <7 || >=8'}
|
||||||
@@ -4056,9 +4049,6 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
jsonfile@6.2.1:
|
|
||||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
|
||||||
|
|
||||||
jwa@2.0.0:
|
jwa@2.0.0:
|
||||||
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
|
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
|
||||||
|
|
||||||
@@ -4605,8 +4595,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
react-router@8.3.0:
|
react-router@8.0.1:
|
||||||
resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==}
|
resolution: {integrity: sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==}
|
||||||
engines: {node: '>=22.22.0'}
|
engines: {node: '>=22.22.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=19.2.7'
|
react: '>=19.2.7'
|
||||||
@@ -6313,7 +6303,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
cross-dirname: 0.1.0
|
cross-dirname: 0.1.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
fs-extra: 11.4.0
|
fs-extra: 11.3.4
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
postject: 1.0.0-alpha.6
|
postject: 1.0.0-alpha.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6637,7 +6627,7 @@ snapshots:
|
|||||||
'@jridgewell/source-map@0.3.11':
|
'@jridgewell/source-map@0.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.30
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
@@ -6646,11 +6636,6 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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/common@1.5.1': {}
|
||||||
|
|
||||||
'@lezer/css@1.3.3':
|
'@lezer/css@1.3.3':
|
||||||
@@ -7756,7 +7741,7 @@ snapshots:
|
|||||||
mime-types: 3.0.1
|
mime-types: 3.0.1
|
||||||
negotiator: 1.0.0
|
negotiator: 1.0.0
|
||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
adler-32@1.3.1: {}
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
@@ -8659,13 +8644,6 @@ snapshots:
|
|||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
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:
|
fs-extra@7.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -9047,13 +9025,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
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:
|
jwa@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer-equal-constant-time: 1.0.1
|
buffer-equal-constant-time: 1.0.1
|
||||||
@@ -9541,7 +9512,7 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.18.0: {}
|
react-refresh@0.18.0: {}
|
||||||
|
|
||||||
react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
react-router@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie-es: 3.1.1
|
cookie-es: 3.1.1
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -9957,7 +9928,7 @@ snapshots:
|
|||||||
terser@5.46.2:
|
terser@5.46.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.11
|
'@jridgewell/source-map': 0.3.11
|
||||||
acorn: 8.18.0
|
acorn: 8.16.0
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user