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/follow-button/FollowButton.module.scss b/apps/client/src/features/operator/follow-button/FollowButton.module.scss index c5167dd27..f8534e3cd 100644 --- a/apps/client/src/features/operator/follow-button/FollowButton.module.scss +++ b/apps/client/src/features/operator/follow-button/FollowButton.module.scss @@ -14,15 +14,16 @@ color: #fff; font-size: 1rem; border-radius: 99px; - transition: bottom 1s; + // transition in + transition: bottom 0.3s; &:active { - transition: background-color $transition-time-action; background-color: $blue-900; } } .hidden { + // transition out transition: bottom 1s; bottom: -50px; } 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/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx index 4058c92a6..8a9893b50 100644 --- a/apps/client/src/features/viewers/ViewWrapper.tsx +++ b/apps/client/src/features/viewers/ViewWrapper.tsx @@ -57,8 +57,7 @@ const withData =

(Component: ComponentType

) => { const { data: customFields } = useCustomFields(); // websocket data - const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } = - useStore(runtimeStore); + const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } = useStore(runtimeStore); const selectedId = eventNow?.id ?? null; const nextId = eventNext?.id ?? null; diff --git a/apps/client/src/features/viewers/countdown/Countdown.scss b/apps/client/src/features/viewers/countdown/Countdown.scss deleted file mode 100644 index b1dfa9be9..000000000 --- a/apps/client/src/features/viewers/countdown/Countdown.scss +++ /dev/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); - } - } -} diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx deleted file mode 100644 index 8951b985c..000000000 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ /dev/null @@ -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(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 ( -

- {general?.projectLogo && } - - {follow === null ? ( - - ) : ( -
-
-
{getLocalizedString('common.time_now')}
- -
- - {runningMessage !== TimerMessage.unhandled && ( -
{getLocalizedString(`countdown.${runningMessage}`)}
- )} - - - {follow?.title &&
{follow.title}
} - -
- {projectedStart && projectedEnd && ( -
-
{getLocalizedString('common.projected_start')}
- -
- )} - {projectedStart && projectedEnd && ( -
-
{getLocalizedString('common.projected_end')}
- -
- )} -
-
{getLocalizedString('common.scheduled_start')}
- -
-
-
{getLocalizedString('common.scheduled_end')}
- -
-
-
- )} -
- ); -} diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx deleted file mode 100644 index 0fd9676a4..000000000 --- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx +++ /dev/null @@ -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 ( -
- {getLocalizedString('countdown.select_event')} - -
- ); -} diff --git a/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.ts b/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.ts deleted file mode 100644 index f1e93d163..000000000 --- a/apps/client/src/features/viewers/countdown/__tests__/Countdown.test.ts +++ /dev/null @@ -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); - }); -}); 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/features/viewers/countdown/countdown.options.ts b/apps/client/src/features/viewers/countdown/countdown.options.ts deleted file mode 100644 index 5c8c8b2d4..000000000 --- a/apps/client/src/features/viewers/countdown/countdown.options.ts +++ /dev/null @@ -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)] : []), - ], - }, -]; 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..637f1fc3a --- /dev/null +++ b/apps/client/src/views/countdown/Countdown.scss @@ -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; + } +} diff --git a/apps/client/src/views/countdown/Countdown.tsx b/apps/client/src/views/countdown/Countdown.tsx new file mode 100644 index 000000000..4cacc18e4 --- /dev/null +++ b/apps/client/src/views/countdown/Countdown.tsx @@ -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 ( +
+ +
+ {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({ playableEvents, selectedId, subscriptions, time, goToEditMode }: CountdownContentsProps) { + const { getLocalizedString } = useTranslation(); + + if (subscriptions.length === 0) { + return ( +
+ + +
+ ); + } + + const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents); + + if (subscribedEvents.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..192d80fb9 --- /dev/null +++ b/apps/client/src/views/countdown/CountdownSelect.tsx @@ -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(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 ( +
+ {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..4b4fe74cf --- /dev/null +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -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(null); + const [lockAutoScroll, setLockAutoScroll] = useState(false); + const selectedRef = useRef(null); + const scrollRef = useRef(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 ( +
+ {subscribedEvents.map((event) => { + const secondaryData = getPropertyValue(event, secondarySource); + const isLive = getIsLive(event.id, selectedId, time.playback); + + 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({ 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 ( + <> +
{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..8a9f2d4a3 --- /dev/null +++ b/apps/client/src/views/countdown/countdown.options.ts @@ -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; +} 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..b9f3331a7 --- /dev/null +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -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; +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': } + 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': }, 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; +} diff --git a/e2e/tests/001-smoke-tests.spec.ts b/e2e/tests/001-smoke-tests.spec.ts index c83d0c2fe..2dff5e4a1 100644 --- a/e2e/tests/001-smoke-tests.spec.ts +++ b/e2e/tests/001-smoke-tests.spec.ts @@ -12,7 +12,6 @@ test.describe('pages routes are available', () => { await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-messages-control')).toBeVisible(); - await page.screenshot({ path: 'automated-screenshots/editor.png' }); }); test('cuesheet', async ({ page }) => { @@ -20,60 +19,54 @@ test.describe('pages routes are available', () => { await expect(page).toHaveTitle(/ontime/); await page.getByTestId('cuesheet').click(); - await page.screenshot({ path: 'automated-screenshots/cuesheet.png' }); }); test('operator', async ({ page }) => { await page.goto('http://localhost:4001/op'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/operator.png' }); }); test('timer', async ({ page }) => { await page.goto('http://localhost:4001/timer'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/timer.png' }); }); test('clock', async ({ page }) => { await page.goto('http://localhost:4001/timer'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/clock.png' }); }); test('minimal', async ({ page }) => { await page.goto('http://localhost:4001/minimal'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/minimal.png' }); }); test('backstage', async ({ page }) => { await page.goto('http://localhost:4001/backstage'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/backstage.png' }); }); test('studio', async ({ page }) => { await page.goto('http://localhost:4001/studio'); await expect(page).toHaveTitle(/ontime/); - await page.screenshot({ path: 'automated-screenshots/studio.png' }); }); test('countdown', async ({ page }) => { await page.goto('http://localhost:4001/countdown'); 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.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(); }); }); diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 3ff3e076d..ba86fb054 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -23,6 +23,5 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) => // make sure preset works await page.goto('http://localhost:4001/testing'); - await page.getByTestId('countdown__select').click(); - await expect(page.getByTestId('countdown__select')).toBeVisible(); + await expect(page.getByTestId('countdown-view')).toBeVisible(); });