diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 7b747d639..6db7ca7b5 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -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')); diff --git a/apps/client/src/common/components/buttons/Button.module.scss b/apps/client/src/common/components/buttons/Button.module.scss index e81e1d854..d8f2d8cf3 100644 --- a/apps/client/src/common/components/buttons/Button.module.scss +++ b/apps/client/src/common/components/buttons/Button.module.scss @@ -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; +} diff --git a/apps/client/src/common/components/buttons/Button.tsx b/apps/client/src/common/components/buttons/Button.tsx index 9ef2fd196..9a09a1889 100644 --- a/apps/client/src/common/components/buttons/Button.tsx +++ b/apps/client/src/common/components/buttons/Button.tsx @@ -6,13 +6,14 @@ import style from './Button.module.scss'; interface ButtonProps extends ButtonHTMLAttributes { 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 ( - ); diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 973c8b0b8..333faeb4d 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -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, })); diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss index 14c7d0e64..b65900133 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss @@ -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; } } diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index 21287135c..a1fb73271 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -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; diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx index 0fd9676a4..2cd58b629 100644 --- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx +++ b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx @@ -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'; diff --git a/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.js b/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.js index b87f3fed4..c0cc38578 100644 --- a/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.js +++ b/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.js @@ -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); }); diff --git a/apps/client/src/features/viewers/countdown/countdown.helpers.ts b/apps/client/src/features/viewers/countdown/countdown.helpers.ts deleted file mode 100644 index e19b8caf1..000000000 --- a/apps/client/src/features/viewers/countdown/countdown.helpers.ts +++ /dev/null @@ -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')); -} diff --git a/apps/client/src/translation/TranslationProvider.tsx b/apps/client/src/translation/TranslationProvider.tsx index ca0d471ad..2a2d7c3bb 100644 --- a/apps/client/src/translation/TranslationProvider.tsx +++ b/apps/client/src/translation/TranslationProvider.tsx @@ -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({ @@ -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]; diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss new file mode 100644 index 000000000..ffabb2276 --- /dev/null +++ b/apps/client/src/views/countdown/Countdown.scss @@ -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; + } +} diff --git a/apps/client/src/views/countdown/Countdown.tsx b/apps/client/src/views/countdown/Countdown.tsx new file mode 100644 index 000000000..3d22e88d9 --- /dev/null +++ b/apps/client/src/views/countdown/Countdown.tsx @@ -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 ( +
+ +
+ {general?.projectLogo && } +
{general.title}
+
+
{getLocalizedString('common.time_now')}
+ +
+
+ + {!hasEvents && } + + {hasEvents && editMode && ( + setEditMode(false)} /> + )} + + {hasEvents && !editMode && ( + setEditMode(true)} + selectedId={selectedId} + /> + )} +
+ ); +} + +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 ( +
+ + +
+ ); + } + + return ( + + ); +} diff --git a/apps/client/src/views/countdown/CountdownSelect.tsx b/apps/client/src/views/countdown/CountdownSelect.tsx new file mode 100644 index 000000000..d59bbb5d9 --- /dev/null +++ b/apps/client/src/views/countdown/CountdownSelect.tsx @@ -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(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 ( +
+ {events.map((event: OntimeEvent, index: number) => { + const title = event.title || '{no title}'; + const isSelected = selectedIds.has(event.id); + + return ( +
toggleSelect(event.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + toggleSelect(event.id); + e.stopPropagation(); + } + }} + className={cx(['sub', isSelected && 'sub--selected'])} + > +
+
+ + → + +
+
{isSelected ? 'Click to remove' : 'Click to add'}
+
{title}
+
+ ); + })} + +
+ + + +
+
+ ); +} diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx new file mode 100644 index 000000000..35ae1948d --- /dev/null +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -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 ( +
+ {subscribedEvents.map((event) => { + const secondaryData = getPropertyValue(event, secondarySource); + const isLive = event.id === selectedId && time.playback !== Playback.Armed; + + return ( +
+
+
0 && 'sub__schedule--delayed'])}> + {showProjected ? ( + + ) : ( + <> + + → + + + )} +
+ +
{sanitiseTitle(event.title)}
+ {secondaryData &&
{secondaryData}
} +
+ ); + })} +
+ +
+
+ ); +} + +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 ( + <> + + → + + + ); +} + +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 ( + <> +
{getLocalizedString(timerProgress[status])}
+
{timer}
+ + ); +} diff --git a/apps/client/src/views/countdown/countdown.options.ts b/apps/client/src/views/countdown/countdown.options.ts new file mode 100644 index 000000000..d9e96a038 --- /dev/null +++ b/apps/client/src/views/countdown/countdown.options.ts @@ -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; +} diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts new file mode 100644 index 000000000..38e537a30 --- /dev/null +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -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; +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': } + 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': } + 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; +}