diff --git a/apps/client/src/common/utils/safeParseNumber.ts b/apps/client/src/common/utils/safeParseNumber.ts new file mode 100644 index 000000000..c59958cee --- /dev/null +++ b/apps/client/src/common/utils/safeParseNumber.ts @@ -0,0 +1,8 @@ +import { MaybeString } from 'ontime-types'; + +export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number { + if (!value) return defaultValue; + const number = Number(value); + if (isNaN(number)) return defaultValue; + return number; +} diff --git a/apps/client/src/features/viewers/lower-thirds/LowerThird.scss b/apps/client/src/features/viewers/lower-thirds/LowerThird.scss index 7929fa994..6a0b491d7 100644 --- a/apps/client/src/features/viewers/lower-thirds/LowerThird.scss +++ b/apps/client/src/features/viewers/lower-thirds/LowerThird.scss @@ -9,57 +9,9 @@ top: 75vh; line-height: normal; - animation-fill-mode: both; - animation-timing-function: ease-out; - - &--pre { - animation-name: in; - animation-play-state: paused; - - .data-top { - animation-play-state: paused; - animation-name: top-in; - } - - .data-lower { - animation-play-state: paused; - animation-name: bottom-in; - } - } - - &--in { - animation-name: in; - animation-play-state: running; - - .data-top { - animation-play-state: running; - animation-name: top-in; - } - - .data-bottom { - animation-play-state: running; - animation-name: bottom-in; - } - } - - &--out { - animation-name: out; - animation-play-state: running; - - .data-top { - animation-play-state: running; - animation-name: top-out; - } - - .data-bottom { - animation-play-state: running; - animation-name: bottom-out; - } - } - .line { width: 100%; - height: var(--bottomThird-line-height-override, 0.5vh); + height: var(--lowerThird-line-height-override, 0.5vh); } .clip { @@ -70,10 +22,10 @@ .data-top { padding: 0 3vw; font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif; - text-align: var(--lowerThird-text-align-override, left); + text-align: var(--lowerThird-text-align-override, right); white-space: nowrap; @include ellipsis-text(); - + &::after { content: '\200b'; } @@ -85,72 +37,24 @@ } .data-bottom { - font-weight: var(--lowerThird-bottom-font-weight-override, normal); + font-weight: var(--lowerThird-bottom-font-weight-override, 540); font-style: var(--lowerThird-bottom-font-style-override, normal); } + + &--in { + transition-timing-function: cubic-bezier(0.25, 0.5, 0.5, 1); + } + + &--out { + transition-timing-function: cubic-bezier(0.5, 0, 0.75, 0.5); + transform: translateX(-100%); + opacity: 0; + .data-top { + transform: translateY(100%); + } + .data-bottom { + transform: translateY(-100%); + } + } } } - -@keyframes in { - 0% { - transform: translateX(-100%); - opacity: 0%; - } - 50%, - 100% { - transform: translateX(0%); - opacity: 100%; - } -} - -@keyframes out { - 0%, - 30% { - transform: translateX(0%); - opacity: 100%; - } - 100% { - transform: translateX(-100%); - opacity: 0%; - } -} - -@keyframes top-in { - 0%, - 50% { - transform: translateY(100%); - } - 100% { - transform: translateY(0%); - } -} - -@keyframes top-out { - 0% { - transform: translateY(0%); - } - 50%, - 100% { - transform: translateY(100%); - } -} - -@keyframes bottom-in { - 0%, - 50% { - transform: translateY(-100%); - } - 100% { - transform: translateY(0%); - } -} - -@keyframes bottom-out { - 0% { - transform: translateY(0%); - } - 50%, - 100% { - transform: translateY(-100%); - } -} diff --git a/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx b/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx index d73fbaca8..ccbbd35f7 100644 --- a/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx +++ b/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx @@ -1,204 +1,123 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types'; +import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils'; import { overrideStylesURL } from '../../../common/api/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; +import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { getPropertyValue } from '../common/viewUtils'; -import { getLowerThirdOptions } from './lowerThird.options'; +import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options'; import './LowerThird.scss'; -type LowerOptions = { - width: number; - topSrc: string; - bottomSrc: string; - topColour: string; - bottomColour: string; - topBg: string; - bottomBg: string; - topSize: string; - bottomSize: string; - transition: number; - delay: number; - key: string; - lineColour: string; - lineHeight: string; -}; - interface LowerProps { customFields: CustomFields; eventNow: OntimeEvent | null; viewSettings: ViewSettings; + time: ViewExtendedTimer; } -const defaultOptions: Readonly = { - width: 45, - topSrc: 'title', - bottomSrc: 'lowerMsg', - topColour: '000000', - bottomColour: '000000', - topBg: 'FFF0', - bottomBg: 'FFF0', - topSize: '65px', - bottomSize: '40px', - transition: 3, - delay: 3, - key: 'FFF0', - lineColour: 'FF0000', - lineHeight: '0.4em', -}; - export default function LowerThird(props: LowerProps) { - const { customFields, eventNow, viewSettings } = props; - const [searchParams] = useSearchParams(); + const { customFields, eventNow, viewSettings, time } = props; const previousId = useRef(); const animationTimeout = useRef(); - const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre'); + const [playState, setPlayState] = useState(false); + const [textValue, setTextValue] = useState<{ top: string; bottom: string }>({ top: '', bottom: '' }); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const options = useLowerOptions(); + const { playback } = time; useWindowTitle('Lower Third'); - const options = useMemo(() => { - const newOptions = { ...defaultOptions }; - - const width = searchParams.get('width'); - if (width !== null) { - newOptions.width = Number(width); - } - - const topSrc = searchParams.get('top-src'); - if (topSrc) { - newOptions.topSrc = topSrc; - } - - const bottomSrc = searchParams.get('bottom-src'); - if (bottomSrc) { - newOptions.bottomSrc = bottomSrc; - } - - const topColour = searchParams.get('top-colour'); - if (topColour !== null) { - newOptions.topColour = topColour; - } - - const bottomColour = searchParams.get('bottom-colour'); - if (bottomColour !== null) { - newOptions.bottomColour = bottomColour; - } - - const topBg = searchParams.get('top-bg'); - if (topBg !== null) { - newOptions.topBg = topBg; - } - - const bottomBg = searchParams.get('bottom-bg'); - if (bottomBg !== null) { - newOptions.bottomBg = bottomBg; - } - - const topSize = searchParams.get('top-size'); - if (topSize !== null) { - newOptions.topSize = topSize; - } - - const bottomSize = searchParams.get('bottom-size'); - if (bottomSize && bottomSize != newOptions.bottomSize) { - newOptions.bottomSize = bottomSize; - } - - const transition = searchParams.get('transition'); - if (transition !== null) { - newOptions.transition = Number(transition); - } - - const delay = searchParams.get('delay'); - if (delay !== null) { - newOptions.delay = Number(delay); - } - - const key = searchParams.get('key'); - if (key !== null) { - newOptions.key = key; - } - - const lineColour = searchParams.get('line-colour'); - if (lineColour !== null) { - newOptions.lineColour = lineColour; - } - - return newOptions; - }, [searchParams]); - // on unmount, cancel any ongoing animations useEffect(() => { - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps + // if hold is negative then force animate in + if (options.hold < 0) { clearTimeout(animationTimeout.current); - }; - }, []); + setTextValue({ + top: getPropertyValue(eventNow, options.topSrc) ?? '', + bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '', + }); + setPlayState(true); + return; + } + }, [eventNow, options.bottomSrc, options.hold, options.topSrc]); + + const animateIn = useCallback(() => { + // if hold skip + if (options.hold < 0) return; + + //clear any pending timeouts + clearTimeout(animationTimeout.current); + // set the values + setTextValue({ + top: getPropertyValue(eventNow, options.topSrc) ?? '', + bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '', + }); + // start animation + setPlayState(true); + // reschedule out animation, should animate out after the in animation time + hold time + setTimeout(() => setPlayState(false), (options.hold + options.transitionIn) * MILLIS_PER_SECOND); + }, [eventNow, options.bottomSrc, options.hold, options.topSrc, options.transitionIn]); + + const animateOut = useCallback(() => { + if (options.hold < 0) return; // if hold is negative then we never animate out + //clear any pending timeouts + clearTimeout(animationTimeout.current); + // start animation + setPlayState(false); + }, [options.hold]); + + // check if playback has changed and schedule animations + useEffect(() => { + if (isPlaybackActive(playback)) { + animateIn(); + } else { + animateOut(); + } + }, [animateIn, animateOut, playback]); // check if data has changed and schedule animations useEffect(() => { const hasChanged = eventNow?.id !== previousId.current; - if (!hasChanged) { - return; + if (hasChanged) { + previousId.current = eventNow?.id; + if (eventNow?.id) animateIn(); } + }, [animateIn, eventNow?.id]); - previousId.current = eventNow?.id; - const animateOutInMs = options.delay * 1000 + options.transition * 1000; + const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`; + const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`; - const reschedule = (newState: 'pre' | 'in' | 'out') => { - clearTimeout(animationTimeout.current); - animationTimeout.current = setTimeout(() => setPlayState(newState), animateOutInMs); - }; - if (eventNow?.id == null) { - setPlayState('out'); - reschedule('pre'); - return; - } - - if (eventNow.id && !previousId.current) { - setPlayState('in'); - reschedule('out'); - return; - } - - if (playState === 'in') { - // event has changed, we just reschedule the timeout - reschedule('out'); - return; - } - setPlayState('in'); - reschedule('out'); - }, [eventNow?.id, options.delay, options.transition, playState, previousId]); - - const topText = getPropertyValue(eventNow, options.topSrc) ?? ''; - const bottomText = getPropertyValue(eventNow, options.bottomSrc) ?? ''; - - const transition = `${options.transition}s`; + const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`; + const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s'; return (
- {topText} + {textValue.top}
- {bottomText} + {textValue.bottom}
diff --git a/apps/client/src/features/viewers/lower-thirds/lowerThird.options.ts b/apps/client/src/features/viewers/lower-thirds/lowerThird.options.ts index 97bc5d0d0..fd3445cfa 100644 --- a/apps/client/src/features/viewers/lower-thirds/lowerThird.options.ts +++ b/apps/client/src/features/viewers/lower-thirds/lowerThird.options.ts @@ -1,7 +1,10 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { CustomFields } from 'ontime-types'; import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants'; import { ViewOption } from '../../../common/components/view-params-editor/types'; +import safeParseNumber from '../../../common/utils/safeParseNumber'; export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => { const topSourceOptions = makeOptionsFromCustomFields(customFields, { @@ -44,18 +47,32 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] = collapsible: true, options: [ { - id: 'transition', - title: 'Transition', - description: 'Transition in time in seconds (default 3)', + id: 'transition-in', + title: 'Transition In', + description: 'Transition in time (default 3 seconds)', + type: 'number', + placeholder: '3 (default)', + }, + { + id: 'transition-out', + title: 'Transition Out', + description: 'Transition out time (default 3 seconds)', + type: 'number', + placeholder: '3 (default)', + }, + { + id: 'hold', + title: 'Hold', + description: 'Time on screen before transition out. Set to -1 to stop transition (default 3 seconds) ', type: 'number', placeholder: '3 (default)', }, { id: 'delay', title: 'Delay', - description: 'Delay between transition in and out in seconds (default 3)', + description: 'Delay between trigger and transition in (default 0 seconds)', type: 'number', - placeholder: '3 (default)', + placeholder: '0 (default)', }, ], }, @@ -69,14 +86,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] = title: 'Top Text Size', description: 'Font size of the top text', type: 'string', - placeholder: '65px', + placeholder: '5em', }, { id: 'bottom-size', title: 'Bottom Text Size', description: 'Font size of the bottom text', type: 'string', - placeholder: '64px', + placeholder: '4em', }, { id: 'width', @@ -132,3 +149,73 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] = }, ]; }; + +type LowerOptions = { + width: number; + topSrc: string; + bottomSrc: string; + topColour: string; + bottomColour: string; + topBg: string; + bottomBg: string; + topSize: number; + bottomSize: number; + transitionIn: number; + transitionOut: number; + hold: number; + delay: number; + key: string; + lineColour: string; +}; + +const defaultOptions: Readonly = { + width: 45, + topSrc: 'title', + bottomSrc: 'lowerMsg', + topColour: '000000', + bottomColour: '000000', + topBg: 'FFF0', + bottomBg: 'FFF0', + topSize: 5, + bottomSize: 4, + transitionIn: 3, + transitionOut: 3, + hold: 3, + delay: 0, + key: 'FFF0', + lineColour: 'FF0000', +}; + +/** + * Utility extract the view options from URL Params + * the names and fallbacks are manually matched with defaultOptions + */ +function getOptionsFromParams(searchParams: URLSearchParams): LowerOptions { + // we manually make an object that matches the key above + return { + width: safeParseNumber(searchParams.get('width'), defaultOptions.width), + topSrc: searchParams.get('top-src') ?? defaultOptions.topSrc, + bottomSrc: searchParams.get('bottom-src') ?? defaultOptions.bottomSrc, + topColour: searchParams.get('top-colour') ?? defaultOptions.topColour, + bottomColour: searchParams.get('bottom-colour') ?? defaultOptions.bottomColour, + topBg: searchParams.get('top-bg') ?? defaultOptions.topBg, + bottomBg: searchParams.get('bottom-bg') ?? defaultOptions.bottomBg, + topSize: safeParseNumber(searchParams.get('top-size'), defaultOptions.topSize), + bottomSize: safeParseNumber(searchParams.get('bottom-size'), defaultOptions.bottomSize), + transitionIn: safeParseNumber(searchParams.get('transition-in'), defaultOptions.transitionIn), + transitionOut: safeParseNumber(searchParams.get('transition-out'), defaultOptions.transitionOut), + hold: safeParseNumber(searchParams.get('hold'), defaultOptions.hold), + delay: safeParseNumber(searchParams.get('hold'), defaultOptions.delay), + key: searchParams.get('key') ?? defaultOptions.key, + lineColour: searchParams.get('line-colour') ?? defaultOptions.lineColour, + }; +} + +/** + * Hook exposes the timer view options + */ +export function useLowerOptions(): LowerOptions { + const [searchParams] = useSearchParams(); + const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + return options; +}