feat: propper timeuntil calc in countdown view (#1765)

* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
This commit is contained in:
Alex Christoffer Rasmussen
2025-09-07 21:40:27 +02:00
parent 4385e0b9c9
commit 67e67d6a5d
15 changed files with 272 additions and 209 deletions
@@ -173,7 +173,7 @@ describe('initRundownMetadata()', () => {
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 10,
isLinkedToLoaded: false, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
groupColour: 'red', groupColour: 'red',
@@ -125,6 +125,7 @@ function processEntry(
if (entry.id === selectedEventId) { if (entry.id === selectedEventId) {
processedData.isLoaded = true; processedData.isLoaded = true;
processedData.isPast = false; processedData.isPast = false;
processedData.isLinkedToLoaded = true;
} }
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
@@ -158,7 +159,8 @@ function processEntry(
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd event * b) find a countToEnd event
*/ */
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd; processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
} }
if (isNewLatest(entry, processedData.latestEvent)) { if (isNewLatest(entry, processedData.latestEvent)) {
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langDe: TranslationObject = { export const langDe: TranslationObject = {
'common.expected_finish': 'Erwartetes Ende', 'common.expected_finish': 'Erwartetes Ende',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sek',
'common.now': 'Jetzt', 'common.now': 'Jetzt',
'common.next': 'Nächste', 'common.next': 'Nächste',
'common.scheduled_start': 'Geplanter beginn', 'common.scheduled_start': 'Geplanter beginn',
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten', 'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um', 'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft', 'countdown.running': 'Veranstaltung läuft',
'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen', 'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn', 'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langEs: TranslationObject = { export const langEs: TranslationObject = {
'common.expected_finish': 'Finalización esperada', 'common.expected_finish': 'Finalización esperada',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Ahora', 'common.now': 'Ahora',
'common.next': 'Siguiente', 'common.next': 'Siguiente',
'common.scheduled_start': 'Inicio programado', 'common.scheduled_start': 'Inicio programado',
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos', 'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las', 'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso', 'countdown.running': 'Evento en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir', 'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento', 'countdown.waiting': 'Esperando el inicio del evento',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langFr: TranslationObject = { export const langFr: TranslationObject = {
'common.expected_finish': 'Fin estimée à', 'common.expected_finish': 'Fin estimée à',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Maintenant', 'common.now': 'Maintenant',
'common.next': 'A suivre', 'common.next': 'A suivre',
'common.scheduled_start': 'Début prévu', 'common.scheduled_start': 'Début prévu',
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée', 'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à', 'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours', 'countdown.running': 'Évènement en cours',
'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre', 'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement', 'countdown.waiting': 'En attente du début de l’évènement',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langIt: TranslationObject = { export const langIt: TranslationObject = {
'common.expected_finish': 'Fine Prevista', 'common.expected_finish': 'Fine Prevista',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Adesso', 'common.now': 'Adesso',
'common.next': 'Prossimo', 'common.next': 'Prossimo',
'common.scheduled_start': 'Inizio programmato', 'common.scheduled_start': 'Inizio programmato',
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile', 'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle', 'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso', 'countdown.running': 'Evento in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire', 'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento", 'countdown.waiting': "In attesa dell'inizio dell'evento",
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langPt: TranslationObject = { export const langPt: TranslationObject = {
'common.expected_finish': 'Término esperado', 'common.expected_finish': 'Término esperado',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Agora', 'common.now': 'Agora',
'common.next': 'Próximo', 'common.next': 'Próximo',
'common.scheduled_start': 'Início programado', 'common.scheduled_start': 'Início programado',
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados', 'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às', 'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento', 'countdown.running': 'Evento em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar', 'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar', 'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento', 'countdown.waiting': 'Aguardando o início do evento',
+11 -3
View File
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
} }
.sub--live { .sub--live {
background-color: $green-700; background-color: $active-green;
}
.sub--armed {
background-color: $gray-1325;
} }
.sub__binder { .sub__binder {
@@ -140,12 +144,16 @@ $item-height: 3.5rem;
color: $delay-color; color: $delay-color;
} }
.sub__schedule--strike {
text-decoration: line-through;
}
.sub__schedule--over { .sub__schedule--over {
color: $playback-over color: $playback-over;
} }
.sub__schedule--under { .sub__schedule--under {
color: $playback-under color: $playback-under;
} }
.sub__title { .sub__title {
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useClock } from '../../common/hooks/useSocket'; import { useClock } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatTime, getDefaultFormat } from '../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
return <Countdown {...data} />; return <Countdown {...data} />;
} }
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) { function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { subscriptions } = useCountdownOptions(); const { subscriptions } = useCountdownOptions();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
// gather rundown data // gather rundown data
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event)); const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
// gather presentation data // gather presentation data
const hasEvents = playableEvents.length > 0; const hasEvents = playableEvents.length > 0;
@@ -85,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
} }
interface CountdownContentsProps { interface CountdownContentsProps {
playableEvents: OntimeEvent[]; playableEvents: ExtendedEntry<OntimeEvent>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -1,32 +1,37 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import { EntryId, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
useCountdownSocket, import useReport from '../../common/hooks-query/useReport';
useCurrentDay,
usePlayback,
useRuntimeOffset,
useSelectedEventId,
} from '../../common/hooks/useSocket';
import { getOffsetState } from '../../common/utils/offset'; import { getOffsetState } from '../../common/utils/offset';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import FollowButton from '../../features/operator/follow-button/FollowButton'; import FollowButton from '../../features/operator/follow-button/FollowButton';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime'; import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils'; import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils'; import {
CountdownEvent,
extendEventData,
getIsLive,
isOutsideRange,
preferredFormat12,
preferredFormat24,
useSubscriptionDisplayData,
} from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSubscriptionsProps { interface CountdownSubscriptionsProps {
subscribedEvents: OntimeEvent[]; subscribedEvents: ExtendedEntry<OntimeEvent>[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const showFab = useFadeOutOnInactivity(true); const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const timeoutId = useRef<NodeJS.Timeout | null>(null); const timeoutId = useRef<NodeJS.Timeout | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -92,23 +100,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
{subscribedEvents.map((event) => { {subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource); const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback); const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
return ( return (
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}> <div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
>
<div className='sub__binder' style={{ '--user-color': event.colour }} /> <div className='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}> <ScheduleTime event={countdownEvent} showExpected={showExpected} />
{showExpected ? ( <SubscriptionStatus event={countdownEvent} />
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} /> <div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
) : (
<>
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
)}
</div>
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div> </div>
); );
@@ -123,63 +127,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
); );
} }
interface ExpectedScheduleProps { type ScheduleTimeProps = {
timeStart: number; event: CountdownEvent;
timeEnd: number; showExpected: boolean;
delay: number; };
} //TODO: consider relative mode
function ExpectedSchedule(props: ExpectedScheduleProps) { export function ScheduleTime(props: ScheduleTimeProps) {
const { timeStart, timeEnd, delay } = props; const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const { offset } = useRuntimeOffset(); const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
// offset is negative if we are ahead // only show new exacted value if outside range of the planned value
const expectedOffset = offset - delay; const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
const expectedState = getOffsetState(expectedOffset);
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return ( return (
<> <div className='sub__schedule'>
<ClockTime <ClockTime
value={timeStart - expectedOffset} value={plannedStart}
className={`sub__schedule--${expectedState}`} preferredFormat12={preferredFormat12}
preferredFormat12='h:mm' preferredFormat24={preferredFormat24}
preferredFormat24='HH:mm' className={plannedStateClass}
/> />
{!isExpectedValueShow && (
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <>
</>
<ClockTime
value={plannedEnd}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
className={plannedStateClass}
/>
</>
)}
{isExpectedValueShow && (
<>
<ClockTime
value={expectedStart}
className={expectedStateClass}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
/>
<ClockTime
value={expectedEnd}
className={expectedEndClass}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
/>
</>
)}
</div>
); );
} }
interface SubscriptionStatusProps { interface SubscriptionStatusProps {
event: OntimeEvent; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
selectedEventId: EntryId | null;
} }
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation(); const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showExpected } = useCountdownOptions();
const { playback, current, clock } = useCountdownSocket();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
current,
playback,
clock,
event,
selectedEventId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showExpected,
);
return ( return (
<> <>
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div> <div className='sub__status'>{statusDisplay}</div>
<div className='sub__timer'>{timer}</div> {status === 'done' ? (
<SuperscriptTime className='sub__timer' time={timeDisplay} />
) : (
<div className='sub__timer'>{timeDisplay}</div>
)}
</> </>
); );
} }
@@ -15,6 +15,14 @@
font-size: clamp(40px, 4.5vw, 80px); font-size: clamp(40px, 4.5vw, 80px);
line-height: 1.1em; line-height: 1.1em;
text-align: center; text-align: center;
border-left: 0.25em solid;
border-color: var(--card-background-color-override, $viewer-card-bg-color);
.secondary {
text-align: left;
font-size: clamp(20px, 2vw, 40px);
}
} }
.event__status { .event__status {
@@ -1,29 +1,57 @@
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import { OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket'; import { useExpectedStartData } from '../../common/hooks/useSocket';
import useReport from '../../common/hooks-query/useReport';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils'; import { useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
interface SingleEventCountdownProps { interface SingleEventCountdownProps {
subscribedEvent: OntimeEvent; subscribedEvent: ExtendedEntry<OntimeEvent>;
goToEditMode: () => void; goToEditMode: () => void;
} }
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) { export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true); const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const { totalGap, isLinkedToLoaded } = subscribedEvent;
const expectedStart = getExpectedStart(subscribedEvent, {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
return ( return (
<div className='single-container' data-testid='countdown-event'> <div className='single-container' data-testid='countdown-event'>
<SubscriptionStatus event={subscribedEvent} /> <SubscriptionStatus event={countdownEvent} />
<div className='event__title'>{subscribedEvent.title}</div> <div className='event__title' style={{ borderColor: countdownEvent.colour }}>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
{title}
{secondaryData && <div className='secondary'>{secondaryData}</div>}
</div>
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}> <div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit <IoPencil /> Edit
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
} }
interface SubscriptionStatusProps { interface SubscriptionStatusProps {
event: OntimeEvent; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
} }
function SubscriptionStatus({ event }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation(); const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
const { selectedEventId } = useSelectedEventId();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showExpected } = useCountdownOptions();
const { playback, current, clock } = useCountdownSocket();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
current,
playback,
clock,
event,
selectedEventId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showExpected,
);
return ( return (
<> <>
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div> <div className='event__status'>{statusDisplay}</div>
<div className='event__timer'>{timer}</div> {status === 'done' ? (
<SuperscriptTime className='event__timer' time={timeDisplay} />
) : (
<div className='event__timer'>{timeDisplay}</div>
)}
</> </>
); );
} }
@@ -1,8 +1,11 @@
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
import { getFormattedTimer } from '../../features/viewers/common/viewUtils'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import type { TranslationKey } from '../../translation/TranslationProvider'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
/** /**
* Parses string as a title * Parses string as a title
@@ -11,6 +14,9 @@ export function sanitiseTitle(title: string | null) {
return title ?? '{no title}'; return title ?? '{no title}';
} }
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/** /**
* Whether the current event is live * Whether the current event is live
*/ */
@@ -18,137 +24,96 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
return currentId === selectedId && playback !== Playback.Armed; return currentId === selectedId && playback !== Playback.Armed;
} }
const subscriptionTimerDisplayOptions = { export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
removeSeconds: true, type TimerMessage = Record<ProgressStatus, TranslationKey>;
removeLeadingZero: true,
} as const;
const subscriptionScheduledTimeDisplayOptions = {
removeSeconds: true,
removeLeadingZero: false,
} as const;
type TimerMessage = Record<string, TranslationKey>;
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
export const timerProgress: TimerMessage = { export const timerProgress: TimerMessage = {
future: 'countdown.to_start', future: 'countdown.to_start',
due: 'timeline.due', due: 'timeline.due',
live: 'timeline.live', live: 'countdown.running',
pending: 'countdown.waiting',
loaded: 'countdown.loaded',
done: 'countdown.ended', done: 'countdown.ended',
}; };
export function getFormattedTime(
value: MaybeNumber,
status: ProgressStatus,
minText: string,
secText: string,
dueText: string,
) {
if (value === null) return timerPlaceholderMin;
if (status === 'future' || status === 'live') {
if (value <= 0) return dueText.toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
.replace('m', `${minText} `)
.replace('s', secText);
}
return removeSeconds(formatTime(value));
}
/** /**
* Returns a parsed timer and relevant status message * Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
* TODO: get data from reporter and check if the event has played
* TODO: get timer data granularly
*/ */
export function getSubscriptionDisplayData( export function useSubscriptionDisplayData(
current: MaybeNumber, subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
playback: Playback, ): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
clock: number, const { playback, current, clock } = useCountdownSocket();
subscribedEvent: OntimeEvent, const { getLocalizedString } = useTranslation();
selectedId: EntryId | null,
offset: number,
currentDay: number,
minutesString: string,
showExpected = false,
): { status: ProgressStatus; timer: string } {
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
if (selectedId === subscribedEvent.id) { const bigDuration = (value: number) => {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>} if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
.replace('m', `${getLocalizedString('common.minutes')} `)
.replace('s', getLocalizedString('common.seconds'));
};
if (subscribedEvent.isLoaded) {
if (playback === Playback.Armed) { if (playback === Playback.Armed) {
// if we are following the event, but it is not running, we show the scheduled start
return { return {
status: 'due', status: 'loaded',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['loaded']),
subscribedEvent.timeStart + offsetAndDelay, timeDisplay: bigDuration(subscribedEvent.duration),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
return { return {
status: 'live', status: 'live',
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions), statusDisplay: getLocalizedString(timerProgress['live']),
timeDisplay: bigDuration(current ?? 0),
}; };
} }
/** if (playback === Playback.Stop || playback === Playback.Armed) {
* If the running timer is not the one we are following
* we can be in future, due or have ended
*/
// 3. event is the day after, we show a countdown to start
if (subscribedEvent.dayOffset > currentDay) {
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
return { return {
status: 'future', status: 'pending',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['pending']),
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay, timeDisplay: ' ',
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
}; };
} }
// 4. event is the before after, show the scheduled end if (subscribedEvent.isPast) {
// TODO: get the time from the reporter
if (subscribedEvent.dayOffset < currentDay) {
return { return {
status: 'done', status: 'done',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['done']),
subscribedEvent.timeEnd, timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
// 5. if event is in future, we count to the scheduled start if (subscribedEvent.expectedStart - clock <= 0) {
// TODO: get time until
if (clock < subscribedEvent.timeStart) {
return { return {
status: 'future', status: 'due',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
subscribedEvent.timeStart - clock - offsetAndDelay, timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
}; };
} }
// 6. if event has ended, we show the scheduled end
// TODO: get the time from the reporter
if (clock > subscribedEvent.timeEnd) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
};
}
// the event here has to be due, we show the countdown the expected start time
return { return {
status: 'due', status: 'future',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['future']),
subscribedEvent.timeStart + offsetAndDelay, timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
@@ -181,7 +146,7 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
* Since the original array is already ordered, we simply filter out the events * Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list. * which are not in the subscriptions list.
*/ */
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] { export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
return playableEvents.filter((event) => subscriptions.includes(event.id)); return playableEvents.filter((event) => subscriptions.includes(event.id));
} }
@@ -212,3 +177,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
return true; return true;
} }
export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE;
}
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
export function extendEventData(
event: ExtendedEntry<OntimeEvent>,
currentDay: number,
actualStart: MaybeNumber,
plannedStart: MaybeNumber,
offset: number,
mode: OffsetMode,
reportData: OntimeReport,
): CountdownEvent {
const { totalGap, isLinkedToLoaded } = event;
const expectedStart = getExpectedStart(event, {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
}
@@ -2,14 +2,15 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils'; import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface CountdownData { export interface CountdownData {
customFields: CustomFields; customFields: CustomFields;
events: OntimeEntry[]; rundownData: ExtendedEntry<OntimeEntry>[];
projectData: ProjectData; projectData: ProjectData;
isMirrored: boolean; isMirrored: boolean;
settings: Settings; settings: Settings;
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
return { return {
data: { data: {
customFields, customFields,
events: rundownData, rundownData,
projectData, projectData,
isMirrored, isMirrored,
settings, settings,
+2
View File
@@ -1,6 +1,7 @@
export const langEn = { export const langEn = {
'common.expected_finish': 'Expected Finish', 'common.expected_finish': 'Expected Finish',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Now', 'common.now': 'Now',
'common.next': 'Next', 'common.next': 'Next',
'common.scheduled_start': 'Scheduled start', 'common.scheduled_start': 'Scheduled start',
@@ -13,6 +14,7 @@ export const langEn = {
'common.no_data': 'No data', 'common.no_data': 'No data',
'countdown.ended': 'Event ended at', 'countdown.ended': 'Event ended at',
'countdown.running': 'Event running', 'countdown.running': 'Event running',
'countdown.loaded': 'Event loaded',
'countdown.select_event': 'Select an event to follow', 'countdown.select_event': 'Select an event to follow',
'countdown.to_start': 'Time to start', 'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start', 'countdown.waiting': 'Waiting for event start',