mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-26 09:29:10 +00:00
refactor: countdown view redesign
- allow selecting multiple events - follow selected - remove past events
This commit is contained in:
committed by
Carlos Valente
parent
93977ebc48
commit
b2b115c329
@@ -25,7 +25,7 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
|||||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
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 Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.baseButton {
|
.baseButton {
|
||||||
height: 2rem;
|
|
||||||
padding-inline: 1em;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
padding-inline: 1em;
|
||||||
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
||||||
@@ -50,3 +51,11 @@
|
|||||||
opacity: $viewer-opacity-disabled;
|
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> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'subtle' | 'primary';
|
variant?: 'subtle' | 'primary';
|
||||||
|
size?: 'medium' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button(props: ButtonProps) {
|
export default function Button(props: ButtonProps) {
|
||||||
const { className, children, variant = 'subtle', ...buttonProps } = props;
|
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -164,11 +164,15 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
|||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
offsetMode: state.runtime.offsetMode,
|
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,
|
actualStart: state.runtime.actualStart,
|
||||||
plannedStart: state.runtime.plannedStart,
|
plannedStart: state.runtime.plannedStart,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offset,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,15 +14,16 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
transition: bottom 1s;
|
// transition in
|
||||||
|
transition: bottom 0.3s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transition: background-color $transition-time-action;
|
|
||||||
background-color: $blue-900;
|
background-color: $blue-900;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
// transition out
|
||||||
transition: bottom 1s;
|
transition: bottom 1s;
|
||||||
bottom: -50px;
|
bottom: -50px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
white-space: pre;
|
// allow multi-line text but trim before
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
|
|
||||||
// websocket data
|
// websocket data
|
||||||
const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } =
|
const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } = useStore(runtimeStore);
|
||||||
useStore(runtimeStore);
|
|
||||||
const selectedId = eventNow?.id ?? null;
|
const selectedId = eventNow?.id ?? null;
|
||||||
const nextId = eventNext?.id ?? null;
|
const nextId = eventNext?.id ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
|
||||||
|
|
||||||
.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);
|
|
||||||
|
|
||||||
/* =================== MAIN - SELECT ===================*/
|
|
||||||
|
|
||||||
.event-select {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 8vh;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: clamp(1.5rem, 2vw, 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__events {
|
|
||||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
|
||||||
margin-top: 1em;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 70vh;
|
|
||||||
width: 60vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
position: absolute;
|
|
||||||
top: 2vw;
|
|
||||||
left: 2vw;
|
|
||||||
max-width: min(200px, 20vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
|
||||||
|
|
||||||
.countdown-container {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
gap: min(2vh, 1rem);
|
|
||||||
padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto auto auto auto 1fr;
|
|
||||||
grid-template-columns: 100%;
|
|
||||||
grid-template-areas:
|
|
||||||
'header'
|
|
||||||
'status'
|
|
||||||
'clock'
|
|
||||||
'title'
|
|
||||||
'timers';
|
|
||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
|
||||||
|
|
||||||
.clock-container {
|
|
||||||
grid-area: header;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
grid-area: status;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
|
||||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================== TIMER + TITLE ===================*/
|
|
||||||
|
|
||||||
.timer {
|
|
||||||
grid-area: clock;
|
|
||||||
|
|
||||||
font-family: var(--font-family-override, $viewer-font-family);
|
|
||||||
color: var(--timer-color-override, $timer-color);
|
|
||||||
font-size: 15vw;
|
|
||||||
line-height: 0.9em;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 1;
|
|
||||||
|
|
||||||
&--paused {
|
|
||||||
opacity: $viewer-opacity-disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--finished {
|
|
||||||
color: $timer-finished-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
grid-area: title;
|
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: clamp(40px, 4.5vw, 80px);
|
|
||||||
color: var(--accent-color-override, $accent-color);
|
|
||||||
line-height: 1.1em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================== FOOTER TIMERS ===================*/
|
|
||||||
|
|
||||||
.timer-group {
|
|
||||||
grid-area: timers;
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas:
|
|
||||||
'projected-start projected-end'
|
|
||||||
'scheduled-start scheduled-end';
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
justify-items: center;
|
|
||||||
text-align: center;
|
|
||||||
row-gap: clamp(1rem, 5vh, 4rem);
|
|
||||||
|
|
||||||
align-self: flex-end;
|
|
||||||
height: fit-content;
|
|
||||||
|
|
||||||
&__projected-start {
|
|
||||||
grid-area: projected-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__projected-end {
|
|
||||||
grid-area: projected-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__scheduled-start {
|
|
||||||
grid-area: scheduled-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__scheduled-end {
|
|
||||||
grid-area: scheduled-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: $timer-label-size;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__value {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: $timer-value-size;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
line-height: 0.95em;
|
|
||||||
|
|
||||||
&--delayed {
|
|
||||||
color: $delay-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* =================== MOBILE ===================*/
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.countdown {
|
|
||||||
.logo img {
|
|
||||||
height: min(50px, 10vh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
OntimeEntry,
|
|
||||||
OntimeEvent,
|
|
||||||
Playback,
|
|
||||||
ProjectData,
|
|
||||||
Runtime,
|
|
||||||
Settings,
|
|
||||||
TimerPhase,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
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 { 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 { getCountdownOptions } from './countdown.options';
|
|
||||||
import CountdownSelect from './CountdownSelect';
|
|
||||||
|
|
||||||
import './Countdown.scss';
|
|
||||||
|
|
||||||
interface CountdownProps {
|
|
||||||
events: OntimeEvent[];
|
|
||||||
general: ProjectData;
|
|
||||||
isMirrored: boolean;
|
|
||||||
runtime: Runtime;
|
|
||||||
selectedId: string | null;
|
|
||||||
settings: Settings | undefined;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
|
||||||
const { events, general, isMirrored, runtime, selectedId, settings, time } = props;
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
|
|
||||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
|
||||||
const [delay, setDelay] = useState(0);
|
|
||||||
|
|
||||||
useWindowTitle('Countdown');
|
|
||||||
|
|
||||||
// eg. http://localhost:4001/countdown?eventId=ei0us
|
|
||||||
// update data to the event we are following
|
|
||||||
useEffect(() => {
|
|
||||||
if (!events) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventId = searchParams.get('eventid');
|
|
||||||
const eventIndex = searchParams.get('event');
|
|
||||||
|
|
||||||
// if there is no event selected, we reset the data
|
|
||||||
if (!eventId && !eventIndex) {
|
|
||||||
setFollow(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let followThis: OntimeEvent | null = null;
|
|
||||||
|
|
||||||
if (eventId !== null) {
|
|
||||||
followThis = events.find((event) => event.id === eventId) || null;
|
|
||||||
} else if (eventIndex !== null) {
|
|
||||||
followThis = events?.[Number(eventIndex) - 1];
|
|
||||||
}
|
|
||||||
if (followThis !== null) {
|
|
||||||
setFollow(followThis);
|
|
||||||
const idx: number = events.findIndex((event: OntimeEntry) => event.id === followThis?.id);
|
|
||||||
const delayToEvent = events[idx]?.delay ?? 0;
|
|
||||||
setDelay(delayToEvent);
|
|
||||||
}
|
|
||||||
}, [events, searchParams]);
|
|
||||||
|
|
||||||
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
|
||||||
|
|
||||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
|
||||||
const finished = time.phase === TimerPhase.Overtime;
|
|
||||||
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
|
||||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
|
||||||
|
|
||||||
const clock = formatTime(time.clock);
|
|
||||||
const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
|
|
||||||
follow?.timeStart,
|
|
||||||
follow?.timeEnd,
|
|
||||||
delay,
|
|
||||||
runtime.offset,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideSeconds = searchParams.get('hideTimerSeconds');
|
|
||||||
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
|
|
||||||
removeSeconds: isStringBoolean(hideSeconds),
|
|
||||||
removeLeadingZero: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const persistParam = () => {
|
|
||||||
const eventId = searchParams.get('eventid');
|
|
||||||
if (eventId !== null) {
|
|
||||||
return { id: 'eventid', value: eventId };
|
|
||||||
}
|
|
||||||
const eventIndex = searchParams.get('event');
|
|
||||||
if (eventIndex !== null) {
|
|
||||||
return { id: 'eventindex', value: eventIndex };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
|
||||||
const viewOptions = getCountdownOptions(defaultFormat, persistParam());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
|
||||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
|
||||||
<ViewParamsEditor viewOptions={viewOptions} />
|
|
||||||
{follow === null ? (
|
|
||||||
<CountdownSelect events={events} />
|
|
||||||
) : (
|
|
||||||
<div className='countdown-container' data-testid='countdown-event'>
|
|
||||||
<div className='clock-container'>
|
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
|
||||||
<SuperscriptTime time={clock} className='time' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{runningMessage !== TimerMessage.unhandled && (
|
|
||||||
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SuperscriptTime
|
|
||||||
time={formattedTimer}
|
|
||||||
className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}
|
|
||||||
/>
|
|
||||||
{follow?.title && <div className='title'>{follow.title}</div>}
|
|
||||||
|
|
||||||
<div className='timer-group'>
|
|
||||||
{projectedStart && projectedEnd && (
|
|
||||||
<div className='timer-group__projected-start'>
|
|
||||||
<div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
|
|
||||||
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectedStart && projectedEnd && (
|
|
||||||
<div className='timer-group__projected-end'>
|
|
||||||
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
|
|
||||||
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='timer-group__scheduled-start'>
|
|
||||||
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
|
|
||||||
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
|
|
||||||
</div>
|
|
||||||
<div className='timer-group__scheduled-end'>
|
|
||||||
<div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
|
|
||||||
<SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Link } from 'react-router-dom';
|
|
||||||
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 './Countdown.scss';
|
|
||||||
|
|
||||||
interface CountdownSelectProps {
|
|
||||||
events: OntimeEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
|
|
||||||
|
|
||||||
export default function CountdownSelect(props: CountdownSelectProps) {
|
|
||||||
const { events } = props;
|
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
|
|
||||||
const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEntry.Event) as OntimeEvent[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='event-select' data-testid='countdown__select'>
|
|
||||||
<span className='event-select__title'>{getLocalizedString('countdown.select_event')}</span>
|
|
||||||
<ul className='event-select__events'>
|
|
||||||
{!events.length ? (
|
|
||||||
<Empty text='No events in database' />
|
|
||||||
) : (
|
|
||||||
filteredEvents.map((event: OntimeEvent, counter: number) => {
|
|
||||||
const index = counter + 1;
|
|
||||||
const title = sanitiseTitle(event.title);
|
|
||||||
const start = formatTime(event.timeStart, scheduleFormat);
|
|
||||||
const end = formatTime(event.timeEnd, scheduleFormat);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={event.id}>
|
|
||||||
<Link to={`/countdown?eventid=${event.id}`}>{`${index}. ${start} → ${end} | ${title}`}</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
|
||||||
import { OntimeEvent } from 'ontime-types';
|
|
||||||
import { dayInMs } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
|
|
||||||
|
|
||||||
describe('sanitiseTitle() function', () => {
|
|
||||||
it('should return a title when valid', () => {
|
|
||||||
const validTitles = ['Test', 'test', 'test000', '...', 'test0999', 'test%&'];
|
|
||||||
|
|
||||||
for (const title of validTitles) {
|
|
||||||
expect(sanitiseTitle(title)).toBe(title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return {no title} when invalid', () => {
|
|
||||||
const invalidTitles = ['', undefined, null];
|
|
||||||
for (const title of invalidTitles) {
|
|
||||||
expect(sanitiseTitle(title as unknown as string)).toBe('{no title}');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchTimerData() function', () => {
|
|
||||||
it('shows current timer if current is the one we follow', () => {
|
|
||||||
const followId = 'testId';
|
|
||||||
const currentMockValue = 13;
|
|
||||||
const follow = { id: followId } as OntimeEvent;
|
|
||||||
const time = { current: currentMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, followId, 0);
|
|
||||||
expect(message).toBe(TimerMessage.running);
|
|
||||||
expect(timer).toBe(currentMockValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the countdown to an upcoming event', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const timeNow = 1000;
|
|
||||||
const follow = { id: 'anotherevent', timeStart: startMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
|
|
||||||
expect(message).toBe(TimerMessage.toStart);
|
|
||||||
expect(timer).toBe(startMockValue - timeNow);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the timer of a scheduled event that hasnt started', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const endMockValue = 20000;
|
|
||||||
const timeNow = 15000;
|
|
||||||
const followId = 'testId';
|
|
||||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow, current: endMockValue - startMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
|
|
||||||
expect(message).toBe(TimerMessage.waiting);
|
|
||||||
expect(timer).toBe(endMockValue - startMockValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the end time of a finished event', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const endMockValue = 20000;
|
|
||||||
const timeNow = 30000;
|
|
||||||
const followId = 'testId';
|
|
||||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow, current: endMockValue - startMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
|
|
||||||
expect(message).toBe(TimerMessage.ended);
|
|
||||||
expect(timer).toBe(endMockValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handle an idle event that finishes after midnight', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const endMockValue = 1000;
|
|
||||||
const timeNow = 15000;
|
|
||||||
const followId = 'testId';
|
|
||||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
|
|
||||||
expect(message).toBe(TimerMessage.waiting);
|
|
||||||
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handle an current event that finishes after midnight', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const endMockValue = 1000;
|
|
||||||
const timeNow = 15000;
|
|
||||||
const followId = 'testId';
|
|
||||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, followId, 0);
|
|
||||||
expect(message).toBe(TimerMessage.running);
|
|
||||||
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handle an event that finishes after midnight but hasnt started', () => {
|
|
||||||
const startMockValue = 10000;
|
|
||||||
const endMockValue = 1000;
|
|
||||||
const timeNow = 2000;
|
|
||||||
const followId = 'testId';
|
|
||||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
|
|
||||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
|
|
||||||
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'));
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { getTimeOption, hideTimerSeconds, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import { ParamField, ViewOption } from '../../../common/components/view-params-editor/types';
|
|
||||||
|
|
||||||
const makePersistedField = (id: string, value: string): ParamField => {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: 'Used to keep the selection on submit',
|
|
||||||
description: 'Used to keep the selection on submit',
|
|
||||||
type: 'persist',
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Persisted = { id: string; value: string };
|
|
||||||
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
|
|
||||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
|
||||||
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds] },
|
|
||||||
{
|
|
||||||
title: OptionTitle.BehaviourOptions,
|
|
||||||
collapsible: true,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
id: 'showProjected',
|
|
||||||
title: 'Show projected time',
|
|
||||||
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -28,8 +28,10 @@ const translationsList = {
|
|||||||
zh: langZhCn,
|
zh: langZhCn,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof langEn;
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
|
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranslationContext = createContext<TranslationContextValue>({
|
const TranslationContext = createContext<TranslationContextValue>({
|
||||||
@@ -40,7 +42,7 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const { data } = useSettings();
|
const { data } = useSettings();
|
||||||
|
|
||||||
const getLocalizedString = useCallback(
|
const getLocalizedString = useCallback(
|
||||||
(key: keyof typeof langEn, lang = data?.language || 'en'): string => {
|
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
||||||
if (lang in translationsList) {
|
if (lang in translationsList) {
|
||||||
if (key in translationsList[lang as keyof typeof translationsList]) {
|
if (key in translationsList[lang as keyof typeof translationsList]) {
|
||||||
return translationsList[lang as keyof typeof translationsList][key];
|
return translationsList[lang as keyof typeof translationsList][key];
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
@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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
padding-bottom: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== 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-color: var(--card-background-color-override, $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, var(--card-background-color-override, $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;
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,142 @@
|
|||||||
|
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 { getOrderedSubscriptions } from './countdown.utils';
|
||||||
|
import CountdownSelect from './CountdownSelect';
|
||||||
|
import CountdownSubscriptions from './CountdownSubscriptions';
|
||||||
|
|
||||||
|
import './Countdown.scss';
|
||||||
|
|
||||||
|
interface CountdownProps {
|
||||||
|
customFields: CustomFields;
|
||||||
|
events: OntimeEvent[];
|
||||||
|
general: ProjectData;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
isMirrored: boolean;
|
||||||
|
selectedId: EntryId | null;
|
||||||
|
settings: Settings | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Countdown({
|
||||||
|
customFields,
|
||||||
|
events,
|
||||||
|
general,
|
||||||
|
time,
|
||||||
|
isMirrored,
|
||||||
|
selectedId,
|
||||||
|
settings,
|
||||||
|
}: CountdownProps) {
|
||||||
|
const { getLocalizedString } = useTranslation();
|
||||||
|
const { subscriptions } = useCountdownOptions();
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useWindowTitle('Countdown');
|
||||||
|
|
||||||
|
// gather rundown data
|
||||||
|
const playableEvents = events.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({ playableEvents, selectedId, subscriptions, time, goToEditMode }: CountdownContentsProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
|
||||||
|
|
||||||
|
if (subscribedEvents.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
|
||||||
|
subscribedEvents={subscribedEvents}
|
||||||
|
selectedId={selectedId}
|
||||||
|
time={time}
|
||||||
|
goToEditMode={goToEditMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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({ events, subscriptions, disableEdit }: CountdownSelectProps) {
|
||||||
|
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 = () => {
|
||||||
|
// we remove events that no longer exist to avoid stale subscriptions
|
||||||
|
const filteredSelected = selected.filter((id) => events.some((event) => event.id === id));
|
||||||
|
const url = makeSubscriptionsUrl(window.location.href, filteredSelected);
|
||||||
|
disableEdit();
|
||||||
|
setSelected([]);
|
||||||
|
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,183 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import Button from '../../common/components/buttons/Button';
|
||||||
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
|
import { useCurrentDay, useRuntimeOffset } from '../../common/hooks/useSocket';
|
||||||
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { throttle } from '../../common/utils/throttle';
|
||||||
|
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||||
|
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 { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
||||||
|
|
||||||
|
import './Countdown.scss';
|
||||||
|
|
||||||
|
interface CountdownSubscriptionsProps {
|
||||||
|
subscribedEvents: OntimeEvent[];
|
||||||
|
selectedId: EntryId | null;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
goToEditMode: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CountdownSubscriptions({
|
||||||
|
time,
|
||||||
|
subscribedEvents,
|
||||||
|
selectedId,
|
||||||
|
goToEditMode,
|
||||||
|
}: CountdownSubscriptionsProps) {
|
||||||
|
const { secondarySource, showProjected } = useCountdownOptions();
|
||||||
|
const showFab = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollToComponent = useFollowComponent({
|
||||||
|
followRef: selectedRef,
|
||||||
|
scrollRef,
|
||||||
|
doFollow: !lockAutoScroll,
|
||||||
|
topOffset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset scroll if nothing is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
if (!lockAutoScroll) {
|
||||||
|
scrollRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedId, lockAutoScroll, scrollRef]);
|
||||||
|
|
||||||
|
// scroll to component if user clicks the Follow button
|
||||||
|
const handleOffset = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
setLockAutoScroll(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// prevent considering automated scrolls as user scrolls
|
||||||
|
const handleUserScroll = () => {
|
||||||
|
if (selectedRef?.current && scrollRef?.current) {
|
||||||
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
if (selectedRect && scrollerRect) {
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||||
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
||||||
|
|
||||||
|
// when the user scrolls we check if we need to show the button
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (timeoutId.current) {
|
||||||
|
clearTimeout(timeoutId.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
throttledHandleScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
|
{subscribedEvents.map((event) => {
|
||||||
|
const secondaryData = getPropertyValue(event, secondarySource);
|
||||||
|
const isLive = getIsLive(event.id, selectedId, time.playback);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={event.id} ref={isLive ? selectedRef : undefined} 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' onClick={goToEditMode}>
|
||||||
|
<IoPencil /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||||
|
</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({ time, event, selectedId }: SubscriptionStatusProps) {
|
||||||
|
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,83 @@
|
|||||||
|
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.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook exposes the backstage view options
|
||||||
|
*/
|
||||||
|
export function useCountdownOptions(): CountdownOptions {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { EntryId, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
|
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 function sanitiseTitle(title: string | null) {
|
||||||
|
return title ?? '{no title}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current event is live
|
||||||
|
*/
|
||||||
|
export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playback: Playback): boolean {
|
||||||
|
return currentId === selectedId && playback !== Playback.Armed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionTimerDisplayOptions = {
|
||||||
|
removeSeconds: true,
|
||||||
|
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 = {
|
||||||
|
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 offsetAndDelay = showProjected ? offset + subscribedEvent.delay : 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 scheduled start
|
||||||
|
return {
|
||||||
|
status: 'due',
|
||||||
|
timer: getFormattedTimer(
|
||||||
|
subscribedEvent.timeStart + offsetAndDelay,
|
||||||
|
TimerType.CountDown,
|
||||||
|
minutesString,
|
||||||
|
subscriptionScheduledTimeDisplayOptions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
|
||||||
|
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, we show a countdown to start
|
||||||
|
if (subscribedEvent.dayOffset > currentDay) {
|
||||||
|
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
|
||||||
|
return {
|
||||||
|
status: 'future',
|
||||||
|
timer: getFormattedTimer(
|
||||||
|
subscribedEvent.timeStart + dayOffset - time.clock - offsetAndDelay,
|
||||||
|
TimerType.CountDown,
|
||||||
|
minutesString,
|
||||||
|
subscriptionTimerDisplayOptions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. event is the before after, show the scheduled end
|
||||||
|
// TODO: get the time from the reporter
|
||||||
|
if (subscribedEvent.dayOffset < currentDay) {
|
||||||
|
return {
|
||||||
|
status: 'done',
|
||||||
|
timer: getFormattedTimer(
|
||||||
|
subscribedEvent.timeEnd,
|
||||||
|
TimerType.CountDown,
|
||||||
|
minutesString,
|
||||||
|
subscriptionScheduledTimeDisplayOptions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. if event is in future, we count to the scheduled start
|
||||||
|
// TODO: get time until
|
||||||
|
if (time.clock < subscribedEvent.timeStart) {
|
||||||
|
return {
|
||||||
|
status: 'future',
|
||||||
|
timer: getFormattedTimer(
|
||||||
|
subscribedEvent.timeStart - time.clock - offsetAndDelay,
|
||||||
|
TimerType.CountDown,
|
||||||
|
minutesString,
|
||||||
|
subscriptionTimerDisplayOptions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. if event has ended, we show the scheduled end
|
||||||
|
// TODO: get the time from the reporter
|
||||||
|
if (time.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 projected start time
|
||||||
|
return {
|
||||||
|
status: 'due',
|
||||||
|
timer: getFormattedTimer(
|
||||||
|
subscribedEvent.timeStart + offsetAndDelay,
|
||||||
|
TimerType.CountDown,
|
||||||
|
minutesString,
|
||||||
|
subscriptionScheduledTimeDisplayOptions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ test.describe('pages routes are available', () => {
|
|||||||
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
||||||
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
||||||
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
|
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
|
||||||
await page.screenshot({ path: 'automated-screenshots/editor.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet', async ({ page }) => {
|
test('cuesheet', async ({ page }) => {
|
||||||
@@ -20,60 +19,54 @@ test.describe('pages routes are available', () => {
|
|||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.getByTestId('cuesheet').click();
|
await page.getByTestId('cuesheet').click();
|
||||||
await page.screenshot({ path: 'automated-screenshots/cuesheet.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator', async ({ page }) => {
|
test('operator', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/op');
|
await page.goto('http://localhost:4001/op');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/operator.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('timer', async ({ page }) => {
|
test('timer', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/timer');
|
await page.goto('http://localhost:4001/timer');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/timer.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clock', async ({ page }) => {
|
test('clock', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/timer');
|
await page.goto('http://localhost:4001/timer');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/clock.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('minimal', async ({ page }) => {
|
test('minimal', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/minimal');
|
await page.goto('http://localhost:4001/minimal');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/minimal.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backstage', async ({ page }) => {
|
test('backstage', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/backstage');
|
await page.goto('http://localhost:4001/backstage');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/backstage.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('studio', async ({ page }) => {
|
test('studio', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/studio');
|
await page.goto('http://localhost:4001/studio');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/studio.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('countdown', async ({ page }) => {
|
test('countdown', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/countdown');
|
await page.goto('http://localhost:4001/countdown');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.screenshot({ path: 'automated-screenshots/countdown.png' });
|
|
||||||
|
|
||||||
await page.getByRole('link', { name: '1. 10:00 → 10:20 | Albania' }).click();
|
await page.getByRole('button', { name: 'Add' }).click();
|
||||||
await page.getByText('Albania').click();
|
await page.getByText('Albania').click();
|
||||||
await page.screenshot({ path: 'automated-screenshots/countdown-2.png' });
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByText('Albania')).toBeVisible();
|
||||||
|
await expect(page.getByText('Latvia')).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,5 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) =>
|
|||||||
|
|
||||||
// make sure preset works
|
// make sure preset works
|
||||||
await page.goto('http://localhost:4001/testing');
|
await page.goto('http://localhost:4001/testing');
|
||||||
await page.getByTestId('countdown__select').click();
|
await expect(page.getByTestId('countdown-view')).toBeVisible();
|
||||||
await expect(page.getByTestId('countdown__select')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user