diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 10a6f9c4e..9b60c3cc2 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -150,6 +150,10 @@ export const useClock = createSelector((state: RuntimeStore) => ({ clock: state.clock, })); +export const useNextFlag = createSelector((state: RuntimeStore) => ({ + nextFlag: state.nextFlag, +})); + /** Used by the progress bar components */ export const useProgressData = createSelector((state: RuntimeStore) => ({ current: state.timer.current, diff --git a/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss b/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss index 58297dd12..599592fa0 100644 --- a/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss +++ b/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss @@ -1,6 +1,6 @@ .label { display: block; - margin-top: 2rem; + margin-top: 1rem; font-size: $inner-section-text-size; color: $label-gray; } diff --git a/apps/client/src/features/overview/CuesheetOverview.tsx b/apps/client/src/features/overview/CuesheetOverview.tsx index d39db69bd..9dfe53771 100644 --- a/apps/client/src/features/overview/CuesheetOverview.tsx +++ b/apps/client/src/features/overview/CuesheetOverview.tsx @@ -1,19 +1,10 @@ -import { memo, PropsWithChildren, useMemo } from 'react'; +import { memo, PropsWithChildren } from 'react'; import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen'; -import { useRuntimeOverview } from '../../common/hooks/useSocket'; -import { - ClockOverview, - CurrentBlockOverview, - OverviewWrapper, - RuntimeOverview, - TimerOverview, -} from './composite/OverviewWrapper'; -import { TimeRow } from './composite/TimeLayout'; -import { calculateEndAndDaySpan, formatedTime } from './overviewUtils'; - -import style from './Overview.module.scss'; +import { ClockOverview, MetadataTimes, RuntimeOverview, StartTimes, TimerOverview } from './composite/TimeElements'; +import TitleOverview from './composite/TitleOverview'; +import { OverviewWrapper } from './OverviewWrapper'; export default memo(CuesheetOverview); function CuesheetOverview({ children }: PropsWithChildren) { @@ -33,49 +24,14 @@ function CuesheetMobile({ children }: PropsWithChildren) { } function CuesheetDesktop({ children }: PropsWithChildren) { - const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); - - const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); - const plannedEndText = formatedTime(maybePlannedEnd); - - const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); - const expectedEndText = formatedTime(maybeExpectedEnd); return ( -
- - -
+ + - + -
- - -
); } diff --git a/apps/client/src/features/overview/EditorOverview.tsx b/apps/client/src/features/overview/EditorOverview.tsx index ddda50fd0..2a0286121 100644 --- a/apps/client/src/features/overview/EditorOverview.tsx +++ b/apps/client/src/features/overview/EditorOverview.tsx @@ -1,67 +1,19 @@ -import { memo, PropsWithChildren, useMemo } from 'react'; +import { memo, PropsWithChildren } from 'react'; -import { useRuntimeOverview } from '../../common/hooks/useSocket'; - -import { - ClockOverview, - CurrentBlockOverview, - OverviewWrapper, - ProgressOverview, - RuntimeOverview, - TitlesOverview, -} from './composite/OverviewWrapper'; -import { TimeRow } from './composite/TimeLayout'; -import { calculateEndAndDaySpan, formatedTime } from './overviewUtils'; - -import style from './Overview.module.scss'; +import { ClockOverview, MetadataTimes, ProgressOverview, RuntimeOverview, StartTimes } from './composite/TimeElements'; +import TitleOverview from './composite/TitleOverview'; +import { OverviewWrapper } from './OverviewWrapper'; export default memo(EditorOverview); function EditorOverview({ children }: PropsWithChildren) { - const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); - - const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); - const plannedEndText = formatedTime(maybePlannedEnd); - - const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); - const expectedEndText = formatedTime(maybeExpectedEnd); - return ( - -
- - -
+ + - + -
- - -
); } diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 4ea30c7e4..92dfb13df 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -2,6 +2,7 @@ grid-area: overview; font-size: $inner-section-text-size; display: flex; + overflow: hidden; } .isOffline { @@ -23,36 +24,14 @@ .nav { display: flex; + align-items: center; gap: 0.5rem; } .info { flex: 1; - padding-inline: 1rem; + padding-left: 1rem; display: flex; align-items: center; justify-content: space-between; } - -.title { - font-size: 1.5rem; - @include ellipsis-overflow; -} - -.description { - font-size: 1rem; - color: $label-gray; - @include ellipsis-overflow; -} - -.offset { - color: $muted-gray; -} - -.ahead { - color: $playback-ahead; -} - -.behind { - color: $ontime-delay-text; -} diff --git a/apps/client/src/features/overview/OverviewWrapper.tsx b/apps/client/src/features/overview/OverviewWrapper.tsx new file mode 100644 index 000000000..f78b59474 --- /dev/null +++ b/apps/client/src/features/overview/OverviewWrapper.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren, ReactNode } from 'react'; +import { ErrorBoundary } from '@sentry/react'; + +import { useIsOnline } from '../../common/hooks/useSocket'; +import { cx } from '../../common/utils/styleUtils'; + +import style from './Overview.module.scss'; + +interface OverviewWrapperProps { + navElements: ReactNode; +} + +export function OverviewWrapper({ navElements, children }: PropsWithChildren) { + const { isOnline } = useIsOnline(); + return ( +
+ +
{navElements}
+
{children}
+
+
+ ); +} diff --git a/apps/client/src/features/overview/__tests__/overviewUtils.test.ts b/apps/client/src/features/overview/__tests__/overviewUtils.test.ts index fe92b8753..b3fc7680f 100644 --- a/apps/client/src/features/overview/__tests__/overviewUtils.test.ts +++ b/apps/client/src/features/overview/__tests__/overviewUtils.test.ts @@ -1,6 +1,6 @@ import { dayInMs } from 'ontime-utils'; -import { calculateEndAndDaySpan } from '../overviewUtils'; +import { calculateEndAndDaySpan } from '../overview.utils'; describe('calculateEndAndDaySpan', () => { it('should return [null, 0] when end is null', () => { diff --git a/apps/client/src/features/overview/composite/OverviewWrapper.tsx b/apps/client/src/features/overview/composite/OverviewWrapper.tsx deleted file mode 100644 index 11e242413..000000000 --- a/apps/client/src/features/overview/composite/OverviewWrapper.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { PropsWithChildren, ReactNode } from 'react'; -import { ErrorBoundary } from '@sentry/react'; -import { isOntimeBlock, TimerType } from 'ontime-types'; -import { isPlaybackActive, millisToString } from 'ontime-utils'; - -import Tooltip from '../../../common/components/tooltip/Tooltip'; -import { - useClock, - useCurrentBlockId, - useIsOnline, - useRuntimePlaybackOverview, - useTimer, -} from '../../../common/hooks/useSocket'; -import useProjectData from '../../../common/hooks-query/useProjectData'; -import { useEntry } from '../../../common/hooks-query/useRundown'; -import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; -import { formatedTime, getOffsetText } from '../overviewUtils'; - -import { TimeColumn, TimeRow } from './TimeLayout'; - -import style from '../Overview.module.scss'; - -interface OverviewWrapperProps { - navElements: ReactNode; -} - -export function OverviewWrapper({ navElements, children }: PropsWithChildren) { - const { isOnline } = useIsOnline(); - return ( -
- -
{navElements}
-
{children}
-
-
- ); -} - -export function TitlesOverview() { - const { data } = useProjectData(); - - if (!data.title && !data.description) { - return null; - } - - return ( -
-
{data.title}
-
{data.description}
-
- ); -} - -export function CurrentBlockOverview() { - const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview(); - const { currentBlockId } = useCurrentBlockId(); - const entry = useEntry(currentBlockId); - - const timeInBlock = formatedTime(blockStartedAt ? clock - blockStartedAt : null, 3, TimerType.CountUp); - const blockExpectedEndString = formatedTime(blockExpectedEnd, 3, TimerType.CountUp); - - const remainingBlockDuration = (() => { - if (blockStartedAt === null || !entry) return timerPlaceholder; - if (!isOntimeBlock(entry)) return timerPlaceholder; - return formatedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown); - })(); - - const timeUntilBlockEnd = (() => { - if (blockExpectedEnd === null) return timerPlaceholder; - return formatedTime(blockExpectedEnd - clock, 3, TimerType.CountDown); - })() ; - - return ( - <> -
- - - - - - -
-
- - - - - - -
- - ); -} - -export function TimerOverview() { - const { current } = useTimer(); - - const display = millisToString(current, { fallback: timerPlaceholder }); - - return ; -} - -export function ProgressOverview() { - const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview(); - - const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; - const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash; - - return ; -} - -export function RuntimeOverview() { - const { offset, playback } = useRuntimePlaybackOverview(); - - const isPlaying = isPlaybackActive(playback); - const offsetText = getOffsetText(isPlaying ? offset : null); - const offsetClasses = cx([style.offset, isPlaying && (offset < 0 ? style.behind : style.ahead)]); - - return ; -} - -export function ClockOverview() { - const { clock } = useClock(); - - return ; -} diff --git a/apps/client/src/features/overview/composite/TimeElements.module.scss b/apps/client/src/features/overview/composite/TimeElements.module.scss new file mode 100644 index 000000000..c944e17ec --- /dev/null +++ b/apps/client/src/features/overview/composite/TimeElements.module.scss @@ -0,0 +1,78 @@ +.column { + display: flex; + flex-direction: column; + align-items: end; +} + +.row { + display: grid; + grid-template-columns: 3rem 10rem 8rem; + align-items: center; + gap: 0.5rem; +} + +.metadataRow { + display: grid; + grid-template-columns: minmax(3rem, 10rem) 8rem 8rem; + align-items: center; + gap: 0.5rem; +} + +.labelledElement { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.icon { + font-size: 1rem; + color: $label-gray; +} + +.label { + color: $label-gray; + font-size: calc(1rem - 2px); + text-align: right; +} + +.time { + font-size: 1.25rem; + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.daySpan { + &::after { + content: '*'; + vertical-align: super; + font-size: 0.75em; + color: $info-blue; + } +} + +.muted { + color: $muted-gray; +} + +.offset { + color: $muted-gray; +} + +.ahead { + color: $playback-ahead; +} + +.behind { + color: $ontime-delay-text; +} + +.labelTitle { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + color: $active-indicator; + font-size: calc(1rem - 2px); + text-align: right; +} \ No newline at end of file diff --git a/apps/client/src/features/overview/composite/TimeElements.tsx b/apps/client/src/features/overview/composite/TimeElements.tsx new file mode 100644 index 000000000..c62b4e704 --- /dev/null +++ b/apps/client/src/features/overview/composite/TimeElements.tsx @@ -0,0 +1,218 @@ +import { useMemo } from 'react'; +import { TbCalendar, TbCalendarClock, TbCalendarDown, TbCalendarStar, TbFlagDown, TbFlagStar } from 'react-icons/tb'; +import { isOntimeBlock, OntimeBlock, OntimeEvent, TimerType } from 'ontime-types'; +import { isPlaybackActive, millisToString } from 'ontime-utils'; + +import Tooltip from '../../../common/components/tooltip/Tooltip'; +import { + useClock, + useCurrentBlockId, + useNextFlag, + useRuntimeOverview, + useRuntimePlaybackOverview, + useTimer, +} from '../../../common/hooks/useSocket'; +import { useEntry } from '../../../common/hooks-query/useRundown'; +import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; +import { formatTime, useTimeUntilStart } from '../../../common/utils/time'; +import { calculateEndAndDaySpan, formattedTime, getOffsetText } from '../overview.utils'; + +import { TimeColumn } from './TimeLayout'; + +import style from './TimeElements.module.scss'; + +export function StartTimes() { + const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); + const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); + + const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); + + const muted = maybeExpectedEnd === null; + + return ( +
+
+ Start +
+ } /> + {formatTime(plannedStart)} +
+
+ } /> + {formattedTime(actualStart)} +
+
+
+ End +
+ } /> + {maybePlannedDaySpan >= 0 ? ( + } + > + {formatTime(maybePlannedEnd)} + + ) : ( + {formatTime(maybePlannedEnd)} + )} +
+
+ } /> + {maybeExpectedEnd !== null && maybeExpectedDaySpan >= 0 ? ( + } + > + {formattedTime(maybeExpectedEnd)} + + ) : ( + {formattedTime(maybeExpectedEnd)} + )} +
+
+
+ ); +} + +export function MetadataTimes() { + return ( +
+ + +
+ ); +} + +function GroupTimes() { + const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview(); + const { currentBlockId } = useCurrentBlockId(); + const entry = useEntry(currentBlockId); + + if (!currentBlockId) { + return ( +
+ Group +
+ } /> + {timerPlaceholder} +
+
+ } /> + {timerPlaceholder} +
+
+ ); + } + + const remainingBlockDuration = (() => { + if (blockStartedAt === null || !entry) return timerPlaceholder; + if (!isOntimeBlock(entry)) return timerPlaceholder; + return formattedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown); + })(); + + const timeUntilBlockEnd = (() => { + if (blockExpectedEnd === null) return timerPlaceholder; + return formattedTime(blockExpectedEnd - clock, 3, TimerType.CountDown); + })(); + + const groupTitle = (entry as OntimeBlock | null)?.title || 'Group'; + + return ( +
+ {groupTitle} +
+ } /> + {remainingBlockDuration} +
+
+ } /> + {timeUntilBlockEnd} +
+
+ ); +} + +function FlagTimes() { + const { clock } = useClock(); + const { nextFlag } = useNextFlag(); + const entry = useEntry(nextFlag?.id ?? null); + + // TODO(v4): can we make a good approximation of time until next flag? + const timeUntil = useTimeUntilStart({ + timeStart: nextFlag?.start ?? 0, + delay: 0, + dayOffset: 0, + totalGap: 0, + isLinkedToLoaded: true, + }); + + if (!nextFlag) { + return ( +
+ Flag +
+ } /> + {timerPlaceholder} +
+
+ } /> + {timerPlaceholder} +
+
+ ); + } + + const muted = nextFlag === null; + const flagTitle = (entry as OntimeEvent | null)?.title || 'Flag'; + const timeToNextFlag = nextFlag.start - clock; + const display = millisToString(timeToNextFlag, { fallback: timerPlaceholder }); + const timeUntilDisplay = millisToString(timeUntil, { fallback: timerPlaceholder }); + + return ( +
+ {flagTitle} +
+ } /> + {display} +
+
+ } /> + {timeUntilDisplay} +
+
+ ); +} + +export function ProgressOverview() { + const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview(); + + const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; + const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash; + + return ; +} + +export function RuntimeOverview() { + const { offset, playback } = useRuntimePlaybackOverview(); + + const isPlaying = isPlaybackActive(playback); + const offsetText = getOffsetText(isPlaying ? offset : null); + const offsetClasses = cx([style.offset, isPlaying && (offset < 0 ? style.behind : style.ahead)]); + + return ; +} + +export function ClockOverview() { + const { clock } = useClock(); + + return ; +} + +export function TimerOverview() { + const { current } = useTimer(); + + const display = millisToString(current, { fallback: timerPlaceholder }); + + return ; +} diff --git a/apps/client/src/features/overview/composite/TimeLayout.module.scss b/apps/client/src/features/overview/composite/TimeLayout.module.scss index e79581ec8..04ac4e592 100644 --- a/apps/client/src/features/overview/composite/TimeLayout.module.scss +++ b/apps/client/src/features/overview/composite/TimeLayout.module.scss @@ -1,7 +1,6 @@ .label { color: $label-gray; font-size: calc(1rem - 2px); - width: 15em; // a number large enough to force right alignment } .clock { @@ -9,6 +8,7 @@ font-size: 1.5rem; letter-spacing: 0.5px; min-width: 5em; + font-variant-numeric: tabular-nums; &::after { content: '\200b'; @@ -21,6 +21,7 @@ .label { line-height: 0.9em; + width: 7em; } } @@ -32,6 +33,7 @@ .label { text-align: right; + width: 5em; } .clock { diff --git a/apps/client/src/features/overview/composite/TimeLayout.tsx b/apps/client/src/features/overview/composite/TimeLayout.tsx index 337a81221..4e45a19d6 100644 --- a/apps/client/src/features/overview/composite/TimeLayout.tsx +++ b/apps/client/src/features/overview/composite/TimeLayout.tsx @@ -1,3 +1,5 @@ +import { PropsWithChildren } from 'react'; + import Tooltip from '../../../common/components/tooltip/Tooltip'; import { cx } from '../../../common/utils/styleUtils'; @@ -41,3 +43,22 @@ export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutP ); } + +export function TimeElementsRow({ label, value, daySpan, muted, className }: PropsWithChildren) { + return ( +
+ {label} + {daySpan ? ( + } + className={cx([style.clock, style.daySpan, className])} + > + {value} + + ) : ( + {value} + )} +
+ ); +} diff --git a/apps/client/src/features/overview/composite/TitleOverview.module.scss b/apps/client/src/features/overview/composite/TitleOverview.module.scss new file mode 100644 index 000000000..ffe5f0277 --- /dev/null +++ b/apps/client/src/features/overview/composite/TitleOverview.module.scss @@ -0,0 +1,10 @@ +.title { + font-size: 1.5rem; + @include ellipsis-overflow; +} + +.description { + font-size: 1rem; + color: $label-gray; + @include ellipsis-overflow; +} diff --git a/apps/client/src/features/overview/composite/TitleOverview.tsx b/apps/client/src/features/overview/composite/TitleOverview.tsx new file mode 100644 index 000000000..71c86d6f6 --- /dev/null +++ b/apps/client/src/features/overview/composite/TitleOverview.tsx @@ -0,0 +1,18 @@ +import useProjectData from '../../../common/hooks-query/useProjectData'; + +import style from './TitleOverview.module.scss'; + +export default function TitleOverview() { + const { data } = useProjectData(); + + if (!data.title && !data.description) { + return null; + } + + return ( +
+
{data.title}
+
{data.description}
+
+ ); +} diff --git a/apps/client/src/features/overview/overviewUtils.ts b/apps/client/src/features/overview/overview.utils.ts similarity index 96% rename from apps/client/src/features/overview/overviewUtils.ts rename to apps/client/src/features/overview/overview.utils.ts index e0a2ce90b..f5e175349 100644 --- a/apps/client/src/features/overview/overviewUtils.ts +++ b/apps/client/src/features/overview/overview.utils.ts @@ -6,7 +6,7 @@ import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/util /** * Encapsulates the logic for formatting time in overview */ -export function formatedTime( +export function formattedTime( time: MaybeNumber, segments: number = 3, direction?: TimerType.CountDown | TimerType.CountUp, diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index caa533455..31a06446d 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -38,10 +38,24 @@ } .entryIndex { - text-align: right; + padding-block: 0.25rem; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + justify-items: end; min-width: 2em; color: $label-gray; font-size: calc(1rem - 3px); + height: 6.5rem; +} + +.flag { + grid-row: 1; + color: $active-indicator; + font-size: 1rem; +} + +.index { + grid-row: 2; } .entry { diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 3ccada4bc..fe724ff3e 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -1,4 +1,5 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; +import { TbFlagFilled } from 'react-icons/tb'; import { closestCenter, DndContext, @@ -482,7 +483,12 @@ export default function Rundown({ data }: RundownProps) { data-testid={`entry-${rundownMetadata.eventIndex}`} style={blockColour ? { '--user-bg': blockColour } : {}} > - {isOntimeEvent(entry) &&
{rundownMetadata.eventIndex}
} + {isOntimeEvent(entry) && ( +
+ {entry.flag && } +
{rundownMetadata.eventIndex}
+
+ )}
actionHandler('update', { field: 'flag', diff --git a/apps/client/src/features/viewers/common/running-time/RunningTime.tsx b/apps/client/src/features/viewers/common/running-time/RunningTime.tsx index ceeca4b1d..dc6bb3aa5 100644 --- a/apps/client/src/features/viewers/common/running-time/RunningTime.tsx +++ b/apps/client/src/features/viewers/common/running-time/RunningTime.tsx @@ -5,7 +5,7 @@ import { MaybeNumber } from 'ontime-types'; import { removeLeadingZero, removeSeconds } from 'ontime-utils'; -import { formatedTime } from '../../../overview/overviewUtils'; +import { formattedTime } from '../../../overview/overview.utils'; interface RunningTimeProps { value: MaybeNumber; @@ -16,15 +16,15 @@ interface RunningTimeProps { export default function RunningTime(props: RunningTimeProps) { const { value, hideSeconds, hideLeadingZero, className } = props; - let formattedTime = formatedTime(value, hideSeconds || hideLeadingZero ? 2 : 3); + let display = formattedTime(value, hideSeconds || hideLeadingZero ? 2 : 3); if (hideLeadingZero) { - formattedTime = removeLeadingZero(formattedTime); + display = removeLeadingZero(display); } if (hideSeconds) { - formattedTime = removeSeconds(formattedTime); + display = removeSeconds(display); } - return
{formattedTime}
; + return
{display}
; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx index 5eb92742f..560413b01 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx @@ -45,7 +45,7 @@ export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, t onClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, blockId, SupportedEntry.Block, rowIndex, null); + openMenu({ x: rect.x, y: yPos }, blockId, SupportedEntry.Block, rowIndex, null, null); }} > diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index 81740b8b9..74a520cf1 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -100,7 +100,7 @@ export default function EventRow({ onClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent); + openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent, event.flag); }} > diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.module.scss new file mode 100644 index 000000000..492990b6b --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.module.scss @@ -0,0 +1,7 @@ +.flag { + width: 100%; + height: 100%; + color: $active-indicator; + padding-top: 0.5rem; + padding-left: 0.75rem; +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.tsx new file mode 100644 index 000000000..e24a9dbc4 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/FlagCell.tsx @@ -0,0 +1,11 @@ +import { TbFlagFilled } from 'react-icons/tb'; + +import style from './FlagCell.module.scss'; + +export default function FlagCell() { + return ( +
+ +
+ ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx index 6c3357854..f18df96c1 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx @@ -57,7 +57,7 @@ export default function MilestoneRow({ onClick={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; - openMenu({ x: rect.x, y: yPos }, entryId, SupportedEntry.Milestone, rowIndex, parentId); + openMenu({ x: rect.x, y: yPos }, entryId, SupportedEntry.Milestone, rowIndex, parentId, null); }} > diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx index d121a313a..e4caf9953 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx @@ -8,6 +8,7 @@ import { formatDuration, formatTime } from '../../../../common/utils/time'; import DurationInput from './DurationInput'; import EditableImage from './EditableImage'; +import FlagCell from './FlagCell'; import MultiLineCell from './MultiLineCell'; import MutedText from './MutedText'; import SingleLineCell from './SingleLineCell'; @@ -154,6 +155,14 @@ function MakeSingleLineField({ row, column, table }: CellContext; } +function MakeFlagField({ row }: CellContext) { + const event = row.original; + if (!isOntimeEvent(event) || !event.flag) { + return null; + } + return ; +} + function MakeCustomField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { @@ -189,6 +198,14 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef state.setEditableEntry); if (!isOpen) { @@ -26,6 +26,14 @@ function CuesheetTableMenu() { items={[ { type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: IoOptions }, { type: 'divider' }, + { + type: 'item', + label: flag ? 'Remove flag' : 'Add flag', + onClick: () => updateEntry({ id: entryId, flag: !flag }), + icon: IoDuplicateOutline, + disabled: flag === null, + }, + { type: 'divider' }, { type: 'item', label: 'Add event above', diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx index 46602141a..7558ba539 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/useCuesheetTableMenu.tsx @@ -9,6 +9,7 @@ type OpenMenu = { entryType: SupportedEntry; entryIndex: number; parentId: EntryId | null; + flag: boolean | null; }; type ClosedMenu = { @@ -17,6 +18,7 @@ type ClosedMenu = { entryType: null; entryIndex: null; parentId: null; + flag: null; }; type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & { @@ -27,6 +29,7 @@ type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & { entryType: SupportedEntry, entryIndex: number, parentId: EntryId | null, + flag: boolean | null, ) => void; closeMenu: () => void; }; @@ -38,12 +41,14 @@ export const useCuesheetTableMenu = create((set) => ({ entryIndex: null, parentId: null, position: { x: 0, y: 0 }, + flag: null, openMenu: ( position: Anchor, entryId: EntryId, entryType: SupportedEntry, entryIndex: number, parentId: EntryId | null, - ) => set({ isOpen: true, position, entryId, entryType, entryIndex, parentId }), + flag: null | boolean, + ) => set({ isOpen: true, position, entryId, entryType, entryIndex, parentId, flag }), closeMenu: () => set({ isOpen: false }), })); diff --git a/apps/client/src/views/editor/Editor.module.scss b/apps/client/src/views/editor/Editor.module.scss index 4b13971ab..ac4089af2 100644 --- a/apps/client/src/views/editor/Editor.module.scss +++ b/apps/client/src/views/editor/Editor.module.scss @@ -13,7 +13,7 @@ $panel-gap: 0.5rem; display: grid; grid-template-columns: auto; - grid-template-rows: 3rem 1fr; + grid-template-rows: 3.5rem 1fr; grid-template-areas: 'overview' 'main'; diff --git a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts index c9278182d..1a4692d7f 100644 --- a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts +++ b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts @@ -427,6 +427,7 @@ describe('getCustomFieldData()', () => { linkStart: 'link start', timeEnd: 'time end', duration: 'duration', + flag: 'flag', cue: 'cue', title: 'title', countToEnd: 'count to end', @@ -479,6 +480,7 @@ describe('getCustomFieldData()', () => { linkStart: 'link start', timeEnd: 'time end', duration: 'duration', + flag: 'flag', cue: 'cue', title: 'title', countToEnd: 'count to end', diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 62bdcfe0b..5476464f7 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -63,6 +63,7 @@ let rundownMetadata: RundownMetadata = { playableEventOrder: [], timedEventOrder: [], flatEntryOrder: [], + flags: [], }; const customFieldsMetadata: CustomFieldsMetadata = { @@ -666,7 +667,7 @@ export function init(initialRundown: Readonly, initialCustomFields: Rea processedData; cachedRundown.entries = entries; cachedRundown.order = order; - cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder + cachedRundown.flatOrder = metadata.flatEntryOrder; cachedRundown.revision = rundown.revision; customFieldsMetadata.assigned = assignedCustomFields; rundownMetadata = metadata; diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index e42d540d1..9b5bf27d5 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -225,6 +225,7 @@ export function makeRundownMetadata(customFields: CustomFields) { playableEventOrder: [], timedEventOrder: [], flatEntryOrder: [], + flags: [], entries: {}, order: [], @@ -300,6 +301,11 @@ function processEntry( processedData.firstStart = currentEntry.timeStart; } + // check if event is flagged + if (currentEntry.flag) { + processedData.flags.push(currentEntry.id); + } + currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent); if (currentEntry.gap === 0) { diff --git a/apps/server/src/api-data/rundown/rundown.types.ts b/apps/server/src/api-data/rundown/rundown.types.ts index cd2adcd22..ce0fc2da7 100644 --- a/apps/server/src/api-data/rundown/rundown.types.ts +++ b/apps/server/src/api-data/rundown/rundown.types.ts @@ -10,6 +10,7 @@ export type RundownMetadata = { playableEventOrder: EntryId[]; // flat order of playable events timedEventOrder: EntryId[]; // flat order of timed events flatEntryOrder: EntryId[]; // flat order of entries + flags: EntryId[]; // flat order of flagged entries }; export type AssignedMap = Record; diff --git a/apps/server/src/services/EventTimer.ts b/apps/server/src/services/EventTimer.ts index 98eac3107..b5d8d1f1a 100644 --- a/apps/server/src/services/EventTimer.ts +++ b/apps/server/src/services/EventTimer.ts @@ -51,6 +51,7 @@ export class EventTimer { } } + // register a callback for the scheduled end const endTime = state.timer.current - timerConfig.triggerAhead; this.endCallback = setTimeout(() => this.update(), endTime); return true; diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index ae0118de2..93432711c 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -810,6 +810,11 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert RuntimeService.previousState.blockNext = state.blockNext; } + if (RuntimeService.previousState?.nextFlag !== state.nextFlag) { + batch.add('nextFlag', state.nextFlag); + RuntimeService.previousState.nextFlag = state.nextFlag; + } + if (hasImmediateChanges) { saveRestoreState(state); } diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index 932972a2e..21c7d9588 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -8,6 +8,7 @@ const baseState: RuntimeState = { eventNext: null, blockNow: null, blockNext: null, + nextFlag: null, runtime: { selectedEventIndex: null, numEvents: 0, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 2bc5d35ae..9262b375c 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,6 +1,7 @@ import { - BlockState, + CurrentBlockState, isOntimeBlock, + isOntimeEvent, MaybeNumber, MaybeString, OffsetMode, @@ -11,6 +12,7 @@ import { runtimeStorePlaceholder, TimerPhase, TimerState, + UpcomingEntry, } from 'ontime-types'; import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; @@ -32,8 +34,9 @@ import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js'; export type RuntimeState = { clock: number; // realtime clock - blockNow: BlockState | null; + blockNow: CurrentBlockState | null; blockNext: MaybeString; + nextFlag: UpcomingEntry | null; eventNow: PlayableEvent | null; eventNext: PlayableEvent | null; runtime: Runtime; @@ -53,6 +56,7 @@ const runtimeState: RuntimeState = { clock: timeNow(), blockNow: null, blockNext: null, + nextFlag: null, eventNow: null, eventNext: null, runtime: { ...runtimeStorePlaceholder.runtime }, @@ -111,6 +115,7 @@ export function clearState() { runtimeState.blockNow = null; runtimeState.blockNext = null; + runtimeState.nextFlag = null; runtimeState.runtime.offset = 0; runtimeState.runtime.relativeOffset = 0; @@ -195,6 +200,7 @@ export function load( loadNow(rundown, metadata, eventIndex); loadNext(rundown, metadata, eventIndex); loadBlock(rundown); + loadNextFlag(eventIndex, rundown, metadata); // update state runtimeState.timer.playback = Playback.Armed; @@ -720,6 +726,31 @@ export function loadBlock(rundown: Rundown, state = runtimeState) { } } +/** + * find and load the next flag from the currently loaded event + */ +export function loadNextFlag(currentIndex: number, rundown: Rundown, metadata: RundownMetadata) { + runtimeState.nextFlag = null; + + if (metadata.flags.length === 0) { + return; + } + + for (let i = currentIndex; i < metadata.timedEventOrder.length; i++) { + const entryId = metadata.timedEventOrder[i]; + if (metadata.flags.includes(entryId)) { + const event = rundown.entries[entryId]; + if (!event || !isOntimeEvent(event)) { + continue; + } + runtimeState.nextFlag = { id: event.id, start: event.timeStart }; + return; + } + } + + return null; +} + export function setOffsetMode(mode: OffsetMode) { runtimeState.runtime.offsetMode = mode; } diff --git a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts index 5ff9900dc..17e53269d 100644 --- a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts +++ b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts @@ -1,8 +1,13 @@ import type { MaybeNumber } from '../../utils/utils.type.js'; import type { EntryId } from '../core/OntimeEntry.js'; -export type BlockState = { +export type CurrentBlockState = { id: EntryId; startedAt: MaybeNumber; expectedEnd: MaybeNumber; }; + +export type UpcomingEntry = { + id: EntryId; + start: number; +}; diff --git a/packages/types/src/definitions/runtime/Runtime.type.ts b/packages/types/src/definitions/runtime/Runtime.type.ts index f19e36700..91f554280 100644 --- a/packages/types/src/definitions/runtime/Runtime.type.ts +++ b/packages/types/src/definitions/runtime/Runtime.type.ts @@ -6,8 +6,8 @@ export enum OffsetMode { } export type Runtime = { - numEvents: number; selectedEventIndex: MaybeNumber; + numEvents: number; offset: number; relativeOffset: number; plannedStart: MaybeNumber; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index 52059be3f..d5a86d59d 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -42,6 +42,7 @@ export const runtimeStorePlaceholder: Readonly = { }, blockNow: null, blockNext: null, + nextFlag: null, eventNow: null, eventNext: null, auxtimer1: { diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index fc38c789d..2eb85b164 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,7 +1,7 @@ import type { MaybeString } from '../../utils/utils.type.js'; import type { OntimeEvent } from '../core/OntimeEntry.js'; import type { SimpleTimerState } from './AuxTimer.type.js'; -import type { BlockState } from './CurrentBlockState.type.js'; +import type { CurrentBlockState, UpcomingEntry } from './CurrentBlockState.type.js'; import type { MessageState } from './MessageControl.type.js'; import type { Runtime } from './Runtime.type.js'; import type { TimerState } from './TimerState.type.js'; @@ -20,8 +20,9 @@ export type RuntimeStore = { eventNow: OntimeEvent | null; eventNext: OntimeEvent | null; - blockNow: BlockState | null; + blockNow: CurrentBlockState | null; blockNext: MaybeString; + nextFlag: UpcomingEntry | null; // extra timers auxtimer1: SimpleTimerState; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 30a611cff..e55228849 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -100,7 +100,7 @@ export { OffsetMode } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; -export type { BlockState } from './definitions/runtime/CurrentBlockState.type.js'; +export type { CurrentBlockState, UpcomingEntry } from './definitions/runtime/CurrentBlockState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js'; diff --git a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts index 8c1360b0b..7b84a1621 100644 --- a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts +++ b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts @@ -2,13 +2,14 @@ import type { ImportMap } from '../spreadsheetImport'; import { isImportMap } from '../spreadsheetImport'; describe('isImportMap()', () => { - it('validates a v3 default import map', () => { - const v3ImportMap: ImportMap = { + it('validates a v4 default import map', () => { + const importMap: ImportMap = { worksheet: 'event schedule', timeStart: 'time start', linkStart: 'link start', timeEnd: 'time end', duration: 'duration', + flag: 'flag', cue: 'cue', title: 'title', countToEnd: 'count to end', @@ -23,7 +24,7 @@ describe('isImportMap()', () => { entryId: 'id', }; - expect(isImportMap(v3ImportMap)).toBe(true); + expect(isImportMap(importMap)).toBe(true); }); it('rejects map missing keys', () => { @@ -50,12 +51,13 @@ describe('isImportMap()', () => { }); it('handles custom properties', () => { - const v3ImportMap: ImportMap = { + const importMap: ImportMap = { worksheet: 'event schedule', timeStart: 'time start', linkStart: 'link start', timeEnd: 'time end', duration: 'duration', + flag: 'flag', cue: 'cue', title: 'title', countToEnd: 'count to end', @@ -73,6 +75,6 @@ describe('isImportMap()', () => { entryId: 'id', }; - expect(isImportMap(v3ImportMap)).toBe(true); + expect(isImportMap(importMap)).toBe(true); }); });