V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
@@ -0,0 +1,145 @@
/* eslint-disable react/display-name */
import { useEffect, useMemo, useState } from 'react';
import { useMessageControl } from '../../common/hooks/useSocket';
import useSubscription from '../../common/hooks/useSubscription';
import useEvent from '../../common/hooks-query/useEvent';
import useRundown from '../../common/hooks-query/useRundown';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import socket from '../../common/utils/socket';
const withSocket = (Component) => {
return (props) => {
const { data: eventsData } = useRundown();
const { data: genData } = useEvent();
const { data: viewSettings } = useViewSettings();
const { data: messages } = useMessageControl();
const [publicSelectedId, setPublicSelectedId] = useState(null);
const [timer] = useSubscription('ontime-timer', {
clock: null,
current: null,
elapsed: null ,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
});
const [titles] = useSubscription('titles', {
titleNow: '',
subtitleNow: '',
presenterNow: '',
titleNext: '',
subtitleNext: '',
presenterNext: '',
});
const [publicTitles] = useSubscription('publictitles', {
titleNow: '',
subtitleNow: '',
presenterNow: '',
titleNext: '',
subtitleNext: '',
presenterNext: '',
});
const [selectedId] = useSubscription('selected-id', null);
const [nextId] = useSubscription('next-id', null);
const [playback] = useSubscription('playback', null);
// Ask for update on load
useEffect(() => {
// todo: remove
socket.on('publicselected-id', (data) => {
setPublicSelectedId(data);
});
}, []);
const publicEvents = useMemo(() => {
if (Array.isArray(eventsData)) {
return eventsData.filter((d) => d.type === 'event' && d.title !== '' && d.isPublic);
}
return [];
}, [eventsData]);
/********************************************/
/*** + titleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showNow = true;
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
// is there a next field?
let showNext = true;
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
const titleManager = { ...titles, showNow: showNow, showNext: showNext };
/********************************************/
/*** + publicTitleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showPublicNow = true;
if (!publicTitles.titleNow && !publicTitles.subtitleNow && !publicTitles.presenterNow)
showPublicNow = false;
// is there a next field?
let showPublicNext = true;
if (!publicTitles.titleNext && !publicTitles.subtitleNext && !publicTitles.presenterNext)
showPublicNext = false;
const publicTitleManager = {
...publicTitles,
showNow: showPublicNow,
showNext: showPublicNext,
};
/******************************************/
/*** + TimeManagerType ***/
/*** WRAP INFORMATION RELATED TO TIME ***/
/*** -------------------------------- ***/
/******************************************/
// inject info:
// is timer finished
// get clock string
const TimeManagerType = {
...timer,
finished: playback === 'play' && timer.current < 0 && timer.startedAt,
playback,
};
// prevent render until we get all the data we need
if (!viewSettings) {
return null;
}
Component.displayName = 'ComponentWithData';
return (
<Component
{...props}
pres={messages.presenter}
publ={messages.public}
lower={messages.lower}
title={titleManager}
publicTitle={publicTitleManager}
time={TimeManagerType}
events={publicEvents}
backstageEvents={eventsData}
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={genData}
onAir={messages.onAir}
/>
);
};
};
export default withSocket;
@@ -0,0 +1,149 @@
@use '../../../theme/viewerDefs' as *;
.backstage {
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);
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid;
grid-template-columns: 1fr 1fr 40vw;
grid-template-rows: auto 12px 1fr auto;
grid-template-areas:
' header header header'
' progress progress schedule-nav'
' now now schedule'
' message message info';
/* =================== HEADER + EXTRAS ===================*/
.event-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
}
.clock-container {
margin-left: auto;
}
.public-container {
grid-area: message;
&--hidden {
opacity: 0;
transition: $viewer-transition-time;
transition-property: opacity;
}
}
.clock-container,
.public-container {
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
}
.message {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 400;
}
}
/* =================== MAIN - NOW ===================*/
.progress-container {
grid-area: progress;
width: 100%;
margin: 0 auto -8px;
}
.now-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: min(2vh, 16px);
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
border-radius: 8px;
}
.timer-group {
grid-area: timer;
border-top: 2px solid var(--background-color-override, $viewer-background-color);
margin-top: 2em;
padding-top: 1em;
display: flex;
gap: 7.5em;
}
.aux-timers {
font-size: max(1vw, 16px);
&__label {
color: var(--label-color-override, $viewer-label-color);
font-weight: 600;
text-transform: uppercase;
}
&__value {
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: clamp(24px, 2vw, 32px);
letter-spacing: 0.05em;
}
}
/* =================== MAIN - SCHEDULE ===================*/
.schedule-container {
grid-area: schedule;
overflow: hidden;
height: 100%;
margin-left: 16px;
}
.schedule-nav-container {
grid-area: schedule-nav;
align-self: center;
}
.info {
grid-area: info;
display: flex;
gap: max(1vw, 16px);
&__message {
font-size: clamp(16px, 1.5vw, 24px);
line-height: 1.3em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
margin-left: auto;
padding: 4px;
background-color: white;
}
}
}
@@ -0,0 +1,176 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time';
import { titleVariants } from '../common/animation';
import './Backstage.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
Backstage.propTypes = {
publ: PropTypes.object,
title: PropTypes.object,
time: PropTypes.object,
backstageEvents: PropTypes.array,
selectedId: PropTypes.string,
general: PropTypes.object,
viewSettings: PropTypes.object,
};
// @ts-expect-error unable to type just yet
export default function Backstage(props) {
const { publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [isMirrored] = useAtom(mirrorViewersAtom);
// Set window title
useEffect(() => {
document.title = 'ontime - Backstage Screen';
}, []);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const clock = formatTime(time.clock, formatOptions);
const startedAt = formatTime(time.startedAt, formatOptions);
const isNegative = (time.current ?? 0) < 0;
const expectedFinish = isNegative ? 'In overtime' : formatTime(time.expectedFinish, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = getEventsWithDelay(backstageEvents);
const showPublicMessage = publ.text && publ.visible;
const showProgress = time.playback !== 'stop';
let stageTimer;
if (time.current === null) {
stageTimer = '- - : - -';
} else {
stageTimer = formatDisplay(Math.abs(millisToSeconds(time.current)), true);
if (isNegative) {
stageTimer = `-${stageTimer}`;
}
}
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<div className='event-header'>
{general.title}
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='time'>{clock}</div>
</div>
</div>
<ProgressBar
className='progress-container'
now={time.current}
complete={time.duration}
hidden={!showProgress}
/>
<div className='now-container'>
<AnimatePresence>
{title.showNow && (
<motion.div
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={title.titleNow}
subtitle={title.subtitleNow}
presenter={title.presenterNow}
/>
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>Started At</div>
<div className='aux-timers__value'>{startedAt}</div>
</div>
<div className='aux-timers'>
<div className='aux-timers__label'>Expected Finish</div>
<div className='aux-timers__value'>{expectedFinish}</div>
</div>
<div className='aux-timers'>
<div className='aux-timers__label'>Stage Timer</div>
<div className='aux-timers__value'>{stageTimer}</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{title.showNext && (
<motion.div
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
/>
</motion.div>
)}
</AnimatePresence>
</div>
<ScheduleProvider
events={filteredEvents}
selectedEventId={selectedId}
isBackstage
>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
<div
className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
<div className='label'>Public message</div>
<div className='message'>{publ.text}</div>
</div>
<div className='info'>
<div className='qr'>
{general.url != null && general.url !== '' && (
<QRCode value={general.url} size={qrSize} level='L' />
)}
</div>
{general.backstageInfo && (
<div className='info__message'>{general.backstageInfo}</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
@use '../../../theme/viewerDefs' as *;
.clock-view {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: grid;
place-content: center;
.clock {
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw;
position: relative;
color: var(--timer-color-override, $timer-color);
letter-spacing: 0.05em;
}
}
@@ -0,0 +1,152 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { ViewSettingsType } from '../../../common/models/ViewSettings.type';
import { OverridableOptions } from '../../../common/models/ViewTypes';
import { formatTime } from '../../../common/utils/time';
import './Clock.scss';
interface ClockProps {
time: TimeManagerType;
viewSettings: ViewSettingsType;
}
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
export default function Clock(props: ClockProps) {
const { time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom);
useEffect(() => {
document.title = 'ontime - Clock';
}, []);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// get config from url: key, text, font, size, hidenav, hideovertime
// eg. http://localhost:3000/minimal?key=f00&text=fff
// Check for user options
const userOptions: OverridableOptions = {
size: 1,
};
// key: string
// Should be a hex string '#00FF00' with key colour
const key = searchParams.get('key');
if (key) {
userOptions.keyColour = `#${key}`;
}
// textColour: string
// Should be a hex string '#ffffff'
const textColour = searchParams.get('text');
if (textColour) {
userOptions.textColour = `#${textColour}`;
}
// textBackground: string
// Should be a hex string '#ffffff'
const textBackground = searchParams.get('textbg');
if (textBackground) {
userOptions.textBackground = `#${textBackground}`;
}
// font: string
// Should be a string with a font name 'arial'
const font = searchParams.get('font');
if (font) {
userOptions.font = font;
}
// size: multiplier
// Should be a number 0.0-n
const size = searchParams.get('size');
if (size !== null && typeof size !== 'undefined') {
if (!Number.isNaN(Number(size))) {
userOptions.size = Number(size);
}
}
// alignX: flex justification
// start | center | end
const alignX = searchParams.get('alignx');
if (alignX) {
if (alignX === 'start' || alignX === 'center' || alignX === 'end') {
userOptions.justifyContent = alignX;
}
}
// alignX: flex alignment
// start | center | end
const alignY = searchParams.get('aligny');
if (alignY) {
if (alignY === 'start' || alignY === 'center' || alignY === 'end') {
userOptions.alignItems = alignY;
}
}
// offsetX: position in pixels
// Should be a number 0 - 1920
const offsetX = searchParams.get('offsetx');
if (offsetX) {
const pixels = Number(offsetX);
if (!isNaN(pixels)) {
userOptions.left = `${pixels}px`;
}
}
// offsetX: position in pixels
// Should be a number 0 - 1920
const offsetY = searchParams.get('offsety');
if (offsetY) {
const pixels = Number(offsetY);
if (!isNaN(pixels)) {
userOptions.top = `${pixels}px`;
}
}
const clock = formatTime(time.clock, formatOptions);
const clean = clock.replace('/:/g', '');
return (
<div
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
style={{
backgroundColor: userOptions.keyColour,
color: userOptions.textColour,
justifyContent: userOptions.justifyContent,
alignItems: userOptions.alignItems,
}}
data-testid='clock-view'
>
<NavigationMenu />
<div
className='clock'
style={{
fontSize: `${(89 / (clean.length - 1)) * (userOptions.size || 1)}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
left: userOptions.left,
backgroundColor: userOptions.textBackground,
}}
>
{clock}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
// used in both sm and public views
export const titleVariants = {
hidden: {
x: -1500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -1500,
},
};
@@ -0,0 +1,146 @@
@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(24px, 2vw, 32px);
}
&__events {
font-size: clamp(16px, 1.5vw, 24px);
margin-top: 1em;
overflow-y: auto;
height: 70vh;
width: 60vw;
}
}
/* =================== MAIN - EVENT CONTAINER ===================*/
.countdown-container {
height: 100%;
width: 100%;
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
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: clamp(16px, 1.5vw, 24px);
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
}
}
.status {
grid-area: status;
color: var(--label-color-override, $viewer-label-color);
font-size: clamp(32px, 3.5vw, 50px);
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: 16px 24px;
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: flex;
justify-content: space-evenly;
align-items: flex-end;
.aux-timers {
text-align: center;
font-size: clamp(24px, 1.75vw, 32px);
&__label {
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
&--delayed {
color: $delay-color;
}
}
}
}
}
}
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time';
import { fetchTimerData, TimerMessage } from './countdown.helpers';
import CountdownSelect from './CountdownSelect';
import './Countdown.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
const formatOptionsFinished = {
showSeconds: false,
format: 'hh:mm a',
};
Countdown.propTypes = {
backstageEvents: PropTypes.array,
time: PropTypes.object,
selectedId: PropTypes.string,
viewSettings: PropTypes.object,
};
// @ts-expect-error we are unable to type this just yet
export default function Countdown(props) {
const { backstageEvents, time, selectedId, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom);
const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0);
useEffect(() => {
document.title = 'ontime - Countdown';
}, []);
// eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options
useEffect(() => {
if (!backstageEvents) {
return;
}
const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event');
let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
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 = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
const delayToEvent = getDelayTo(backstageEvents, idx);
setDelay(delayToEvent);
}
}, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const standby = time.playback !== 'play' && selectedId === follow?.id;
const isRunningFinished = time.finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock, formatOptions);
const startTime =
follow === null
? '...'
: formatTime(follow.timeStart + delay, formatOptions);
const endTime =
follow === null
? '...'
: formatTime(follow.timeEnd + delay, formatOptions);
const formattedTimer = runningMessage === TimerMessage.ended
? formatTime(runningTimer, formatOptionsFinished)
: formatDisplay(
isSelected ? millisToSeconds(runningTimer) : millisToSeconds(runningTimer + delay),
isSelected || time.waiting,
);
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
<div className='countdown-container' data-testid='countdown-event'>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='time'>{clock}</div>
</div>
<div className='status'>{runningMessage}</div>
<span
className={`timer ${standby ? 'timer--paused' : ''} ${
isRunningFinished ? 'timer--finished' : ''
}`}
>
{formattedTimer}
</span>
<div className='title'>{follow?.title || 'Untitled Event'}</div>
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>Start Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{startTime}
</span>
</div>
<div className='aux-timers'>
<div className='aux-timers__label'>End Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{endTime}
</span>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { Link } from 'react-router-dom';
import Empty from '../../../common/components/state/Empty';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
import { formatTime } from '../../../common/utils/time';
import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeRundownEntry[];
}
export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const filteredEvents = events.filter((event: OntimeRundownEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
return (
<div className='event-select' data-testid='countdown-select'>
<span className='event-select__title'>Select an event to follow</span>
<ul className='event-select__events'>
{!events.length ? (
<Empty text='No events in database' />
) : (
filteredEvents.map((event: OntimeEvent, counter: number) => {
const index = counter + 1;
const title = sanitiseTitle(event.title);
const start = formatTime(event.timeStart, { format: 'hh:mm' });
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
return (
<li key={event.id}>
<Link to={`/countdown?eventid=${event.id}`}>
{`${index}. ${start}${end} | ${title}`}
</Link>
</li>
);
},
)
)}
</ul>
</div>
);
}
@@ -0,0 +1,108 @@
import { DAY_TO_MS } from '../../../../common/utils/timeConstants';
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)).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 };
const time = { current: currentMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
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 };
const time = { clock: timeNow };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(DAY_TO_MS + 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 };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.running);
expect(timer).toBe(DAY_TO_MS + 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 };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
});
@@ -0,0 +1,69 @@
import { OntimeEvent } from '../../../common/models/EventTypes';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
export enum TimerMessage {
toStart = 'Time to start',
waiting = 'Waiting for event start',
running = 'Event running',
ended = 'Event ended at',
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: TimeManagerType, follow: OntimeEvent, selectedId: string): { message: TimerMessage, timer: number } => {
let message;
let timer;
if (selectedId === follow.id) {
// check that is not running
message = time.playback === 'pause' ? TimerMessage.waiting : TimerMessage.running;
timer = time.current ?? 0;
} else if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// running timer timer is not the one we are following
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
}
return { message, timer };
};
@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import './LowerClean.scss';
export default function LowerClean(props) {
const { lower, title, options } = props;
const defaults = {
size: 1,
transitionIn: 3,
textColour: '#fffffa',
posX: 50,
posY: 700,
};
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
useEffect(() => {
if (!options.fadeOut) return;
// Calculate time
const fadeOutTime =
(parseInt(options.fadeOut, 10) +
(options.transitionIn || defaults.transitionIn)) *
1000;
if (isNaN(fadeOutTime)) return;
const timeout = setTimeout(() => {
setShowLower(false);
}, fadeOutTime);
return () => clearTimeout(timeout);
}, [options.fadeOut, options.transitionIn, defaults.transitionIn]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
// motion
// transition segments
const t = options.transitionIn || defaults.transitionIn;
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,
transition: {
duration: third,
},
},
};
const titleVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
delay: quarter,
duration: half,
},
},
};
const sizeMultiplier = (options.size || 1) * 4;
return (
<div
className='lower-third clean'
style={{
backgroundColor: options.keyColour || defaults.keyColour,
color: options.textColour || defaults.textColour,
fontSize: `${sizeMultiplier}vh`,
}}
>
<NavigationMenu />
<AnimatePresence>
{showLower && (
<motion.div
className='lower-container'
style={{
backgroundColor: options.bgColour || defaults.bgColour,
top: options.posY || defaults.posY,
left: options.posX || defaults.posX,
}}
variants={lowerThirdVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
</motion.div>
<motion.div className='subtitle' variants={titleVariants}>
{title.presenterNow}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showLowerMessage && (
<motion.div
className='message-container'
style={{
backgroundColor: options.bgColour || defaults.bgColour,
}}
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,26 @@
.lower-third.clean {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100%;
color: #fffffa;
font-size: 4vh;
.lower-container {
position: absolute;
padding: 1vh 2vh;
border-radius: 8px;
}
.message-container {
position: absolute;
bottom: 2vh;
width: 100%;
text-align: center;
.message {
font-size: 3.5vh;
}
}
}
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import './LowerLines.scss';
export default function LowerLines(props) {
const { lower, title, options } = props;
const defaults = {
size: 1,
transitionIn: 3,
textColour: '#fffffa',
bgColour: '#00000033',
};
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
useEffect(() => {
if (!options.fadeOut) return;
// Calculate time
const fadeOutTime =
(parseInt(options.fadeOut, 10) +
(options.transitionIn || defaults.transitionIn)) *
1000;
if (isNaN(fadeOutTime)) return;
const timeout = setTimeout(() => {
setShowLower(false);
}, fadeOutTime);
return () => clearTimeout(timeout);
}, [options.fadeOut, options.transitionIn, defaults.transitionIn]);
useEffect(() => {
setShowLower(title.showNow);
}, [title.showNow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
// motion
// transition segments
const t = options.transitionIn || defaults.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 || defaults.keyColour,
color: options.textColour || defaults.textColour,
fontSize: `${sizeMultiplier}vh`,
}}
>
<NavigationMenu />
<AnimatePresence>
{showLower && (
<motion.div
className='lower-container'
style={{ backgroundColor: options.bgColour || defaults.bgColour }}
variants={lowerThirdVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<motion.div
className='title-container'
variants={titleContainerVariants}
>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
</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}
>
{title.presenterNow}
</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,66 @@
.lower-third.lines {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100%;
color: #fffffa;
font-size: 4vh;
.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;
}
}
@@ -0,0 +1,135 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import PropTypes from 'prop-types';
import LowerClean from './LowerClean';
import LowerLines from './LowerLines';
const areEqual = (prevProps, nextProps) => {
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props) => {
const { title, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [titles, setTitles] = useState({
titleNow: '',
titleNext: '',
subtitleNow: '',
subtitleNext: '',
presenterNow: '',
presenterNext: '',
showNow: false,
showNext: 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 = null;
if (
title?.titleNow !== titles?.titleNow ||
title?.subtitleNow !== titles?.subtitleNow ||
title?.presenterNow !== titles?.presenterNow
) {
setTitles((t) => ({ ...t, showNow: false }));
const transitionTime = 2000;
timeout = setTimeout(() => {
setTitles(title);
}, transitionTime);
}
return () => {
if (timeout != null) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// TODO: sanitize data
// getting config from URL: preset, size, transition, bg, text, key
// eg. http://localhost:3000/lower?bg=ff2&text=f00&size=0.6&transition=5
// Check for user options
// create aux
const options = {};
// preset: selector
// Should be a number 1-n
const p = parseInt(searchParams.get('preset'), 10);
const preset = !isNaN(p) ? 1 : p;
// size: multiplier
// Should be a number 0.0-n
const s = searchParams.get('size');
if (s) options.size = s;
// transitionIn: seconds
// Should be a number 0-n
const t = parseInt(searchParams.get('transition'), 10);
if (!isNaN(t)) options.transitionIn = t;
// textColour: string
// Should be a hex string '#ffffff'
const c = searchParams.get('text');
if (c) options.textColour = `#${c}`;
// bgColour: string
// Should be a hex string '#ffffff'
const b = searchParams.get('bg');
if (b) options.bgColour = `#${b}`;
// key: string
// Should be a hex string '#00FF00' with key colour
const k = searchParams.get('key');
if (k) options.keyColour = `#${k}`;
// fadeOut: seconds
// Should be a number 0-n
const f = parseInt(searchParams.get('fadeout'), 10);
if (!isNaN(f)) options.fadeOut = f;
// x: pixels
// Should be a number 0-n
const x = parseInt(searchParams.get('x'), 10);
if (!isNaN(x)) options.posX = x;
// y: pixels
// Should be a number 0-n
const y = parseInt(searchParams.get('y'), 10);
if (!isNaN(y)) options.posY = y;
switch (preset) {
case 0:
return <LowerClean lower={lower} title={titles} options={options} />;
case 1:
return <LowerLines lower={lower} title={titles} options={options} />;
default:
return <LowerLines lower={lower} title={titles} options={options} />;
}
};
export default memo(Lower, areEqual);
Lower.propTypes = {
title: PropTypes.object,
lower: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -0,0 +1,79 @@
@use '../../../theme/viewerDefs' as *;
.minimal-timer {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: grid;
place-content: center;
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
.timer {
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw;
position: relative;
color: var(--timer-color-override, $timer-color);
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
background-color: transparent;
letter-spacing: 0.05em;
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
&--finished {
color: $timer-finished-color;
}
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: $viewer-transition-time;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: white;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center;
font-weight: 600;
}
}
@@ -0,0 +1,179 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { PresenterMessageType } from '../../../common/models/PresenterMessage.type';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { ViewSettingsType } from '../../../common/models/ViewSettings.type';
import { OverridableOptions } from '../../../common/models/ViewTypes';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import './MinimalTimer.scss';
interface MinimalTimerProps {
pres: PresenterMessageType;
time: TimeManagerType;
viewSettings: ViewSettingsType;
}
export default function MinimalTimer(props: MinimalTimerProps) {
const { pres, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom);
useEffect(() => {
document.title = 'ontime - Minimal Timer';
}, []);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// get config from url: key, text, font, size, hidenav, hideovertime
// eg. http://localhost:3000/minimal?key=f00&text=fff
// Check for user options
const userOptions: OverridableOptions = {
size: 1,
};
// key: string
// Should be a hex string '#00FF00' with key colour
const key = searchParams.get('key');
if (key) {
userOptions.keyColour = `#${key}`;
}
// textColour: string
// Should be a hex string '#ffffff'
const textColour = searchParams.get('text');
if (textColour) {
userOptions.textColour = `#${textColour}`;
}
// textBackground: string
// Should be a hex string '#ffffff'
const textBackground = searchParams.get('textbg');
if (textBackground) {
userOptions.textBackground = `#${textBackground}`;
}
// font: string
// Should be a string with a font name 'arial'
const font = searchParams.get('font');
if (font) {
userOptions.font = font;
}
// size: multiplier
// Should be a number 0.0-n
const size = searchParams.get('size');
if (size !== null && typeof size !== 'undefined') {
if (!Number.isNaN(Number(size))) {
userOptions.size = Number(size);
}
}
// alignX: flex justification
// start | center | end
const alignX = searchParams.get('alignx');
if (alignX) {
if (alignX === 'start' || alignX === 'center' || alignX === 'end') {
userOptions.justifyContent = alignX;
}
}
// alignX: flex alignment
// start | center | end
const alignY = searchParams.get('aligny');
if (alignY) {
if (alignY === 'start' || alignY === 'center' || alignY === 'end') {
userOptions.alignItems = alignY;
}
}
// offsetX: position in pixels
// Should be a number 0 - 1920
const offsetX = searchParams.get('offsetx');
if (offsetX) {
const pixels = Number(offsetX);
if (!isNaN(pixels)) {
userOptions.left = `${pixels}px`;
}
}
// offsetX: position in pixels
// Should be a number 0 - 1920
const offsetY = searchParams.get('offsety');
if (offsetY) {
const pixels = Number(offsetY);
if (!isNaN(pixels)) {
userOptions.top = `${pixels}px`;
}
}
const hideOvertime = searchParams.get('hideovertime');
userOptions.hideOvertime = Boolean(hideOvertime);
const hideMessagesOverlay = searchParams.get('hidemessages');
userOptions.hideMessagesOverlay = Boolean(hideMessagesOverlay);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== 'pause';
const isNegative = (time.current ?? 0) < 0;
const showFinished = isNegative && !userOptions?.hideOvertime;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
let stageTimer;
if (time.current === null) {
stageTimer = '- - : - -';
} else {
stageTimer = formatDisplay(Math.abs(millisToSeconds(time.current)), true);
if (time.current < 0) {
stageTimer = `-${stageTimer}`;
}
}
const stageTimerCharacters = stageTimer.replace('/:/g', '').length;
return (
<div
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
style={{
backgroundColor: userOptions.keyColour,
justifyContent: userOptions.justifyContent,
alignItems: userOptions.alignItems,
}}
data-testid='minimal-timer'
>
<NavigationMenu />
{!hideMessagesOverlay && (
<div
className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}
>
<div className='message'>{pres.text}</div>
</div>
)}
<div
className={`timer ${!isPlaying ? 'timer--paused' : ''} ${
showFinished ? 'timer--finished' : ''
}`}
style={{
color: userOptions.textColour,
fontSize: `${(89 / (stageTimerCharacters - 1)) * (userOptions.size || 1)}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
left: userOptions.left,
backgroundColor: userOptions.textBackground,
}}
>
{stageTimer}
</div>
</div>
);
}
@@ -0,0 +1,117 @@
@use '../../../theme/viewerDefs' as *;
.public-screen {
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);
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid;
grid-template-columns: 1fr 1fr 40vw;
grid-template-rows: auto 12px 1fr auto;
grid-template-areas:
' header header header'
' progress progress schedule-nav'
' now now schedule'
' message message info';
/* =================== HEADER + EXTRAS ===================*/
.event-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
}
.clock-container {
margin-left: auto;
}
.public-container {
grid-area: message;
&--hidden {
opacity: 0;
transition: $viewer-transition-time;
transition-property: opacity;
}
}
.clock-container,
.public-container {
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
letter-spacing: 0.05em;
}
.message {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 400;
}
}
/* =================== MAIN - NOW ===================*/
.now-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: min(2vh, 16px);
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
border-radius: 8px;
}
/* =================== MAIN - SCHEDULE ===================*/
.schedule-container {
grid-area: schedule;
overflow: hidden;
height: 100%;
margin-left: 16px;
}
.schedule-nav-container {
grid-area: schedule-nav;
align-self: center;
}
.info {
grid-area: info;
display: flex;
gap: max(1vw, 16px);
&__message {
font-size: clamp(16px, 1.5vw, 24px);
line-height: 1.3em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
margin-left: auto;
padding: 4px;
background-color: white;
}
}
}
@@ -0,0 +1,135 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatTime } from '../../../common/utils/time';
import { titleVariants } from '../common/animation';
import './Public.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
Public.propTypes = {
publ: PropTypes.object,
publicTitle: PropTypes.object,
time: PropTypes.object,
events: PropTypes.array,
publicSelectedId: PropTypes.string,
general: PropTypes.object,
viewSettings: PropTypes.object,
};
// @ts-expect-error unable to type just yet
export default function Public(props) {
const { publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [isMirrored] = useAtom(mirrorViewersAtom);
useEffect(() => {
document.title = 'ontime - Public Screen';
}, []);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const showPublicMessage = publ.text && publ.visible;
const clock = formatTime(time.clock, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<div className='event-header'>
{general.title}
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='time'>{clock}</div>
</div>
</div>
<div className='now-container'>
<AnimatePresence>
{publicTitle.showNow && (
<motion.div
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={publicTitle.titleNow}
subtitle={publicTitle.subtitleNow}
presenter={publicTitle.presenterNow}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{publicTitle.showNext && (
<motion.div
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={publicTitle.titleNext}
subtitle={publicTitle.subtitleNext}
presenter={publicTitle.presenterNext}
/>
</motion.div>
)}
</AnimatePresence>
</div>
<ScheduleProvider
events={events}
selectedEventId={publicSelectedId}
>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
<div
className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
<div className='label'>Public message</div>
<div className='message'>{publ.text}</div>
</div>
<div className='info'>
<div className='qr'>
{general.url != null && general.url !== '' && (
<QRCode value={general.url} size={qrSize} level='L' />
)}
</div>
{general.backstageInfo && (
<div className='info__message'>{general.backstageInfo}</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatDisplay } from '../../../common/utils/dateConfig';
import { formatEventList, getEventsWithDelay, trimEventlist } from '../../../common/utils/eventsManager';
import { formatTime, stringFromMillis } from '../../../common/utils/time';
import './StudioClock.scss';
const formatOptions = {
showSeconds: false,
format: 'hh:mm',
};
StudioClock.propTypes = {
title: PropTypes.object,
time: PropTypes.object,
backstageEvents: PropTypes.array,
selectedId: PropTypes.string,
nextId: PropTypes.string,
onAir: PropTypes.bool,
viewSettings: PropTypes.object,
};
export default function StudioClock(props) {
const { title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
const [schedule, setSchedule] = useState([]);
const [isMirrored] = useAtom(mirrorViewersAtom);
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
const MAX_TITLES = 12;
const [searchParams] = useSearchParams();
const showSeconds = searchParams.get('seconds');
formatOptions.showSeconds = Boolean(showSeconds);
formatOptions.format = `hh:mm${formatOptions.showSeconds ? 'mm' : ''}`;
useEffect(() => {
document.title = 'ontime - Studio Clock';
}, []);
// Prepare event list
useEffect(() => {
if (!backstageEvents) {
return;
}
const delayed = getEventsWithDelay(backstageEvents);
const events = delayed.filter((e) => e.type === 'event');
const trimmed = trimEventlist(events, selectedId, MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId, nextId, {
showEnd: false,
});
setSchedule(formatted);
}, [backstageEvents, nextId, selectedId]);
const clock = formatTime(time.clock, formatOptions);
const [, , secondsNow] = stringFromMillis(time.clock).split(':');
const isNegative = (time.current ?? 0) < 0;
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<NavigationMenu />
<div className='clock-container'>
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
ref={titleRef}
className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
>
{title.titleNext}
</div>
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId != null && formatDisplay(time.current)}
</div>
<div className='clock-indicators'>
{activeIndicators.map((i) => (
<div
key={i}
className='hours hours--active'
style={{
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(40vh)`,
}}
/>
))}
{secondsIndicators.map((i) => (
<div
key={i}
className={i <= secondsNow ? 'min min--active' : 'min'}
style={{
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(43vh)`,
}}
/>
))}
</div>
</div>
<div className='schedule-container'>
<div
className={onAir ? 'onAir' : 'onAir onAir--idle'}
data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}
>
ON AIR
</div>
<div className='schedule'>
<ul>
{schedule.map((s) => (
<li key={s.id} className={s.isNow ? 'now' : s.isNext ? 'next' : ''}>
<div className='user-colour' style={{ backgroundColor: `${s.colour !== '' ? s.colour : ''}` }} />
{`${s.time} ${s.title}`}
</li>
))}
</ul>
</div>
</div>
</div>
);
}
@@ -0,0 +1,186 @@
@use '../../../theme/viewerDefs' as *;
@font-face {
font-family: "digital-clock";
src: local('digital-7'), url('./../../../assets/fonts/digital-7.monoitalic.ttf') format('truetype') ;
}
/* =============== CLOCK STUFF ==================*/
$clock-size: 90vh;
$size-hours: min(3vh, 20px);
$half-hours: min(1.5vh, 10px);
$size-min: min(2.5vh, 18px);
$half-min: min(1.25vh, 9px);
$red-active: #c53030;
$red-idle: #300000;
$cyan-active: #0ff;
$cyan-idle: #0aa;
.studio-clock {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
padding: 1vw;
background: var(--background-color-override, #000);
display: grid;
grid-template-columns: 95vh 1fr;
gap: 2vw;
grid-template-areas: "clck schd";
.clock-container {
display: flex;
flex-direction: column;
align-items: center;
grid-area: clck;
width: $clock-size;
height: $clock-size;
aspect-ratio: 1;
text-align: center;
position: relative;
margin: 4vh auto;
font-family: digital-clock, monospace;
text-transform: uppercase;
.clock-indicators {
position: absolute;
top: 0;
width: 100%;
height: 100%;
.min,
.hours {
border-radius: 50%;
position: absolute;
background: $red-idle;
&--active {
background: $red-active;
box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25);
}
}
.min {
min-height: $size-min;
width: $size-min;
top: calc(50% - #{$half_min});
left: calc(50% - #{$half_min});
}
.hours {
min-height: $size-hours;
width: $size-hours;
top: calc(50% - #{$half_hours});
left: calc(50% - #{$half_hours});
}
}
.studio-timer {
color: $red-active;
font-size: calc(#{$clock-size} / 3);
margin-top: calc(50% - calc(#{$clock-size} / 7));
line-height: 0.8em;
&--with-seconds {
font-size: calc(#{$clock-size} / 4.6);
margin-top: calc(50% - calc(#{$clock-size} / 11));
}
}
.next-title:after,
.next-title:after,
.next-countdown--overtime:after {
content: '\200b';
}
.next-title {
color: $cyan-idle;
text-align: center;
}
.next-countdown {
font-size: 10vh;
line-height: 1em;
color: $cyan-active;
&--overtime {
color: darken($red-active, 10%);
}
}
.next-countdown::before {
content: '-';
}
}
/* ============= SCHEDULE STUFF =================*/
.schedule-container {
grid-area: schd;
margin: 4vh 0;
font-family: digital-clock, monospace;
text-transform: uppercase;
.onAir {
padding-bottom: 2vh;
font-size: 15vh;
line-height: 0.9em;
color: $red-active;
&--idle {
color: $red-idle;
}
}
.schedule {
ul {
color: $cyan-idle;
font-size: 3.75vh;
line-height: 1em;
list-style: none;
}
li {
margin-bottom: 1.5vh;
padding-left: 0.25em;
display: flex;
align-items: center;
}
.now {
color: $cyan-active;
}
.next {
color: $red-active;
}
.user-colour {
width: 0.35em;
height: 0.35em;
aspect-ratio: 1;
background-color: $red-idle;
margin-right: 0.35em;
}
}
}
}
@media only screen and (max-width: 1200px) {
.studio-clock {
display: grid;
grid-template-areas: "clck";
grid-template-columns: 100%;
place-content: center;
.schedule-container {
display: none;
}
}
}
@@ -0,0 +1,142 @@
@use '../../../theme/viewerDefs' as *;
.stage-timer {
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);
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid;
grid-template-columns: auto;
grid-template-rows: auto 1fr auto auto auto;
grid-template-areas:
'clock'
'timer'
'progress'
'now'
'next';
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
/* =================== CLOCK ===================*/
.clock-container {
grid-area: clock;
margin-left: auto;
font-weight: 600;
.label {
font-size: clamp(16px, 1.5vw, 24px);
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.clock {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
}
}
/* =================== TITLES ===================*/
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
border-radius: 8px;
&.now {
grid-area: now;
}
&.next {
grid-area: next;
}
}
/* =================== MAIN ===================*/
.timer-container {
grid-area: timer;
justify-self: center;
align-self: center;
color: var(--timer-color-override, $timer-color);
.end-message {
text-align: center;
font-size: 12vw;
line-height: 0.9em;
font-weight: 600;
color: $timer-finished-color;
padding: 0;
}
.timer {
opacity: 1;
}
}
.progress-container {
grid-area: progress;
width: 100%;
margin: 0 auto;
opacity: 1;
transition: $viewer-transition-time;
}
.timer--paused,
.progress-container--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: $viewer-transition-time;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: $viewer-color;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center;
font-weight: 600;
}
}
@@ -0,0 +1,151 @@
import { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import TimerDisplay from '../../../common/components/timer-display/TimerDisplay';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatTime } from '../../../common/utils/time';
import './Timer.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
// motion
const titleVariants = {
hidden: {
y: 500,
},
visible: {
y: 0,
transition: {
duration: 1,
},
},
exit: {
y: 500,
},
};
Timer.propTypes = {
general: PropTypes.object,
pres: PropTypes.object,
title: PropTypes.object,
time: PropTypes.object,
viewSettings: PropTypes.object,
};
// @ts-expect-error unable to type just yet
export default function Timer(props) {
const { general, pres, title, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [isMirrored] = useAtom(mirrorViewersAtom);
useEffect(() => {
document.title = 'ontime - Timer';
}, []);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const clock = formatTime(time.clock, formatOptions);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== 'pause';
const normalisedTime = time.current === null ? null : Math.max(time.current, 0);
const showProgress = time.playback !== 'stop';
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
return (
<div
className={time.finished ? `${baseClasses} stage-timer--finished` : baseClasses}
data-testid='timer-view'
>
<NavigationMenu />
<div
className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className='message'>{pres.text}</div>
</div>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='clock'>{clock}</div>
</div>
<div className='timer-container'>
{time.finished ? (
<div className='end-message'>
{!general.endMessage ? (
<TimerDisplay time={time.current} hideZeroHours />
) : (
general.endMessage
)}
</div>
) : (
<TimerDisplay
time={normalisedTime}
hideZeroHours
className={isPlaying ? 'timer' : 'timer--paused'} />
)}
</div>
<ProgressBar
className={
isPlaying ? 'progress-container' : 'progress-container progress-container--paused'
}
now={time.current}
complete={time.duration}
hidden={!showProgress}
/>
<AnimatePresence>
{title.showNow && (
<motion.div
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={title.titleNow}
subtitle={title.subtitleNow}
presenter={title.presenterNow}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{title.showNext && (
<motion.div
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}