wip: countdown view redesign

This commit is contained in:
Carlos Valente
2025-06-03 21:45:17 +02:00
parent 0c7c90cae4
commit 2f8f4ad129
16 changed files with 889 additions and 127 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
@@ -1,12 +1,13 @@
@use '../../../theme/viewerDefs' as *;
.baseButton {
height: 2rem;
padding-inline: 1em;
display: flex;
align-items: center;
gap: 0.5em;
width: fit-content;
padding-inline: 1em;
border: 1px solid transparent;
border-radius: 3px;
@@ -50,3 +51,11 @@
opacity: $viewer-opacity-disabled;
}
}
.medium {
height: 2rem;
}
.large {
height: 3.5rem;
}
@@ -6,13 +6,14 @@ import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'subtle' | 'primary';
size?: 'medium' | 'large';
}
export default function Button(props: ButtonProps) {
const { className, children, variant = 'subtle', ...buttonProps } = props;
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
return (
<button className={cx([style.baseButton, style[variant], className])} type='button' {...buttonProps}>
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
{children}
</button>
);
+5 -1
View File
@@ -164,11 +164,15 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
@@ -118,7 +118,8 @@
padding-inline: 0.25rem;
background-color: $gray-1250;
color: $ui-white;
white-space: pre;
// allow multi-line text but trim before
white-space: pre-line;
}
}
@@ -20,7 +20,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getSubscriptionDisplayData, getTimerItems, TimerMessage } from '../../../views/countdown/countdown.utils';
import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect';
@@ -78,7 +78,7 @@ export default function Countdown(props: CountdownProps) {
}
}, [backstageEvents, searchParams]);
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const { message: runningMessage, timer: runningTimer } = getSubscriptionDisplayData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.phase === TimerPhase.Overtime;
@@ -4,8 +4,7 @@ import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { sanitiseTitle } from './countdown.helpers';
import { sanitiseTitle } from '../../../views/countdown/countdown.utils';
import './Countdown.scss';
@@ -1,6 +1,6 @@
import { dayInMs } from 'ontime-utils';
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
import { getSubscriptionDisplayData, sanitiseTitle, TimerMessage } from '../../../../views/countdown/countdown.utils';
describe('sanitiseTitle() function', () => {
it('should return a title when valid', () => {
@@ -26,7 +26,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId };
const time = { current: currentMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
const { message, timer } = getSubscriptionDisplayData(time, follow, followId);
expect(message).toBe(TimerMessage.running);
expect(timer).toBe(currentMockValue);
});
@@ -37,7 +37,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: 'anotherevent', timeStart: startMockValue };
const time = { clock: timeNow };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
@@ -50,7 +50,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(endMockValue - startMockValue);
});
@@ -63,7 +63,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.ended);
expect(timer).toBe(endMockValue);
});
@@ -76,7 +76,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
});
@@ -89,7 +89,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
const { message, timer } = getSubscriptionDisplayData(time, follow, followId);
expect(message).toBe(TimerMessage.running);
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
});
@@ -102,7 +102,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
@@ -1,106 +0,0 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage {
toStart = 'to_start',
waiting = 'waiting',
running = 'running',
ended = 'ended',
unhandled = '',
}
/**
* Parses string as a title
*/
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
/**
* Returns a parsed timer and relevant status message
*/
export const fetchTimerData = (
time: ViewExtendedTimer,
follow: OntimeEvent | null,
selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => {
if (follow === null) {
return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) {
// if it is selected, it may not be running
return {
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
timer: time.current ?? 0,
};
}
const showProjected = getShouldShowProjected();
const addedTime = showProjected ? offset : 0;
if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// running timer timer is not the one we are following
// ends day after
if (follow.timeStart > follow.timeEnd) {
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
};
/**
* Gets values for the timer items
*/
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
if (start == null || end == null) {
return {
scheduledStart: '',
scheduledEnd: '',
projectedStart: '',
projectedEnd: '',
};
}
const showProjected = getShouldShowProjected();
const scheduledStart = formatTime(start + delay);
const scheduledEnd = formatTime(end + delay);
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
return {
scheduledStart,
scheduledEnd,
projectedStart,
projectedEnd,
};
}
/**
* Gets from the URL whether the showProjected option is active
*/
function getShouldShowProjected() {
const params = new URL(document.location.href).searchParams;
return isStringBoolean(params.get('showProjected'));
}
@@ -28,8 +28,10 @@ const translationsList = {
zh: langZhCn,
};
export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue {
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
getLocalizedString: (key: TranslationKey, lang?: string) => string;
}
const TranslationContext = createContext<TranslationContextValue>({
@@ -40,7 +42,7 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings();
const getLocalizedString = useCallback(
(key: keyof typeof langEn, lang = data?.language || 'en'): string => {
(key: TranslationKey, lang = data?.language || 'en'): string => {
if (lang in translationsList) {
if (key in translationsList[lang as keyof typeof translationsList]) {
return translationsList[lang as keyof typeof translationsList][key];
@@ -0,0 +1,199 @@
@use '../../theme/viewerDefs' as *;
$item-height: 3.5rem;
.countdown {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: flex;
flex-direction: column;
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
/* =================== HEADER + EXTRAS ===================*/
.project-header {
font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
}
.logo {
max-width: min(200px, 20vw);
}
.title {
line-height: 1.1em;
}
.clock-container {
margin-left: auto;
font-weight: 600;
.label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.fab-container {
position: fixed;
bottom: 1.5rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
right: $view-inline-padding;
left: 0;
display: flex;
justify-content: end;
gap: 2rem;
transition-property: opacity;
transition-duration: 0.3s;
}
.fab-container--hidden {
opacity: 0;
pointer-events: none;
}
/* ========================= LIST ========================*/
.empty-container {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 2rem;
}
}
.list-container {
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* ====================== LIST-ITEM ======================*/
.sub {
margin: 2px;
flex: 1;
display: grid;
grid-template-columns: 1rem 1fr auto;
grid-template-areas:
'binder schedule status'
'binder title timer'
'binder secondary secondary';
column-gap: 1rem;
background: $viewer-card-bg-color;
padding-right: 1rem;
border-radius: $element-border-radius;
overflow: clip;
&:hover {
.sub__label {
color: $ui-white;
}
}
}
.sub--selected {
background: $blue-700;
}
.sub--live {
background-color: $green-700;
}
.sub__binder {
background: var(--user-color, $viewer-card-bg-color);
grid-area: binder;
}
.sub__schedule {
grid-area: schedule;
padding-top: 0.5rem;
display: flex;
gap: 0.25em;
font-size: $timer-label-size;
}
.sub__schedule--delayed {
color: $delay-color;
}
.sub__schedule--ahead {
color: $green-500;
}
.sub__schedule--behind {
color: $orange-500;
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
font-size: $title-font-size;
line-height: 1.1em;
}
.sub__secondary {
grid-area: secondary;
padding-bottom: 0.5rem;
font-size: $base-font-size;
line-height: 1.1em;
// allow multi-line text but trim before
white-space: pre-line;
}
.sub__status {
grid-area: status;
padding-top: 0.5rem;
font-size: $timer-label-size;
text-align: right;
text-transform: uppercase;
}
.sub__label {
grid-area: status;
padding-top: 0.5rem;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-align: right;
}
.sub__timer {
grid-area: timer;
font-size: $timer-value-size;
line-height: 1.1em;
font-weight: 600;
text-align: right;
}
/* ====================== MODIFIERS ======================*/
.subdued {
opacity: 0.6;
}
}
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import {
CustomFields,
EntryId,
isOntimeEvent,
isPlayableEvent,
OntimeEvent,
ProjectData,
Settings,
} from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import './Countdown.scss';
interface CountdownProps {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
general: ProjectData;
time: ViewExtendedTimer;
isMirrored: boolean;
selectedId: EntryId | null;
settings: Settings | undefined;
}
export default function Countdown(props: CountdownProps) {
const { backstageEvents, customFields, general, time, isMirrored, selectedId, settings } = props;
const { getLocalizedString } = useTranslation();
const { subscriptions } = useCountdownOptions();
const [editMode, setEditMode] = useState(false);
useWindowTitle('Countdown');
// gather rundown data
const playableEvents = backstageEvents.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
// gather timer data
const clock = formatTime(time.clock);
// gather presentation data
const hasEvents = playableEvents.length > 0;
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const countdownOptions = getCountdownOptions(defaultFormat, customFields);
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor viewOptions={countdownOptions} />
<div className='project-header'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<div className='title'>{general.title}</div>
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
{hasEvents && editMode && (
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)}
{hasEvents && !editMode && (
<CountdownContents
playableEvents={playableEvents}
subscriptions={subscriptions}
time={time}
goToEditMode={() => setEditMode(true)}
selectedId={selectedId}
/>
)}
</div>
);
}
interface CountdownContentsProps {
playableEvents: OntimeEvent[];
selectedId: EntryId | null;
subscriptions: EntryId[];
time: ViewExtendedTimer;
goToEditMode: () => void;
}
function CountdownContents(props: CountdownContentsProps) {
const { playableEvents, selectedId, subscriptions, time, goToEditMode } = props;
const { getLocalizedString } = useTranslation();
if (subscriptions.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<Button variant='primary' size='large' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
</div>
);
}
return (
<CountdownSubscriptions
events={playableEvents}
selectedId={selectedId}
subscriptions={subscriptions}
time={time}
goToEditMode={goToEditMode}
/>
);
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { EntryId, OntimeEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeEvent[];
subscriptions: EntryId[];
disableEdit: () => void;
}
export default function CountdownSelect(props: CountdownSelectProps) {
const { events, subscriptions, disableEdit } = props;
const [selected, setSelected] = useState<EntryId[]>(subscriptions);
const navigate = useNavigate();
/**
* Toggles an entry from the selected set
*/
const toggleSelect = (entryId: EntryId) => {
setSelected((prev) => {
if (prev.includes(entryId)) {
// If the entry is already selected, remove it
return prev.filter((id) => id !== entryId);
}
return [...prev, entryId];
});
};
/**
* Creates a URL with the selected subscriptions
* and navigates to it
*/
const applySelection = () => {
const url = makeSubscriptionsUrl(window.location.href, selected);
disableEdit();
navigate(url.search.toString());
};
// make a copy of the selected array for quick lookup
const selectedIds = new Set(selected);
return (
<div className='list-container'>
{events.map((event: OntimeEvent, index: number) => {
const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id);
return (
<div
key={index}
role='button'
tabIndex={0}
onClick={() => toggleSelect(event.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
toggleSelect(event.id);
e.stopPropagation();
}
}}
className={cx(['sub', isSelected && 'sub--selected'])}
>
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div>
</div>
);
})}
<div className='fab-container'>
<Button variant='subtle' size='large' onClick={disableEdit}>
<IoArrowBack /> Go back
</Button>
<Button variant='subtle' size='large' onClick={() => setSelected([])} disabled={selected.length === 0}>
<IoClose /> Clear
</Button>
<Button variant='primary' size='large' disabled={events.length < 1} onClick={applySelection}>
<IoSaveOutline /> Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { IoPencil } from 'react-icons/io5';
import { EntryId, OntimeEvent, Playback } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useCurrentDay, useRuntimeOffset } from '../../common/hooks/useSocket';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
import './Countdown.scss';
interface CountdownSubscriptionsProps {
events: OntimeEvent[];
selectedId: EntryId | null;
subscriptions: EntryId[];
time: ViewExtendedTimer;
goToEditMode: () => void;
}
export default function CountdownSubscriptions(props: CountdownSubscriptionsProps) {
const { time, events, selectedId, goToEditMode } = props;
const { secondarySource, subscriptions, showProjected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
// TODO: add follow selected
// gather data
const subscribedEvents = getOrderedSubscriptions(subscriptions, events);
return (
<div className='list-container'>
{subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource);
const isLive = event.id === selectedId && time.playback !== Playback.Armed;
return (
<div key={event.id} className={cx(['sub', isLive && 'sub--live'])}>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
{showProjected ? (
<ProjectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
) : (
<>
<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} selectedId={selectedId} time={time} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='large' disabled={events.length < 1} onClick={goToEditMode}>
<IoPencil /> Edit
</Button>
</div>
</div>
);
}
interface ProjectedScheduleProps {
timeStart: number;
timeEnd: number;
delay: number;
}
function ProjectedSchedule(props: ProjectedScheduleProps) {
const { timeStart, timeEnd, delay } = props;
const { offset } = useRuntimeOffset();
// offset is negative if we are ahead
const projectedOffset = offset - delay;
const classes = cx([projectedOffset > 0 && 'sub__schedule--ahead', projectedOffset < 0 && 'sub__schedule--behind']);
return (
<>
<ClockTime
value={timeStart - projectedOffset}
className={classes}
preferredFormat12='h:mm'
preferredFormat24='HH:mm'
/>
<ClockTime value={timeEnd - projectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
);
}
interface SubscriptionStatusProps {
time: ViewExtendedTimer;
event: OntimeEvent;
selectedId: EntryId | null;
}
function SubscriptionStatus(props: SubscriptionStatusProps) {
const { time, event, selectedId } = props;
const { getLocalizedString } = useTranslation();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showProjected } = useCountdownOptions();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
time,
event,
selectedId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showProjected,
);
return (
<>
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
<div className='sub__timer'>{timer}</div>
</>
);
}
@@ -0,0 +1,107 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getCountdownOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
value: '',
type: 'persist',
},
{
// TODO: adding a secondary source is removing the subscriptions
// this seems to be a bug with persist assuming that the property has a single entry
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in the card',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'followSelected',
title: 'Follow selected event',
description: 'Whether the view should automatically scroll to the selected event',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
type CountdownOptions = {
subscriptions: EntryId[];
secondarySource: keyof OntimeEvent | null;
showProjected: boolean;
followSelected: boolean;
hidePast: boolean;
};
/**
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
// we manually make an object that matches the key above
return {
subscriptions: searchParams.getAll('sub') as EntryId[],
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
showProjected: isStringBoolean(searchParams.get('showProjected')),
followSelected: isStringBoolean(searchParams.get('followSelected')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
};
}
/**
* Hook exposes the backstage view options
*/
export function useCountdownOptions(): CountdownOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -0,0 +1,194 @@
import { EntryId, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
import type { TranslationKey } from '../../translation/TranslationProvider';
/**
* Parses string as a title
*/
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
const subscriptionTimerDisplayOptions = {
removeSeconds: true,
removeLeadingZero: true,
} as const;
type TimerMessage = Record<string, TranslationKey>;
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
export const timerProgress: TimerMessage = {
future: 'countdown.to_start',
due: 'timeline.due',
live: 'timeline.live',
done: 'countdown.ended',
};
/**
* Returns a parsed timer and relevant status message
* 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(
time: ViewExtendedTimer,
subscribedEvent: OntimeEvent,
selectedId: EntryId | null,
offset: number,
currentDay: number,
minutesString: string,
showProjected = false,
): { status: ProgressStatus; timer: string } {
const addedTime = showProjected ? offset : 0;
if (selectedId === subscribedEvent.id) {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
if (time.playback === Playback.Armed) {
// if we are following the event, but it is not running, we show the armed timer
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}
return {
status: 'live',
timer: getFormattedTimer(time.current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
};
}
/**
* 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
if (subscribedEvent.dayOffset > currentDay) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart - time.clock - addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 4. event is the before after, show the scheduled end
if (subscribedEvent.dayOffset < currentDay) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 5. if event is in future, we count to the scheduled start
if (time.clock < subscribedEvent.timeStart) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart - time.clock - addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 6. if event has ended, we count to the scheduled end
if (time.clock > subscribedEvent.timeEnd) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// the event here has to be due
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
/**
* Adds a set of subscriptions to the URL parameters
*/
export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
const url = new URL(urlRef);
const newParams = new URLSearchParams();
// copy existing parameters except for 'sub'
for (const [key, value] of url.searchParams.entries()) {
if (key !== 'sub') {
newParams.append(key, value);
}
}
// add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
url.search = newParams.toString();
return url;
}
/**
* Returns an array of events subscribed events ordered by scheduled
* Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list.
*/
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
return playableEvents.filter((event) => subscriptions.includes(event.id));
}
/**
* Checks through the rundown whether the current event is linked to the loaded event
*/
export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId | null, currentId: EntryId): boolean {
// if nothing is loaded, we return true to simplify the logic
if (!loadedId) {
return true;
}
const loadedIndex = events.findIndex((event) => event.id === loadedId);
if (loadedIndex === -1) {
return true;
}
for (let i = loadedIndex; i < events.length; i++) {
const event = events[i];
if (event.id === currentId) {
return true;
}
if (event.linkStart === null) {
return false;
}
}
return true;
}