refactor: update lower third (#665)

* init

* auto in out

* fix in out animation and other options

* move some constants to css override

* dont show block if text source is empty

* select trigger

* adjust the animation

* remove the old one and replace it in the router

* return null from useEffect when there is no timer to clear

* typo: manuel -> manual

* fix: delay is excluding transition in time

* rename to top and bottom

* change font size to px and increese the default

* sharper animation timning

* fix: dont need state for options

* fix: e2e test for lower third

* deafault to ffffff00 key color

* typo: scss naming

* style: all white default bg key

* style: add default values to src options

* style: `white-space: nowrap;`

* style: start out animation a bit early

* style: browser tab title

* fix: checking the correct default values in ViewParamsEditor

* fix: defaultValues save improved by @asharonbaltazar

* lint: capitalize enum names

* fix: memoize options

* fix: searchParams test

* fix: readability of `topText` and `bottomText` and memoize them

* fix: enum naming upper case

* refactor: remove cx import

* refactor: try to simplify is else flow
This commit is contained in:
Alex Christoffer Rasmussen
2024-01-07 23:23:25 +01:00
committed by GitHub
parent 655e9e8fb5
commit 64573aff8a
10 changed files with 498 additions and 416 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView));
@@ -23,14 +23,14 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
return { ...acc, [id]: String(defaultValue) };
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
if (defaultValues[id] === value) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -233,13 +233,97 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
export const LOWER_THIRD_OPTIONS: ParamField[] = [
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the text source for the bottom element',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'subtitle',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
placeholder: '1 (default)',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
@@ -249,35 +333,27 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
placeholder: 'ffffffff (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
@@ -1,67 +0,0 @@
.lower-third.lines {
margin: 0;
box-sizing: border-box;
overflow: hidden;
width: 100%;
height: 100%;
color: #fffffa;
font-size: 4vh;
background-color: white;
.lower-container {
position: absolute;
top: 75vh;
background-color: #0003;
padding: 1vh 1vw 1vh 0;
display: flex;
flex-direction: column;
width: 45vw;
border-radius: 0 8px 8px 0;
}
.message-container {
position: absolute;
bottom: 2vh;
width: 100%;
text-align: center;
background-color: #0003;
.message {
font-size: 3.5vh;
}
}
.title-container,
.subtitle-container {
display: grid;
grid-template-columns: auto max-content;
grid-template-areas: 'decor text';
align-items: center;
position: relative;
margin-bottom: 1vh;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-decor,
.sub-decor {
grid-area: decor;
height: 4vh;
width: 100%;
background-color: #ff6969;
background: linear-gradient(
90deg,
rgb(255 105 105 / 100%) 0%,
rgb(255 132 132 / 100%) 100%
);
}
.title,
.subtitle {
grid-area: text;
padding-left: 1vw;
justify-self: right;
width: fit-content;
}
}
@@ -1,180 +0,0 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Message } from 'ontime-types';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { LowerOptions } from './LowerWrapper';
import './LowerLines.scss';
interface LowerLinesProps {
lower: Message;
heading: string;
subheading: string;
options: LowerOptions;
doShow: boolean;
}
export default function LowerLines(props: LowerLinesProps) {
const { lower, heading, subheading, options, doShow } = props;
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
useEffect(() => {
if (!options.fadeOut) return;
// Calculate time
const fadeOutTime = (options.fadeOut + options.transitionIn) * 1000;
const timeout = setTimeout(() => {
setShowLower(false);
}, fadeOutTime);
return () => clearTimeout(timeout);
}, [options.fadeOut, options.transitionIn]);
useEffect(() => {
setShowLower(doShow);
}, [doShow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
// motion
// transition segments
const t = options.transitionIn;
const eight = t / 8;
const quarter = t / 4;
const third = t / 3;
const half = t / 2;
const lowerThirdVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: third,
},
},
exit: {
opacity: 0,
x: -1000,
transition: {
duration: third,
},
},
};
const titleContainerVariants = {
hidden: {
left: -1000,
},
visible: {
left: 0,
transition: {
duration: third,
},
},
};
const titleVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
delay: quarter,
duration: half,
},
},
};
const subtitleContainerVariants = {
hidden: {
left: -1000,
},
visible: {
left: 0,
transition: {
delay: eight,
duration: third,
},
},
};
const subtitleVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
delay: third,
duration: third * 2,
},
},
};
const sizeMultiplier = (options.size || 1) * 4;
return (
<div
className='lower-third lines'
style={{
backgroundColor: options.keyColour,
color: options.textColour,
fontSize: `${sizeMultiplier}vh`,
}}
>
<NavigationMenu />
<ViewParamsEditor paramFields={LOWER_THIRDS_OPTIONS} />
<AnimatePresence>
{showLower && (
<motion.div
className='lower-container'
style={{ backgroundColor: options.bgColour }}
variants={lowerThirdVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<motion.div className='title-container' variants={titleContainerVariants}>
<motion.div className='title' variants={titleVariants}>
{heading}
</motion.div>
<div className='title-decor' />
</motion.div>
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
<div className='sub-decor' />
<motion.div className='subtitle' variants={subtitleVariants}>
{subheading}
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showLowerMessage && (
<motion.div
className='message-container'
key='modal'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div className='message'>{lower.text}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,139 @@
.lower-third {
margin: 0;
width: 100%;
height: 100%;
.container {
position: absolute;
display: block;
top: 75vh;
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);
}
.clip {
overflow: hidden;
}
.data-bottom,
.data-top {
padding-left: 3vw;
padding-right: 3vw;
font-family: var(--lowerThird-font-family-override, 'Arial');
text-align: var(--lowerThird-text-align-override, left);
white-space: nowrap;
}
.data-top {
font-weight: var(--lowerThird-top-font-weight-override, bold);
font-style: var(--lowerThird-top-font-style-override, normal);
}
.data-bottom {
font-weight: var(--lowerThird-bottom-font-weight-override, normal);
font-style: var(--lowerThird-bottom-font-style-override, normal);
}
}
}
@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%);
}
}
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRD_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import './LowerThird.scss';
enum SrcKeys {
Title = 'title',
Subtitle = 'subtitle',
Presenter = 'presenter',
LowerMsg = 'lowerMsg',
}
enum TriggerType {
Event = 'event',
Manual = 'manual',
}
type LowerOptions = {
width: number;
trigger: TriggerType;
topSrc: SrcKeys;
bottomSrc: SrcKeys;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: string;
bottomSize: string;
transition: number;
delay: number;
key: string;
lineColour: string;
lineHeight: string;
};
interface LowerProps {
eventNow: OntimeEvent | null;
viewSettings: ViewSettings;
lower: Message;
}
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
trigger: TriggerType.Event,
topSrc: SrcKeys.Title,
bottomSrc: SrcKeys.Subtitle,
topColour: '000000ff',
bottomColour: '000000ff',
topBg: '00000000',
bottomBg: '00000000',
topSize: '65px',
bottomSize: '40px',
transition: 3,
delay: 3,
key: 'ffffffff',
lineColour: 'ff0000ff',
lineHeight: '0.4em',
};
export default function LowerThird(props: LowerProps) {
const { eventNow, lower, viewSettings } = props;
const [searchParams] = useSearchParams();
const options = useMemo(() => {
const newOptions = { ...defaultOptions };
const width = searchParams.get('width');
if (width !== null) {
newOptions.width = Number(width);
}
const trigger = Object.values(TriggerType).find((s) => s === searchParams.get('trigger'));
if (trigger) {
newOptions.trigger = trigger;
}
const topSrc = Object.values(SrcKeys).find((s) => s === searchParams.get('top-src'));
if (topSrc) {
newOptions.topSrc = topSrc;
}
const bottomSrc = Object.values(SrcKeys).find((s) => s === 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]);
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
useEffect(() => {
document.title = 'ontime - Lower Third';
}, []);
const topText = useMemo(() => {
if (options.topSrc === SrcKeys.LowerMsg) {
return lower.text;
} else if (eventNow) {
return eventNow[options.topSrc];
}
return '';
}, [eventNow, lower.text, options]);
const bottomText = useMemo(() => {
if (options.bottomSrc === SrcKeys.LowerMsg) {
return lower.text;
} else if (eventNow) {
return eventNow[options.bottomSrc];
}
return '';
}, [eventNow, lower.text, options]);
const transition = `${options.transition}s`;
const trigger = useMemo(() => {
if (options.trigger === TriggerType.Event) {
return eventNow?.id;
} else if (options.trigger === TriggerType.Manual) {
return lower.visible;
}
return false;
}, [eventNow?.id, lower.visible, options.trigger]);
useEffect(() => {
if (options.trigger === TriggerType.Event && trigger) {
setPlayState('in');
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
const timeout = setTimeout(() => {
setPlayState('out');
}, animateOutInMs);
return () => clearTimeout(timeout);
} else if (options.trigger === TriggerType.Manual) {
setPlayState(trigger ? 'in' : 'out');
} else {
setPlayState('pre');
}
return () => null;
}, [options.delay, options.transition, options.trigger, trigger]);
return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<NavigationMenu />
<ViewParamsEditor paramFields={LOWER_THIRD_OPTIONS} />
<div
className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
>
<div className='clip'>
<div
className='data-top'
style={{
animationDuration: transition,
color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`,
fontSize: options.topSize,
}}
>
{topText}
</div>
</div>
<div
className='line'
style={{
backgroundColor: `#${options.lineColour}`,
}}
/>
<div className='clip'>
<div
className='data-bottom'
style={{
animationDuration: transition,
color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`,
fontSize: options.bottomSize,
}}
>
{bottomText}
</div>
</div>
</div>
</div>
);
}
@@ -1,137 +0,0 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import LowerLines from './LowerLines';
export type LowerOptions = {
size: number;
transitionIn: number;
textColour: string;
bgColour: string;
keyColour?: string;
fadeOut: number;
};
interface LowerProps {
eventNow: OntimeEvent | null;
lower: Message;
viewSettings: ViewSettings;
}
// prevent triggering animation without a content change
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props: LowerProps) => {
const { eventNow, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [heading, setHeading] = useState('');
const [subheading, setSubheading] = useState('');
const [showLower, setShowLower] = useState(false);
// Set window title
useEffect(() => {
document.title = 'ontime - Lower Thirds';
}, []);
// reload if data changes
useEffect(() => {
// clear titles if necessary
// will trigger an animation out in the component
let timeout: NodeJS.Timeout;
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
// we have new titles
if (haveTitlesChanged && !areTitlesEmpty) {
// show lower
setHeading(eventNow?.title ?? '');
setSubheading(eventNow?.presenter ?? '');
setShowLower(true);
// schedule transition out
const transitionTime = 5000;
timeout = setTimeout(() => {
setShowLower(false);
}, transitionTime);
}
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line -- we do this to keep animations
}, [eventNow?.title, eventNow?.presenter]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const options: LowerOptions = {
size: 1,
transitionIn: 3,
textColour: '#fffffa',
bgColour: '#00000033',
fadeOut: 3,
};
// size: multiplier
// Should be a number 0.0-n
const _size = searchParams.get('size');
if (_size) {
const parsedValue = Number(_size);
if (!Number.isNaN(parsedValue)) {
options.size = parsedValue;
}
}
// transitionIn: seconds
// Should be a number 0-n
const _transitionIn = searchParams.get('transition');
if (_transitionIn) {
const parsedValue = Number(_transitionIn);
if (!Number.isNaN(parsedValue)) {
options.transitionIn = parsedValue;
}
}
// textColour: string
// Should be a hex string '#ffffff'
const _textColour = searchParams.get('text');
if (_textColour) options.textColour = `#${_textColour}`;
// bgColour: string
// Should be a hex string '#ffffff'
const _bgColour = searchParams.get('bg');
if (_bgColour) options.bgColour = `#${_bgColour}`;
// key: string
// Should be a hex string '#00FF00' with key colour
const _key = searchParams.get('key');
if (_key) options.keyColour = `#${_key}`;
// fadeOut: seconds
// Should be a number 0-n
const _fadeOut = searchParams.get('fadeout');
if (_fadeOut) {
const parsedValue = Number(_transitionIn);
if (!Number.isNaN(parsedValue)) {
options.fadeOut = parsedValue;
}
}
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
};
export default memo(Lower, areEqual);
+8
View File
@@ -24,6 +24,14 @@
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
--lowerThird-font-family-override: 'Times New Roman';
--lowerThird-top-font-weight-override: bold;
--lowerThird-bottom-font-weight-override: bold;
--lowerThird-top-font-style-override: normal;
--lowerThird-bottom-font-style-override: italic;
--lowerThird-line-height-override: 1vh;
--lowerThird-text-align-override: center;
}
.timer {
@@ -20,7 +20,7 @@ test('message control sends messages to screens', async ({ context }) => {
await editorPage.getByPlaceholder('Shown in lower third').fill('testing lower');
await editorPage.getByRole('button', { name: /toggle lower third message/i }).click();
await featurePage.goto('http://localhost:4001/lower');
await featurePage.goto('http://localhost:4001/lower?trigger=manual&bottom-src=lowerMsg');
await featurePage.waitForLoadState('load', { timeout: 5000 });
await featurePage.getByText('testing lower').click({ timeout: 5000 });