mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 21:24:11 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user