refactor: lower third (#1507)

* refactor

* separate in and out time

* keep element on screen

* animate on play

* spelling

* explain animation decisions

* guard numbers from url params

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename safeParseNumber

* use view data

* refactor getOptionsFromParams

* rebalance animation timing

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2025-04-20 12:50:16 +02:00
committed by GitHub
parent dce87c3a81
commit 529751578d
4 changed files with 198 additions and 279 deletions
@@ -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<LowerOptions> = {
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<string>();
const animationTimeout = useRef<NodeJS.Timeout>();
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
const [playState, setPlayState] = useState<boolean>(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 (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div
className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
className={`container ${playState ? 'container--in' : 'container--out'}`}
style={{
minWidth: `${options.width}vw`,
transitionDuration: boxDuration,
transitionDelay: boxDelay,
}}
>
<div className='clip'>
<div
className='data-top'
style={{
animationDuration: transition,
transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`,
fontSize: options.topSize,
fontSize: `${options.topSize}em`,
}}
>
{topText}
{textValue.top}
</div>
</div>
<div
@@ -211,13 +130,14 @@ export default function LowerThird(props: LowerProps) {
<div
className='data-bottom'
style={{
animationDuration: transition,
transitionDuration: textDuration,
transitionDelay: textDelay,
color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`,
fontSize: options.bottomSize,
fontSize: `${options.bottomSize}em`,
}}
>
{bottomText}
{textValue.bottom}
</div>
</div>
</div>