diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 741e9f9ef..0000a7650 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -182,3 +182,11 @@ export const usePing = createSelector((state: RuntimeStore) => ({ export const useIsOnline = createSelector((state: RuntimeStore) => ({ isOnline: state.ping > 0, })); + +export const usePlayback = () => { + const featureSelector = (state: RuntimeStore) => ({ + playback: state.timer.playback, + }); + + return useRuntimeStore(featureSelector); +}; diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 7099ab0f1..6422a343f 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -7,6 +7,7 @@ import { isOntimeEvent, isPlayableEvent, MaybeString, + OntimeEvent, PlayableEvent, Playback, RundownCached, @@ -269,8 +270,9 @@ export default function Rundown({ data }: RundownProps) { // all events before the current selected are in the past let isPast = Boolean(featureData?.selectedEventId); let isNextDay = false; + let totalGap = 0; const isEditMode = appMode === AppMode.Edit; - + let currentDay = 0; return (
@@ -297,6 +299,7 @@ export default function Rundown({ data }: RundownProps) { if (isPlayableEvent(entry)) { isNextDay = checkIsNextDay(entry, lastEvent); + totalGap += !isPast ? entry.gap : 0; if (isNewLatest(entry, lastEvent)) { // populate previous entry thisEvent = entry; @@ -310,6 +313,7 @@ export default function Rundown({ data }: RundownProps) { const hasCursor = entry.id === cursor; if (isLoaded) { isPast = false; + currentDay = (entry as OntimeEvent).dayOffset; } return ( @@ -331,6 +335,8 @@ export default function Rundown({ data }: RundownProps) { playback={isLoaded ? featureData.playback : undefined} isRolling={featureData.playback === Playback.Roll} isNextDay={isNextDay} + totalGap={totalGap} + currentDay={currentDay} />
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index d7fb2ce7d..744674dcc 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -37,6 +37,8 @@ interface RundownEntryProps { previousEventId?: string; playback?: Playback; // we only care about this if this event is playing isRolling: boolean; // we need to know even if not related to this event + totalGap: number; + currentDay: number; } export default function RundownEntry(props: RundownEntryProps) { @@ -52,6 +54,8 @@ export default function RundownEntry(props: RundownEntryProps) { isRolling, eventIndex, isNextDay, + totalGap, + currentDay, } = props; const { emitError } = useEmitLog(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); @@ -173,6 +177,8 @@ export default function RundownEntry(props: RundownEntryProps) { isRolling={isRolling} gap={data.gap} isNextDay={isNextDay} + dayOffset={data.dayOffset - currentDay} + totalGap={totalGap} actionHandler={actionHandler} /> ); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss index 2328b52e0..1ee1bb917 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss +++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss @@ -9,7 +9,7 @@ $skip-opacity: 0.2; display: grid; grid-template-areas: 'binder ... ... ...' - 'binder pb-actions times ...' + 'binder pb-actions times chip' 'binder pb-actions title title' 'binder pb-actions estatus estatus' 'binder ... ... ...'; @@ -131,6 +131,10 @@ $skip-opacity: 0.2; height: 100%; } +.chipSection { + grid-area: chip; +} + .titleSection { grid-area: title; display: flex; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 1f1de2ef3..8fd2fc4ea 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -49,6 +49,8 @@ interface EventBlockProps { isRolling: boolean; gap: number; isNextDay: boolean; + dayOffset: number; + totalGap: number; actionHandler: ( action: EventItemActions, payload?: @@ -87,6 +89,8 @@ export default function EventBlock(props: EventBlockProps) { isRolling, gap, isNextDay, + dayOffset, + totalGap, actionHandler, } = props; const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); @@ -303,6 +307,9 @@ export default function EventBlock(props: EventBlockProps) { loaded={loaded} playback={playback} isRolling={isRolling} + dayOffset={dayOffset} + isPast={isPast} + totalGap={totalGap} /> )} diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index 6d64376fa..a59450486 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -11,12 +11,14 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward' import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types'; +import { dayInMs } from 'ontime-utils'; import { cx } from '../../../common/utils/styleUtils'; import { tooltipDelayMid } from '../../../ontimeConfig'; import EditableBlockTitle from '../common/EditableBlockTitle'; import TimeInputFlow from '../time-input-flow/TimeInputFlow'; +import EventBlockChip from './composite/EventBlockChip'; import EventBlockPlayback from './composite/EventBlockPlayback'; import EventBlockProgressBar from './composite/EventBlockProgressBar'; @@ -42,6 +44,9 @@ interface EventBlockInnerProps { loaded: boolean; playback?: Playback; isRolling: boolean; + dayOffset: number; + isPast: boolean; + totalGap: number; } function EventBlockInner(props: EventBlockInnerProps) { @@ -64,6 +69,9 @@ function EventBlockInner(props: EventBlockInnerProps) { loaded, playback, isRolling, + dayOffset, + isPast, + totalGap, } = props; const [renderInner, setRenderInner] = useState(false); @@ -108,6 +116,17 @@ function EventBlockInner(props: EventBlockInnerProps) { loaded={loaded} disablePlayback={skip || isRolling} /> + {!skip && ( + + )}
{note}
diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss new file mode 100644 index 000000000..b69696431 --- /dev/null +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss @@ -0,0 +1,21 @@ +.chip { + background-color: $gray-1100; + white-space: nowrap; + + font-size: calc(1rem - 3px); + color: $label-gray; + padding: 0.125rem 0.5rem; + border-radius: 2px; + + &.over { + color: $playback-negative; + } + + &.under { + color: $playback-ahead; + } + + &.due { + color: $warning-orange; + } +} diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx new file mode 100644 index 000000000..7cf9f2360 --- /dev/null +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx @@ -0,0 +1,67 @@ +import { Tooltip } from '@chakra-ui/react'; +import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; + +import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket'; +import { cx } from '../../../../common/utils/styleUtils'; +import { formatDuration } from '../../../../common/utils/time'; +import { tooltipDelayFast } from '../../../../ontimeConfig'; + +import style from './EventBlockChip.module.scss'; + +interface EventBlockChipProps { + id: string; + trueTimeStart: number; + isPast: boolean; + isLoaded: boolean; + className: string; + totalGap: number; + isLinkedAndNext: boolean; +} + +export default function EventBlockChip(props: EventBlockChipProps) { + const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext } = props; + const { playback } = usePlayback(); + + if (isLoaded) { + return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates + } + + const playbackActive = isPlaybackActive(playback); + + if (!playbackActive || isPast) { + return null; //TODO: Event report will go here + } + + if (playbackActive) { + // we extracted the component to avoid unnecessary calculations and re-renders + return ( + +
+ +
+
+ ); + } + + return null; +} + +interface EventUntilProps { + trueTimeStart: number; + totalGap: number; + isLinkedAndNext: boolean; +} + +function EventUntil(props: EventUntilProps) { + const { trueTimeStart, totalGap, isLinkedAndNext } = props; + const { clock, offset } = useTimelineStatus(); + + const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0); + const offsetTimestart = trueTimeStart - consumedOffset; + const timeUntil = offsetTimestart - clock; + const isDue = timeUntil < MILLIS_PER_SECOND; + + const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; + + return
{timeUntilString}
; +} diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index efaea7b83..6254e8534 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -25,6 +25,7 @@ $ontime-delay-text: #E69056; $ontime-paused: #c05621; $ontime-stop: #E4281E; $playback-negative: $red-500; +$playback-ahead: $green-500; $active-indicator: #8bb33d; $text-black: $gray-1350; diff --git a/e2e/tests/features/212-time-until.spec.ts b/e2e/tests/features/212-time-until.spec.ts new file mode 100644 index 000000000..cf546b92a --- /dev/null +++ b/e2e/tests/features/212-time-until.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; + +test('linked time until', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + + await page.getByRole('button', { name: 'Clear rundown' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByRole('button', { name: 'Event' }).nth(4).click(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + + await page.getByTestId('entry-1').getByLabel('Start event').click(); + await page.getByLabel('Pause event').click(); + + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m'); + await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('19m'); + await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('29m'); + + await page.getByTestId('entry-1').getByTestId('time-input-duration').click(); + await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('6h'); + await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter'); + + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5h59m'); + await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('6h9m'); + await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('6h19m'); + + await page.getByTestId('entry-1').getByTestId('time-input-duration').click(); + await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('30s'); + await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter'); + + await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('29s'); + await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('10m'); + await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('20m'); +});