mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 00:13:53 +00:00
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:
committed by
GitHub
parent
655e9e8fb5
commit
64573aff8a
@@ -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);
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user