mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
v2: views (#278)
* config(sentry): instrumentation only in production * chore: upgrade deps * refactor: simplify and convert to ts * refactor: composition and styles * chore: remove deprecated deps * ux: close menu on click outside * style: small tweaks and clarifications * refactor: retire PiP view * ux: small tweaks * style: cleanup deprecated styles * chore: remove unused * refactor: remove usages of deprecated fields and improve null timer presentation * refactor: improve composition of countdown * style: cleanup overridable styles * ux: show event overtime status * style: correct logic in paused timers
This commit is contained in:
+31
-33
@@ -3,29 +3,29 @@
|
||||
"version": "1.9.7",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.4.1",
|
||||
"@dnd-kit/core": "^6.0.5",
|
||||
"@chakra-ui/react": "^2.4.5",
|
||||
"@dnd-kit/core": "^6.0.6",
|
||||
"@dnd-kit/sortable": "^7.0.1",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.24.1",
|
||||
"@sentry/react": "^7.28.1",
|
||||
"@sentry/tracing": "^7.24.1",
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@tanstack/react-query-devtools": "^4.18.0",
|
||||
"autosize": "^5.0.1",
|
||||
"autosize": "^5.0.2",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
"framer-motion": "^7.5.3",
|
||||
"framer-motion": "^8.0.2",
|
||||
"jotai": "^1.10.0",
|
||||
"luxon": "^3.1.0",
|
||||
"react": "^18.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-qr-code": "^2.0.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-table": "^7.7.0",
|
||||
"socket.io-client": "^4.5.4",
|
||||
@@ -53,40 +53,38 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.2.4",
|
||||
"@sentry/vite-plugin": "^0.3.0",
|
||||
"@tanstack/eslint-plugin-query": "^4.15.1",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/node": "^18.7.16",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-beautiful-dnd": "^13.1.2",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.37.0",
|
||||
"@typescript-eslint/parser": "^5.37.0",
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-beautiful-dnd": "^13.1.3",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.47.1",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-jest": "^27.0.4",
|
||||
"eslint-plugin-react": "^7.31.8",
|
||||
"eslint-plugin-jest": "^27.1.7",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-testing-library": "^5.6.4",
|
||||
"jsdom": "^20.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"eslint-plugin-testing-library": "^5.9.1",
|
||||
"jsdom": "^20.0.3",
|
||||
"prettier": "^2.8.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-test-renderer": "^18.1.0",
|
||||
"sass": "^1.51.0",
|
||||
"stylelint": "^14.9.1",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-standard-scss": "^4.0.0",
|
||||
"sass": "^1.57.1",
|
||||
"stylelint": "^14.16.0",
|
||||
"stylelint-config-prettier": "^9.0.4",
|
||||
"stylelint-config-standard-scss": "^6.1.0",
|
||||
"typescript": "^4.8.3",
|
||||
"vite": "^3.2.4",
|
||||
"vite-plugin-svgr": "^2.2.2",
|
||||
"vite-tsconfig-paths": "^3.6.0",
|
||||
"vitest": "^0.25.3"
|
||||
"vite": "^4.0.3",
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.26.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/@types/react": "18.0.21"
|
||||
|
||||
@@ -15,7 +15,6 @@ const Countdown = lazy(() => import('features/viewers/countdown/Countdown'));
|
||||
const Backstage = lazy(() => import('features/viewers/backstage/Backstage'));
|
||||
const Public = lazy(() => import('features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('features/viewers/lower-thirds/LowerWrapper'));
|
||||
const Pip = lazy(() => import('features/viewers/picture-in-picture/Pip'));
|
||||
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
|
||||
|
||||
const STimer = withSocket(TimerView);
|
||||
@@ -25,7 +24,6 @@ const SCountdown = withSocket(Countdown);
|
||||
const SBackstage = withSocket(Backstage);
|
||||
const SPublic = withSocket(Public);
|
||||
const SLowerThird = withSocket(Lower);
|
||||
const SPip = withSocket(Pip);
|
||||
const SStudio = withSocket(StudioClock);
|
||||
|
||||
const FeatureWrapper = lazy(() => import('features/FeatureWrapper'));
|
||||
@@ -70,7 +68,6 @@ export default function AppRouter() {
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/pip' element={<SPip />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDisplay } from 'common/utils/dateConfig';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './TimerDisplay.scss';
|
||||
|
||||
const TimerDisplay = ({ time, small, isNegative, hideZeroHours }) => {
|
||||
|
||||
const display =
|
||||
(time === null || typeof time === 'undefined' || isNaN(time))
|
||||
? '-- : -- : --'
|
||||
: formatDisplay(time, hideZeroHours);
|
||||
|
||||
const classes = `timer ${small ? 'timer--small' : ''} ${isNegative ? 'timer--finished' : ''}`;
|
||||
|
||||
return <div className={classes}>{display}</div>;
|
||||
};
|
||||
|
||||
export default memo(TimerDisplay);
|
||||
|
||||
TimerDisplay.propTypes = {
|
||||
time: PropTypes.number,
|
||||
small: PropTypes.bool,
|
||||
isNegative: PropTypes.bool,
|
||||
hideZeroHours: PropTypes.bool,
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.timer {
|
||||
font-family: var(--font-family-override, $ontime-font-family);
|
||||
color: var(--timer-color-override, $viewer-color);
|
||||
font-size: 21vw;
|
||||
line-height: 21vw;
|
||||
text-align: center;
|
||||
letter-spacing: 1vw;
|
||||
|
||||
&--small {
|
||||
font-size: 3.75em;
|
||||
line-height: 0.9;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../theme/main' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.errorContainer {
|
||||
width: 100%;
|
||||
@@ -9,17 +9,17 @@
|
||||
color: white;
|
||||
|
||||
.error {
|
||||
color: $ontime-pink;
|
||||
color: $error-red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report {
|
||||
text-decoration: underline $ontime-pink;
|
||||
text-decoration: underline $error-red;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report:hover {
|
||||
color: $ontime-pink;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.report:active {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { clamp } from 'common/utils/math';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import styles from './MyProgressBar.module.scss';
|
||||
|
||||
export default function MyProgressBar(props) {
|
||||
const { now, complete, showElapsed } = props;
|
||||
|
||||
let percentComplete = showElapsed ? 0 : 100;
|
||||
|
||||
if (now != null && complete != null) {
|
||||
percentComplete = showElapsed
|
||||
? clamp(100 - (now * 100) / complete, 0, 100)
|
||||
: clamp((now * 100) / complete, 0, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={showElapsed ? styles.progress : styles.progressCountdown}>
|
||||
<div
|
||||
className={styles.progressBar}
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MyProgressBar.propTypes = {
|
||||
now: PropTypes.number,
|
||||
complete: PropTypes.number,
|
||||
showElapsed: PropTypes.bool,
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
@use '../../../theme/main' as *;
|
||||
|
||||
.progress,
|
||||
.progressCountdown {
|
||||
height: 2vh;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: $bg-gray-900;
|
||||
}
|
||||
|
||||
.progressCountdown {
|
||||
background-color: $ontime-pink;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 2vh;
|
||||
background-color: $title-white;
|
||||
border-radius: 4px;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
@@ -10,6 +10,7 @@ import { useAtom } from 'jotai';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { mirrorViewersAtom } from '../../atoms/ViewerSettings';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
|
||||
@@ -22,6 +23,8 @@ export default function NavigationMenu() {
|
||||
const [isMirrored, setMirrored] = useAtom(mirrorViewersAtom);
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
useKeyDown(toggleMenu, ' ');
|
||||
@@ -49,7 +52,7 @@ export default function NavigationMenu() {
|
||||
const handleMirror = () => setMirrored((prev) => !prev);
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' className={isMirrored ? style.mirror : ''}>
|
||||
<div id='navigation-menu-portal' ref={menuRef} className={isMirrored ? style.mirror : ''}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
@@ -81,7 +84,7 @@ export default function NavigationMenu() {
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleMirror();
|
||||
}}>
|
||||
Mirror Screen
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
</div>
|
||||
{/*<div className={style.link} tabIndex={0}>*/}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.paginator.entries {
|
||||
width: 100%;
|
||||
|
||||
.entry-times {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 1.4vw;
|
||||
|
||||
min-width: 10vw;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.035em;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
font-size: 1.3vw;
|
||||
padding: 0.2vh 1vw;
|
||||
margin-bottom: 1.5vh;
|
||||
border-radius: 3px;
|
||||
|
||||
&--now,
|
||||
&--future {
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
|
||||
&--past {
|
||||
font-size: 1.2vw;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--now {
|
||||
line-height: 5vh;
|
||||
background-color: $now-bg-color;
|
||||
border-bottom: 0.5vh solid;
|
||||
border-bottom-color: var(--accent-color-override, $accent-color);
|
||||
margin-left: -0.5em;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
&--future {
|
||||
background-color: $future-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.entry .skip {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.backstage-indicator {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
margin-left: auto;
|
||||
margin-right: -0.5vw;
|
||||
|
||||
&::before {
|
||||
content: '*';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useInterval } from 'common/hooks/useInterval';
|
||||
import { OntimeEvent } from 'common/models/EventTypes';
|
||||
|
||||
import Empty from '../state/Empty';
|
||||
|
||||
import TodayItem from './TodayItem';
|
||||
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
events: OntimeEvent[];
|
||||
selectedId: string;
|
||||
limit?: number;
|
||||
time?: number;
|
||||
isBackstage: boolean;
|
||||
setPageNumber: (page: number) => void;
|
||||
setCurrentPage: (selectedPage: number) => void;
|
||||
}
|
||||
|
||||
export default function Paginator(props: PaginatorProps) {
|
||||
const {
|
||||
events,
|
||||
selectedId,
|
||||
limit = 8,
|
||||
time = 10,
|
||||
isBackstage,
|
||||
setPageNumber,
|
||||
setCurrentPage,
|
||||
} = props;
|
||||
const LIMIT_PER_PAGE = limit;
|
||||
const SCROLL_TIME = time * 1000;
|
||||
const [numEvents, setNumEvents] = useState(0);
|
||||
const [page, setPage] = useState<OntimeEvent[]>([]);
|
||||
const [pages, setPages] = useState<number>(0);
|
||||
const [selPage, setSelPage] = useState<number>(0);
|
||||
|
||||
// keep parent up to date
|
||||
useEffect(() => {
|
||||
if (setPageNumber) {
|
||||
setPageNumber(pages);
|
||||
}
|
||||
}, [setPageNumber, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setCurrentPage) {
|
||||
setCurrentPage(selPage);
|
||||
}
|
||||
}, [setCurrentPage, selPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events == null) return;
|
||||
// how many events in list
|
||||
const n = events.length;
|
||||
setNumEvents(n);
|
||||
|
||||
// how many paginated views
|
||||
setPages(Math.ceil(n / LIMIT_PER_PAGE));
|
||||
|
||||
// divide events in parts of LIMIT_PER_PAGE
|
||||
const eventStart = LIMIT_PER_PAGE * selPage;
|
||||
const eventEnd = LIMIT_PER_PAGE * (selPage + 1);
|
||||
setPage(events.slice(eventStart, eventEnd));
|
||||
|
||||
// if array is completely in past, show depending on SCROLL_PAST
|
||||
}, [events, selPage, LIMIT_PER_PAGE]);
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (numEvents > LIMIT_PER_PAGE) {
|
||||
const next = (selPage + 1) % pages;
|
||||
setSelPage(next);
|
||||
}
|
||||
}, SCROLL_TIME);
|
||||
|
||||
let selectedState = 0;
|
||||
|
||||
if (events?.length < 1) {
|
||||
return <Empty text='No events to show' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='paginator entries'>
|
||||
{page.map((e) => {
|
||||
if (e.id === selectedId) selectedState = 1;
|
||||
else if (selectedState === 1) selectedState = 2;
|
||||
return (
|
||||
<TodayItem
|
||||
key={e.id}
|
||||
selected={selectedState}
|
||||
timeStart={e.timeStart}
|
||||
timeEnd={e.timeEnd}
|
||||
title={e.title}
|
||||
colour={isBackstage ? e.colour : ''}
|
||||
backstageEvent={!e.isPublic}
|
||||
skip={e.skip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { formatTime } from '../../utils/time';
|
||||
|
||||
import './Paginator.scss';
|
||||
|
||||
interface TodayItemProps {
|
||||
selected: number;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export default function TodayItem(props: TodayItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
||||
|
||||
// Format timers
|
||||
const start = formatTime(timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(timeEnd, { format: 'hh:mm' });
|
||||
|
||||
// user colours
|
||||
const userColour = colour !== '' ? colour : 'transparent';
|
||||
|
||||
let selectStyle = 'entry--past';
|
||||
if (selected === 1) selectStyle = 'entry--now';
|
||||
else if (selected === 2) selectStyle = 'entry--future';
|
||||
return (
|
||||
<div className={`entry ${selectStyle} ${skip ? 'skip': ''}}`} style={{ borderLeft: `4px solid ${userColour}` }}>
|
||||
<div className='entry-times'>
|
||||
{`${start} · ${end}`}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
{backstageEvent && <div className='backstage-indicator' />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 6px;
|
||||
|
||||
.progress-bar__bg {
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__indicator {
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--accent-color-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { clamp } from 'common/utils/math';
|
||||
|
||||
import './ProgressBar.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
now?: number;
|
||||
complete?: number;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { now = 0, complete = 100, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||
<div
|
||||
className='progress-bar__indicator'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
@use '../../../theme/main' as *;
|
||||
@use '../../../theme/mixins' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.container {
|
||||
background: $bg-black;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
|
||||
color: $ontime-pink;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
background: $bg-container-l1;
|
||||
color: $ontime-color;
|
||||
font-family: $ontime-font-family;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
@@ -37,9 +35,9 @@
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $ontime-pink;
|
||||
background: $action-blue;
|
||||
}
|
||||
to {
|
||||
background: rgba($ontime-pink, 0);
|
||||
background: rgba($action-blue, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.schedule {
|
||||
width: 100%;
|
||||
border-spacing: 50px;
|
||||
|
||||
.entry {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
|
||||
.entry-colour {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.entry-times {
|
||||
font-family: $viewer-font-family;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: clamp(16px, 1.5vw, 24px);
|
||||
}
|
||||
|
||||
&--past {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
|
||||
&--now {
|
||||
.entry-title {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.schedule-nav__item {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-left: 8px;
|
||||
|
||||
&--selected {
|
||||
background-color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Empty from '../state/Empty';
|
||||
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Schedule({ className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage } = useSchedule();
|
||||
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return <Empty text='No events to show' />;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
const selectedEvent = paginatedEvents.find((event) => event.id === selectedEventId);
|
||||
|
||||
return (
|
||||
<ul className={`schedule ${className}`}>
|
||||
{selectedEvent && (
|
||||
<ScheduleItem
|
||||
key={selectedEvent.id}
|
||||
selected='now'
|
||||
timeStart={selectedEvent.timeStart}
|
||||
timeEnd={selectedEvent.timeEnd}
|
||||
title={selectedEvent.title}
|
||||
presenter={selectedEvent.presenter}
|
||||
colour={isBackstage ? selectedEvent.colour : ''}
|
||||
backstageEvent={!selectedEvent.isPublic}
|
||||
skip={selectedEvent.skip}
|
||||
/>
|
||||
)}
|
||||
{paginatedEvents.map((event) => {
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
selected={selectedState}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : ''}
|
||||
backstageEvent={!event.isPublic}
|
||||
skip={event.skip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
import { OntimeEvent } from '../../models/EventTypes';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
paginatedEvents: OntimeEvent[];
|
||||
selectedEventId: string;
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
isBackstage: boolean;
|
||||
}
|
||||
|
||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
events: OntimeEvent[];
|
||||
selectedEventId: string;
|
||||
isBackstage?: boolean;
|
||||
eventsPerPage?: number;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export const ScheduleProvider = (
|
||||
{
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
eventsPerPage = 4,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
|
||||
const numPages = Math.ceil(events.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
}, time * 1000);
|
||||
|
||||
return (
|
||||
<ScheduleContext.Provider
|
||||
value={{
|
||||
events,
|
||||
paginatedEvents,
|
||||
selectedEventId,
|
||||
numPages,
|
||||
visiblePage,
|
||||
isBackstage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScheduleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSchedule = () => {
|
||||
const context = useContext(ScheduleContext);
|
||||
if (!context) {
|
||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { formatTime } from '../../utils/time';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleItemProps {
|
||||
selected: 'past' | 'now' | 'future';
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
presenter?: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const {
|
||||
selected,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
title,
|
||||
presenter,
|
||||
backstageEvent,
|
||||
colour,
|
||||
skip,
|
||||
} = props;
|
||||
|
||||
const start = formatTime(timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(timeEnd, { format: 'hh:mm' });
|
||||
const userColour = colour !== '' ? colour : '';
|
||||
const selectStyle = `entry--${selected}`;
|
||||
|
||||
return (
|
||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||
{`${start} → ${end} ${backstageEvent ? '*' : ''}`}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
{presenter && (
|
||||
<div className='entry-presenter'>{presenter}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleNavProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleNav({ className }: ScheduleNavProps) {
|
||||
const { numPages, visiblePage } = useSchedule();
|
||||
|
||||
return (
|
||||
<div className={`schedule-nav ${className}`}>
|
||||
{numPages > 1 &&
|
||||
[...Array(numPages).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text: string;
|
||||
style: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.timer {
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
font-size: 20vw;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
|
||||
&--small {
|
||||
font-size: 3.75em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDisplay, millisToSeconds } from 'common/utils/dateConfig';
|
||||
|
||||
import './TimerDisplay.scss';
|
||||
|
||||
interface TimerDisplayProps {
|
||||
time?: number | null;
|
||||
small?: boolean;
|
||||
hideZeroHours?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays time in ms in formatted timetag
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
const TimerDisplay = (props: TimerDisplayProps) => {
|
||||
const { time, small, hideZeroHours, className = '' } = props;
|
||||
|
||||
console.log('debug', time)
|
||||
|
||||
const display =
|
||||
(time === null || typeof time === 'undefined' || isNaN(time))
|
||||
? '-- : -- : --'
|
||||
: formatDisplay(millisToSeconds(time), hideZeroHours);
|
||||
|
||||
const isNegative = (time ?? 0) < 0;
|
||||
const classes = `timer ${small ? 'timer--small' : ''} ${isNegative ? 'timer--finished' : ''} ${className}`;
|
||||
|
||||
return <div className={classes}>{display}</div>;
|
||||
};
|
||||
|
||||
export default memo(TimerDisplay);
|
||||
@@ -1,41 +1,36 @@
|
||||
@use '../../../theme/main';
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
.title-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.title,
|
||||
.subtitle,
|
||||
.presenter {
|
||||
@include main.ellipsis;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $title-color;
|
||||
font-weight: 600;
|
||||
font-size: 2.5vw;
|
||||
flex: 1;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.presenter {
|
||||
color: $subtitle-color;
|
||||
}
|
||||
.subtitle, .presenter {
|
||||
font-size: clamp(24px, 2vw, 35px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5vw;
|
||||
font-weight: 200;
|
||||
margin-top: -0.8vh;
|
||||
}
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
.presenter {
|
||||
font-size: 2vw;
|
||||
}
|
||||
|
||||
.title:after,
|
||||
.presenter:after,
|
||||
.subtitle:after {
|
||||
content: '\200b';
|
||||
&.accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: string;
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
presenter: string;
|
||||
@@ -9,13 +9,16 @@ interface TitleCardProps {
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, subtitle, presenter } = props;
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='label'>{label}</div>
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='presenter'>{presenter}</span>
|
||||
<span className={accent? 'label accent': 'label'}>{label}</span>
|
||||
</div>
|
||||
<div className='title'>{title}</div>
|
||||
<div className='presenter'>{presenter}</div>
|
||||
<div className='subtitle'>{subtitle}</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './TitleSide.scss';
|
||||
|
||||
export default function TitleSide(props) {
|
||||
const { type, label, title, subtitle, presenter } = props;
|
||||
|
||||
const isNext = type === 'next';
|
||||
|
||||
return (
|
||||
<div className='title-side'>
|
||||
<div className='label'>{label}</div>
|
||||
<div className={isNext ? 'title next' : 'title now'}>{title}</div>
|
||||
<div className={isNext ? 'presenter next' : 'presenter now'}>{presenter}</div>
|
||||
<div className={isNext ? 'subtitle next' : 'subtitle now'}>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TitleSide.propTypes = {
|
||||
type: PropTypes.oneOf(['now', 'next']),
|
||||
label: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
subtitle: PropTypes.string,
|
||||
presenter: PropTypes.string,
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.title-side {
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle,
|
||||
.presenter {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $title-color;
|
||||
font-weight: 600;
|
||||
|
||||
&.now {
|
||||
font-size: 2.5vw;
|
||||
}
|
||||
|
||||
&.next {
|
||||
font-size: 2vw;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $subtitle-color;
|
||||
&.now {
|
||||
font-size: 1.2vw;
|
||||
margin-top: -1vh;
|
||||
}
|
||||
|
||||
&.next {
|
||||
font-size: 1.1vw;
|
||||
margin-top: -0.8vh;
|
||||
}
|
||||
}
|
||||
|
||||
.presenter {
|
||||
font-weight: 400;
|
||||
color: $subtitle-color;
|
||||
|
||||
&.now {
|
||||
font-size: 2vw;
|
||||
}
|
||||
|
||||
&.next {
|
||||
font-size: 1.5vw;
|
||||
}
|
||||
}
|
||||
|
||||
.title:after,
|
||||
.subtitle:after,
|
||||
.presenter:after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../theme/main' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.modalBody {
|
||||
min-height: 40vh;
|
||||
@@ -18,11 +18,11 @@
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: $bg-gray;
|
||||
background-color: $bg-container-onlight;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 2px;
|
||||
color: black;
|
||||
color: $text-black;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import useClickOutside from '../useClickOutside';
|
||||
|
||||
describe('useClickOutside', () => {
|
||||
let target: HTMLElement;
|
||||
let anotherElement: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
target = global.document.createElement('div');
|
||||
global.document.body.appendChild(target);
|
||||
|
||||
anotherElement = global.document.createElement('div');
|
||||
global.document.body.appendChild(anotherElement);
|
||||
});
|
||||
|
||||
it('should trigger clicking outside', () => {
|
||||
const ref = { current: target };
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useClickOutside(ref, callback));
|
||||
|
||||
act(() => {
|
||||
global.document.dispatchEvent(new Event('click'));
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
anotherElement.click();
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not trigger clicking inside', () => {
|
||||
const ref = { current: target };
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useClickOutside(ref, callback));
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
type ClickOutsideEventHandler = (event: MouseEvent) => void;
|
||||
|
||||
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
callback: ClickOutsideEventHandler,
|
||||
) {
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(event: MouseEvent) {
|
||||
const element = ref?.current;
|
||||
|
||||
// Do nothing if clicking ref's element or descendent element
|
||||
if (!element || element.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
callback(event);
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Playback } from './OntimeTypes';
|
||||
|
||||
export type TimeManagerType = {
|
||||
clock: number;
|
||||
current: null | number;
|
||||
elapsed: null | number;
|
||||
expectedFinish: null | number;
|
||||
addedTime: number;
|
||||
startedAt: null | number;
|
||||
finishedAt: null | number;
|
||||
secondaryTimer: null | number;
|
||||
|
||||
finished: boolean;
|
||||
playback: Playback;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Playstate } from './OntimeTypes';
|
||||
|
||||
export type TimeManagerType = {
|
||||
clock: number,
|
||||
running: number,
|
||||
isNegative: boolean;
|
||||
startedAt: null | number;
|
||||
expectedFinish: null | number;
|
||||
finished: boolean;
|
||||
playstate: Playstate
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const timeFormatSeconds = 'HH:mm:ss';
|
||||
/**
|
||||
* another go at simpler string formatting (counters)
|
||||
* @description Converts seconds to string representing time
|
||||
* @param {number} seconds - time in seconds
|
||||
* @param {number | null} seconds - time in seconds
|
||||
* @param {boolean} [hideZero] - whether to show hours in case its 00
|
||||
* @returns {string} String representing absolute time 00:12:02
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OntimeEvent, OntimeRundownEntry } from '../models/EventTypes';
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../models/EventTypes';
|
||||
|
||||
import { formatTime } from './time';
|
||||
|
||||
@@ -8,24 +8,24 @@ import { formatTime } from './time';
|
||||
* @returns {Object[]} Filtered events with calculated delays
|
||||
*/
|
||||
|
||||
export const getEventsWithDelay = (events: OntimeRundownEntry[]) => {
|
||||
export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[] => {
|
||||
if (events == null) return [];
|
||||
|
||||
const unfilteredEvents = [...events];
|
||||
|
||||
// Add running delay
|
||||
let delay = 0;
|
||||
for (const e of unfilteredEvents) {
|
||||
if (e.type === 'block') delay = 0;
|
||||
else if (e.type === 'delay') delay = delay + e.duration;
|
||||
else if (e.type === 'event' && delay > 0) {
|
||||
e.timeStart += delay;
|
||||
e.timeEnd += delay;
|
||||
for (const event of unfilteredEvents) {
|
||||
if (event.type === SupportedEvent.Block) delay = 0;
|
||||
else if (event.type === SupportedEvent.Delay) delay = delay + event.duration;
|
||||
else if (event.type === SupportedEvent.Event && delay > 0) {
|
||||
event.timeStart += delay;
|
||||
event.timeEnd += delay;
|
||||
}
|
||||
}
|
||||
|
||||
// filter just events
|
||||
return unfilteredEvents.filter((e) => e.type === 'event');
|
||||
return unfilteredEvents.filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,7 +100,7 @@ export const formatEventList = (events: OntimeEvent[], selectedId: string, nextI
|
||||
type ClonedEvent = OntimeEvent | { after?: string };
|
||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||
return {
|
||||
type: 'event',
|
||||
type: SupportedEvent.Event,
|
||||
title: event.title,
|
||||
subtitle: event.subtitle,
|
||||
presenter: event.presenter,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
@use '../theme/main' as *;
|
||||
@use '../theme/v2Styles' as *;
|
||||
|
||||
.wrapper {
|
||||
background: $bg-black;
|
||||
background: $bg-container-l1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
color: $title-white;
|
||||
padding: max(16px, 2vh);
|
||||
}
|
||||
@@ -22,8 +22,8 @@ export default function MessageControl() {
|
||||
actionHandler={() => setMessage.presenterVisible(!data?.presenter.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Public screen message'
|
||||
placeholder='Shown in public screens'
|
||||
label='Public / Backstage screen message'
|
||||
placeholder='Shown in public and backstage screens'
|
||||
text={data?.public.text || ''}
|
||||
visible={data?.public.visible || false}
|
||||
changeHandler={(newValue) => setMessage.publicText(newValue)}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import TimerDisplay from 'common/components/countdown/TimerDisplay';
|
||||
import TimerDisplay from 'common/components/timer-display/TimerDisplay';
|
||||
|
||||
import { setPlayback, useTimer } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
import { millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { stringFromMillis } from '../../../common/utils/time';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
@@ -37,8 +36,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
</div>
|
||||
<div className={style.timer}>
|
||||
<TimerDisplay
|
||||
time={isWaiting ? millisToSeconds(timerData.secondaryTimer) : millisToSeconds(timerData.current)}
|
||||
isNegative={isOvertime}
|
||||
time={isWaiting ? timerData.secondaryTimer : timerData.current}
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@use '../../theme/main' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.gap {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.open {
|
||||
background: $light-bg;
|
||||
background: $blue-700;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../theme/main' as *;
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
.progressBar {
|
||||
// layout
|
||||
@@ -11,7 +11,7 @@
|
||||
transition-property: width;
|
||||
|
||||
&.play {
|
||||
background-color: $ontime-accent;
|
||||
background-color: $playback-start;
|
||||
}
|
||||
|
||||
&.pause {
|
||||
@@ -23,7 +23,6 @@
|
||||
}
|
||||
|
||||
&.overtime {
|
||||
background-color: $ontime-pink-variant;
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../theme/main' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.quickAdd {
|
||||
display: grid;
|
||||
|
||||
@@ -17,12 +17,15 @@ const withSocket = (Component) => {
|
||||
|
||||
const [publicSelectedId, setPublicSelectedId] = useState(null);
|
||||
|
||||
const [timer] = useSubscription('timer', {
|
||||
clock: 0,
|
||||
running: 0,
|
||||
isNegative: false,
|
||||
startedAt: 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: '',
|
||||
@@ -107,7 +110,7 @@ const withSocket = (Component) => {
|
||||
// get clock string
|
||||
const TimeManagerType = {
|
||||
...timer,
|
||||
finished: playback === 'play' && timer.isNegative && timer.startedAt,
|
||||
finished: playback === 'play' && timer.current < 0 && timer.startedAt,
|
||||
playback,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import TitleSide from 'common/components/title-side/TitleSide';
|
||||
import { formatDisplay } from 'common/utils/dateConfig';
|
||||
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 Paginator from '../../../common/components/paginator/Paginator';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
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',
|
||||
};
|
||||
|
||||
export default function Backstage(props) {
|
||||
const { publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [filteredEvents, setFilteredEvents] = useState(null);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Backstage Screen';
|
||||
}, []);
|
||||
|
||||
// calculate delays if any
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
const f = getEventsWithDelay(backstageEvents);
|
||||
setFilteredEvents(f);
|
||||
}, [backstageEvents]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format messages
|
||||
const showPubl = publ.text !== '' && publ.visible;
|
||||
|
||||
let stageTimer;
|
||||
if (time.running === null) {
|
||||
stageTimer = '- - : - -';
|
||||
} else {
|
||||
stageTimer = formatDisplay(Math.abs(time.running), true);
|
||||
if (time.isNegative) stageTimer = `-${stageTimer}`;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<NavigationMenu />
|
||||
|
||||
<div className='event-title'>{general.title}</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{title.showNow && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleSide
|
||||
label='Now'
|
||||
type='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'
|
||||
>
|
||||
<TitleSide
|
||||
label='Next'
|
||||
type='next'
|
||||
title={title.titleNext}
|
||||
subtitle={title.subtitleNext}
|
||||
presenter={title.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className='today-container'>
|
||||
<div className='today-header-block'>
|
||||
<div className='label'>Today</div>
|
||||
<div className='nav'>
|
||||
{pageNumber > 1 &&
|
||||
[...Array(pageNumber).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Paginator
|
||||
selectedId={selectedId}
|
||||
events={filteredEvents}
|
||||
isBackstage
|
||||
setCurrentPage={setCurrentPage}
|
||||
setPageNumber={setPageNumber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={showPubl ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='clock'>{clock}</div>
|
||||
</div>
|
||||
|
||||
<div className='timer-container'>
|
||||
<div className='label'>Stage Timer</div>
|
||||
<div className='timer'>{stageTimer}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='label'>Info</div>
|
||||
<div className='info__message'>{general.backstageInfo}</div>
|
||||
<div className='qr'>
|
||||
{general.url != null && general.url !== '' && (
|
||||
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Backstage.propTypes = {
|
||||
publ: PropTypes.object,
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.array,
|
||||
selectedId: PropTypes.string,
|
||||
general: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -5,148 +5,145 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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);
|
||||
font-weight: 300;
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 3vw 2fr;
|
||||
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh;
|
||||
grid-template-columns: 1fr 1fr 40vw;
|
||||
grid-template-rows: auto 12px 1fr auto;
|
||||
grid-template-areas:
|
||||
' titl titl titl . schd'
|
||||
' now now now . schd'
|
||||
' next next .... . schd'
|
||||
' ... .... .... . ....'
|
||||
' publ publ clck . info'
|
||||
' publ publ time . info';
|
||||
gap: 1vw;
|
||||
padding: 1vw;
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
|
||||
.event,
|
||||
.public-container,
|
||||
.info,
|
||||
.timer-container,
|
||||
.clock-container,
|
||||
.today-container {
|
||||
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 1vw;
|
||||
border-radius: $element-border-radius;
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
|
||||
.clock,
|
||||
.timer {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 2.25vw;
|
||||
line-height: 3vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25vw;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
border-radius: 0 $element-border-radius $element-border-radius 0;
|
||||
margin-left: -1vw;
|
||||
padding: 1vh 2vw;
|
||||
overflow: hidden;
|
||||
|
||||
&.now {
|
||||
grid-area: now;
|
||||
}
|
||||
|
||||
&.next {
|
||||
grid-area: next;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
grid-area: titl;
|
||||
font-size: 3vw;
|
||||
.event-header {
|
||||
grid-area: header;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
text-decoration: underline 0.5vh;
|
||||
text-decoration-color: var(--accent-color-override, $accent-color);
|
||||
padding-top: 0.2vh;
|
||||
padding-left: 1vw;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.today-container {
|
||||
grid-area: schd;
|
||||
padding: 2.5vh 2vw;
|
||||
overflow: hidden;
|
||||
margin-top: 3vh;
|
||||
height: 95%;
|
||||
|
||||
.today-header-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5vh;
|
||||
|
||||
.nav {
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 2.5vh;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
background-color: $today-item-bg;
|
||||
width: 0.7vw;
|
||||
height: 0.7vw;
|
||||
border-radius: 0.35vw;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: publ;
|
||||
grid-area: message;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: time;
|
||||
border-radius: 0 0 $element-border-radius $element-border-radius;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.timer-container {
|
||||
grid-area: clck;
|
||||
border-radius: $element-border-radius $element-border-radius 0 0;
|
||||
/* =================== 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: grid;
|
||||
padding: 2.5vh 2vw;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
|
||||
&__message {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
line-height: 1.3em;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: qr;
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { formatDisplay, millisToSeconds } from 'common/utils/dateConfig';
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.clock-view {
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1vw;
|
||||
border: 1vw solid transparent;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
.clock {
|
||||
font-family: "Arial Black", sans-serif;
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $viewer-color);
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
background-color: transparent;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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/TimeManaget.type';
|
||||
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';
|
||||
|
||||
@@ -5,25 +5,27 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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);
|
||||
padding: 1vw;
|
||||
|
||||
/* =================== MAIN - SELECT ===================*/
|
||||
|
||||
.event-select {
|
||||
display: flex;
|
||||
margin-top: 8vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: max(1.3vw, 12px);
|
||||
flex-direction: column;
|
||||
|
||||
&__title {
|
||||
font-size: max(2vw, 14px);
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
}
|
||||
|
||||
&__events {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
margin-top: 1em;
|
||||
overflow-y: auto;
|
||||
height: 70vh;
|
||||
@@ -31,21 +33,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== 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: 1fr auto auto auto 1fr auto;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'title'
|
||||
'header'
|
||||
'status'
|
||||
'clock'
|
||||
'y'
|
||||
'title'
|
||||
'timers';
|
||||
gap: 1vw;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
/* =================== 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;
|
||||
@@ -55,16 +124,17 @@
|
||||
|
||||
.aux-timers {
|
||||
text-align: center;
|
||||
font-size: clamp(24px, 1.75vw, 32px);
|
||||
|
||||
&__label {
|
||||
font-size: max(1.3vw, 12px);
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: max(2vw, 14px);
|
||||
letter-spacing: 0.3px;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--delayed {
|
||||
color: $delay-color;
|
||||
@@ -72,42 +142,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: max(4vw, 18px);
|
||||
align-self: center;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 2vw;
|
||||
border-radius: $element-border-radius;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
padding-left: 5vw;
|
||||
font-size: max(2.5vw, 14px);
|
||||
justify-self: flex-start;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: clock;
|
||||
line-height: 20vw;
|
||||
font-size: 21vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.4vw;
|
||||
align-self: flex-start;
|
||||
opacity: 1.0;
|
||||
transition: opacity 0.5s;
|
||||
color: var(--timer-color-override, $viewer-color);
|
||||
|
||||
&--paused {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-55
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
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 Empty from '../../../common/components/state/Empty';
|
||||
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, sanitiseTitle, timerMessages } from './countdown.helpers';
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
@@ -21,15 +22,28 @@ const formatOptions = {
|
||||
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(null);
|
||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||
const [runningTimer, setRunningTimer] = useState(0);
|
||||
const [runningMessage, setRunningMessage] = useState('');
|
||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,17 +60,17 @@ export default function Countdown(props) {
|
||||
const eventId = searchParams.get('eventid');
|
||||
const eventIndex = searchParams.get('event');
|
||||
|
||||
let followThis;
|
||||
const events = [...backstageEvents].filter((event) => event.type === '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);
|
||||
followThis = events.find((event) => event.id === eventId) || null;
|
||||
} else if (eventIndex !== null) {
|
||||
followThis = events?.[eventIndex - 1];
|
||||
followThis = events?.[Number(eventIndex) - 1];
|
||||
}
|
||||
if (typeof followThis !== 'undefined') {
|
||||
if (followThis !== null) {
|
||||
setFollow(followThis);
|
||||
const idx = backstageEvents.findIndex((event) => event.id === followThis.id);
|
||||
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
|
||||
const delayToEvent = getDelayTo(backstageEvents, idx);
|
||||
setDelay(delayToEvent);
|
||||
}
|
||||
@@ -77,9 +91,9 @@ export default function Countdown(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const standby = time.playback !== 'start' && selectedId === follow?.id;
|
||||
const isRunningFinished = time.finished && runningMessage === timerMessages.running;
|
||||
const isSelected = runningMessage === timerMessages.running;
|
||||
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);
|
||||
@@ -91,36 +105,38 @@ export default function Countdown(props) {
|
||||
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 ? (
|
||||
<div className='event-select' data-testid='countdown-select'>
|
||||
<span className='event-select__title'>Select an event to follow</span>
|
||||
<ul className='event-select__events'>
|
||||
{backstageEvents.length === 0 ? (
|
||||
<Empty text='No events in database' />
|
||||
) : (
|
||||
backstageEvents
|
||||
.filter((e) => e.type === 'event')
|
||||
.map((event, index) => (
|
||||
<li key={event.id}>
|
||||
<Link to={`/countdown?eventid=${event.id}`}>
|
||||
{`${index + 1}. ${sanitiseTitle(event.title)}`}
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<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'>Time Now</div>
|
||||
<span className='aux-timers__value'>{clock}</span>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Start Time</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>
|
||||
@@ -134,27 +150,9 @@ export default function Countdown(props) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='status'>{runningMessage}</div>
|
||||
<span
|
||||
className={`timer ${standby ? 'timer--paused' : ''} ${
|
||||
isRunningFinished ? 'timer--finished' : ''
|
||||
}`}
|
||||
>
|
||||
{formatDisplay(
|
||||
isSelected ? runningTimer : runningTimer + millisToSeconds(delay),
|
||||
isSelected || time.waiting,
|
||||
)}
|
||||
</span>
|
||||
<div className='title'>{follow?.title || 'Untitled Event'}</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Countdown.propTypes = {
|
||||
backstageEvents: PropTypes.array,
|
||||
time: PropTypes.object,
|
||||
selectedId: PropTypes.string,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description parses string as a title
|
||||
* @param {string|null} title
|
||||
* @return {string}
|
||||
*/
|
||||
export const sanitiseTitle = (title) =>
|
||||
title === null || title === '' || typeof title === 'undefined' ? '{no title}' : title;
|
||||
|
||||
/**
|
||||
* @description object with possible timer messages
|
||||
* @type {{running: string, toStart: string, waiting: string, ended: string}}
|
||||
*/
|
||||
export const timerMessages = {
|
||||
toStart: 'Time to start',
|
||||
waiting: 'Waiting for event start',
|
||||
running: 'Event running',
|
||||
ended: 'Event ended at',
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns a parsed timer and relevant status message
|
||||
* @param {object} time
|
||||
* @param {object} follow
|
||||
* @param {string} selectedId
|
||||
* @return {{timer: number, message: string}}
|
||||
*/
|
||||
export const fetchTimerData = (time, follow, selectedId) => {
|
||||
let message = "";
|
||||
let timer = 0;
|
||||
|
||||
if (selectedId === follow.id) {
|
||||
// check that is not running
|
||||
message = time.playback === 'pause' ? timerMessages.waiting : timerMessages.running;
|
||||
timer = time.running;
|
||||
} else if (time.clock < follow.timeStart) {
|
||||
// if it hasnt started, we count to start
|
||||
message = timerMessages.toStart;
|
||||
timer = millisToSeconds(follow.timeStart - time.clock);
|
||||
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||
// if it has started, we show running timer
|
||||
message = timerMessages.waiting;
|
||||
timer = time.running;
|
||||
} else {
|
||||
if (follow.timeStart > follow.timeEnd) {
|
||||
// ends day after
|
||||
if (follow.timeStart > time.clock ) {
|
||||
// if it hasnt started, we count to start
|
||||
message = timerMessages.toStart;
|
||||
timer = millisToSeconds(follow.timeStart - time.clock);
|
||||
} else if (follow.timeStart <= time.clock) {
|
||||
// if it has started, we show running timer
|
||||
message = timerMessages.waiting;
|
||||
timer = time.running;
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = timerMessages.ended;
|
||||
timer = millisToSeconds(follow.timeEnd);
|
||||
}
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = timerMessages.ended;
|
||||
timer = millisToSeconds(follow.timeEnd);
|
||||
}
|
||||
}
|
||||
return { message, timer };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,33 +1,37 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.minimal-timer {
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1vw;
|
||||
border: 1vw solid transparent;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&--finished {
|
||||
border: 1vw solid $timer-finished-color;
|
||||
transition: 0.3s;
|
||||
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
|
||||
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-family: "Arial Black", sans-serif;
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $viewer-color);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
background-color: transparent;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--paused {
|
||||
opacity: 0.6;
|
||||
transition: 0.5s;
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
@@ -48,11 +52,11 @@
|
||||
background-color: $viewer-overlay-bg-color;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ 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/TimeManaget.type';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { ViewSettingsType } from '../../../common/models/ViewSettings.type';
|
||||
import { OverridableOptions } from '../../../common/models/ViewTypes';
|
||||
import { formatDisplay } from '../../../common/utils/dateConfig';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
|
||||
import './MinimalTimer.scss';
|
||||
|
||||
@@ -125,12 +125,22 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== 'pause';
|
||||
const timer = formatDisplay(time.running, true);
|
||||
const clean = timer.replace('/:/g', '');
|
||||
const showFinished = time.isNegative && !userOptions?.hideOvertime;
|
||||
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}
|
||||
@@ -155,14 +165,14 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
}`}
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
fontSize: `${(89 / (clean.length - 1)) * (userOptions.size || 1)}vw`,
|
||||
fontSize: `${(89 / (stageTimerCharacters - 1)) * (userOptions.size || 1)}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
left: userOptions.left,
|
||||
backgroundColor: userOptions.textBackground,
|
||||
}}
|
||||
>
|
||||
{time.isNegative ? `-${timer}` : timer}
|
||||
{stageTimer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { ReactComponent as Emptyimage } from 'assets/images/empty.svg';
|
||||
import { formatDisplay } from 'common/utils/dateConfig';
|
||||
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 Paginator from '../../../common/components/paginator/Paginator';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import './Pip.scss';
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
};
|
||||
|
||||
export default function Pip(props) {
|
||||
const { time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const ref = useRef(null);
|
||||
const [filteredEvents, setFilteredEvents] = useState(null);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Pip';
|
||||
}, []);
|
||||
|
||||
// calculate delays if any
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
|
||||
const events = [...backstageEvents];
|
||||
|
||||
// Add running delay
|
||||
let delay = 0;
|
||||
for (const e of events) {
|
||||
if (e.type === 'block') delay = 0;
|
||||
else if (e.type === 'delay') delay = delay + e.duration;
|
||||
else if (e.type === 'event' && delay > 0) {
|
||||
e.timeStart += delay;
|
||||
e.timeEnd += delay;
|
||||
}
|
||||
}
|
||||
|
||||
// filter just events
|
||||
setFilteredEvents(events.filter((e) => e.type === 'event'));
|
||||
}, [backstageEvents]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format messages
|
||||
const showInfo = general.backstageInfo !== '' && general.backstageInfo != null;
|
||||
let stageTimer = formatDisplay(Math.abs(time.running), true);
|
||||
if (time.isNegative) stageTimer = `-${stageTimer}`;
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
|
||||
return (
|
||||
<div className={`pip ${isMirrored ? 'mirror' : ''}`} data-testid='pip-view'>
|
||||
<NavigationMenu />
|
||||
|
||||
<div className='event-title'>{general.title}</div>
|
||||
|
||||
<div className='today-container'>
|
||||
<div className='today-header-block'>
|
||||
<div className='label'>Today</div>
|
||||
<div className='nav'>
|
||||
{pageNumber > 1 &&
|
||||
[...Array(pageNumber).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Paginator
|
||||
selectedId={selectedId}
|
||||
events={filteredEvents}
|
||||
isBackstage
|
||||
limit={14}
|
||||
time={20}
|
||||
setCurrentPage={setCurrentPage}
|
||||
setPageNumber={setPageNumber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pip-placeholder' ref={ref}>
|
||||
<Emptyimage className='pip-placeholder__empty' />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showInfo && (
|
||||
<motion.div className='info-container'>
|
||||
<div className='label'>Info</div>
|
||||
<div className='info-message'>{general.backstageInfo}</div>
|
||||
<div className='qr'>
|
||||
{general.url != null && general.url !== '' && (
|
||||
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='clock'>{clock}</div>
|
||||
</div>
|
||||
|
||||
<div className='timer-container'>
|
||||
<div className='label'>Stage Timer</div>
|
||||
<div className='timer'>{stageTimer}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Pip.propTypes = {
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.object,
|
||||
selectedId: PropTypes.string,
|
||||
general: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.pip {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
height: 100vh;
|
||||
color: var(--color-override, $viewer-color);
|
||||
font-weight: 300;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 20vw 20vw 18vw 3vw 1fr;
|
||||
grid-template-rows: 60vh 1fr 1fr 1fr;
|
||||
grid-template-areas:
|
||||
' pip pip pip . schd'
|
||||
' titl titl titl . schd'
|
||||
' info info clck . schd'
|
||||
' info info time . schd';
|
||||
gap: 1vw;
|
||||
padding: 1vw;
|
||||
|
||||
.today-container,
|
||||
.info-container,
|
||||
.clock-container,
|
||||
.timer-container {
|
||||
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 1vw;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
|
||||
.clock,
|
||||
.timer {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 2.25vw;
|
||||
line-height: 3vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25vw;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
grid-area: titl;
|
||||
font-size: 3vw;
|
||||
font-weight: 600;
|
||||
text-decoration: underline 0.5vh;
|
||||
text-decoration-color: var(--accent-color-override, $accent-color);
|
||||
padding-top: 0.2vh;
|
||||
padding-left: 1vw;
|
||||
}
|
||||
|
||||
.today-container {
|
||||
grid-area: schd;
|
||||
padding: 2.5vh 2vw;
|
||||
overflow: hidden;
|
||||
margin-top: 3vh;
|
||||
height: 95%;
|
||||
|
||||
.today-header-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5vh;
|
||||
|
||||
.nav {
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 2.5vh;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
background-color: $today-item-bg;
|
||||
width: 0.7vw;
|
||||
height: 0.7vw;
|
||||
border-radius: 0.35vw;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pip-placeholder {
|
||||
grid-area: pip;
|
||||
background-color: $pip-bg-color;
|
||||
border: 1px solid $pip-border-color;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&__empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.info-container {
|
||||
grid-area: info;
|
||||
display: grid;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
|
||||
.info-message {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: qr;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: time;
|
||||
border-radius: 0 0 $element-border-radius $element-border-radius;
|
||||
}
|
||||
|
||||
.timer-container {
|
||||
grid-area: clck;
|
||||
border-radius: $element-border-radius $element-border-radius 0 0;
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import TitleSide from 'common/components/title-side/TitleSide';
|
||||
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 Paginator from '../../../common/components/paginator/Paginator';
|
||||
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',
|
||||
};
|
||||
|
||||
export default function Public(props) {
|
||||
const { publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Public Screen';
|
||||
}, []);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format messages
|
||||
const showPubl = publ.text !== '' && publ.visible;
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<NavigationMenu />
|
||||
|
||||
<div className='event-title'>{general.title}</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicTitle.showNow && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleSide
|
||||
label='Now'
|
||||
type='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'
|
||||
>
|
||||
<TitleSide
|
||||
label='Next'
|
||||
type='next'
|
||||
title={publicTitle.titleNext}
|
||||
subtitle={publicTitle.subtitleNext}
|
||||
presenter={publicTitle.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className='today-container'>
|
||||
<div className='today-header-block'>
|
||||
<div className='label'>Today</div>
|
||||
<div className='nav'>
|
||||
{pageNumber > 1 &&
|
||||
[...Array(pageNumber).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Paginator
|
||||
selectedId={publicSelectedId}
|
||||
events={events}
|
||||
isBackstage
|
||||
setCurrentPage={setCurrentPage}
|
||||
setPageNumber={setPageNumber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={showPubl ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='clock'>{clock}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='label'>Info</div>
|
||||
<div className='info__message'>{general.publicInfo}</div>
|
||||
<div className='qr'>
|
||||
{general.url != null && general.url !== '' && (
|
||||
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Public.propTypes = {
|
||||
publ: PropTypes.object,
|
||||
publicTitle: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
events: PropTypes.array,
|
||||
publicSelectedId: PropTypes.string,
|
||||
general: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -5,149 +5,113 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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);
|
||||
font-weight: 300;
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 3vw 2fr;
|
||||
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh;
|
||||
grid-template-columns: 1fr 1fr 40vw;
|
||||
grid-template-rows: auto 12px 1fr auto;
|
||||
grid-template-areas:
|
||||
' titl titl titl . schd'
|
||||
' now now now . schd'
|
||||
' next next .... . schd'
|
||||
' ... .... .... . ....'
|
||||
' publ publ .... . info'
|
||||
' publ publ time . info';
|
||||
gap: 1vw;
|
||||
padding: 1vw;
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
|
||||
.event,
|
||||
.public-container,
|
||||
.info,
|
||||
.timer-container,
|
||||
.clock-container,
|
||||
.today-container {
|
||||
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 1vw;
|
||||
border-radius: $element-border-radius;
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
|
||||
.clock,
|
||||
.timer {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 2.25vw;
|
||||
line-height: 3vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25vw;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
border-radius: 0 $element-border-radius $element-border-radius 0;
|
||||
margin-left: -1vw;
|
||||
padding: 1vh 2vw;
|
||||
overflow: hidden;
|
||||
|
||||
&.now {
|
||||
grid-area: now;
|
||||
}
|
||||
&.next {
|
||||
grid-area: next;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
grid-area: titl;
|
||||
font-size: 3vw;
|
||||
.event-header {
|
||||
grid-area: header;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
text-decoration: underline 0.5vh;
|
||||
text-decoration-color: var(--accent-color-override, $accent-color);
|
||||
padding-top: 0.2vh;
|
||||
padding-left: 1vw;
|
||||
}
|
||||
|
||||
.today-container {
|
||||
grid-area: schd;
|
||||
padding: 2.5vh 2vw;
|
||||
overflow: hidden;
|
||||
margin-top: 3vh;
|
||||
height: 95%;
|
||||
|
||||
.today-header-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5vh;
|
||||
|
||||
.nav {
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 2.5vh;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
background-color: $today-item-bg;
|
||||
width: 0.7vw;
|
||||
height: 0.7vw;
|
||||
border-radius: 0.35vw;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: publ;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
}
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: time;
|
||||
border-radius: $element-border-radius;
|
||||
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: grid;
|
||||
padding: 2.5vh 2vw;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
|
||||
&__message {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
line-height: 1.3em;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: 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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,16 @@ const formatOptions = {
|
||||
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;
|
||||
|
||||
@@ -34,7 +44,7 @@ export default function StudioClock(props) {
|
||||
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
const MAX_TITLES = 10;
|
||||
const MAX_TITLES = 12;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Studio Clock';
|
||||
@@ -42,7 +52,9 @@ export default function StudioClock(props) {
|
||||
|
||||
// Prepare event list
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
if (!backstageEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayed = getEventsWithDelay(backstageEvents);
|
||||
const events = delayed.filter((e) => e.type === 'event');
|
||||
@@ -55,6 +67,7 @@ export default function StudioClock(props) {
|
||||
|
||||
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'>
|
||||
@@ -64,13 +77,13 @@ export default function StudioClock(props) {
|
||||
<div
|
||||
ref={titleRef}
|
||||
className='next-title'
|
||||
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '82%' }}
|
||||
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
|
||||
>
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div
|
||||
className={time.isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||
{selectedId != null && formatDisplay(time.running)}
|
||||
className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||
{selectedId != null && formatDisplay(time.current)}
|
||||
</div>
|
||||
<div className='clock-indicators'>
|
||||
{activeIndicators.map((i) => (
|
||||
@@ -106,8 +119,8 @@ export default function StudioClock(props) {
|
||||
<li
|
||||
key={s.id}
|
||||
className={s.isNow ? 'now' : s.isNext ? 'next' : ''}
|
||||
style={{ borderLeft: `4px solid ${s.colour !== '' ? s.colour : 'transparent'}` }}
|
||||
>
|
||||
<div className='user-colour' style={{ backgroundColor: `${s.colour !== '' ? s.colour : ''}` }} />
|
||||
{`${s.time} ${s.title}`}
|
||||
</li>
|
||||
))}
|
||||
@@ -117,13 +130,3 @@ export default function StudioClock(props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
StudioClock.propTypes = {
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.array,
|
||||
selectedId: PropTypes.string,
|
||||
nextId: PropTypes.string,
|
||||
onAir: PropTypes.bool,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
|
||||
@@ -142,6 +142,8 @@ $cyan-idle: #0aa;
|
||||
li {
|
||||
margin-bottom: 1.5vh;
|
||||
padding-left: 0.25em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.now {
|
||||
@@ -151,6 +153,14 @@ $cyan-idle: #0aa;
|
||||
.next {
|
||||
color: $red-active;
|
||||
}
|
||||
|
||||
.user-colour {
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
aspect-ratio: 1;
|
||||
background-color: $red-idle;
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,38 +5,61 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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 5vw 1fr 1fr;
|
||||
grid-template-rows: auto 1fr auto minmax(25vh, auto);
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
grid-template-areas:
|
||||
' clck clck .... .... ....'
|
||||
' timr timr timr timr timr'
|
||||
' prog prog prog prog prog'
|
||||
' now now .... next next';
|
||||
gap: 1vw;
|
||||
padding: 1vw;
|
||||
'clock'
|
||||
'timer'
|
||||
'progress'
|
||||
'now'
|
||||
'next';
|
||||
|
||||
&--finished {
|
||||
border: 1vw solid $timer-finished-color;
|
||||
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(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 2vw;
|
||||
border-radius: $element-border-radius;
|
||||
max-width: 100%;
|
||||
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;
|
||||
}
|
||||
@@ -45,15 +68,15 @@
|
||||
/* =================== MAIN ===================*/
|
||||
|
||||
.timer-container {
|
||||
grid-area: timr;
|
||||
grid-area: timer;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
color: white;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
font-size: 12vw;
|
||||
line-height: 18vw;
|
||||
line-height: 0.9em;
|
||||
font-weight: 600;
|
||||
color: $timer-finished-color;
|
||||
padding: 0;
|
||||
@@ -65,17 +88,17 @@
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
grid-area: prog;
|
||||
width: 80%;
|
||||
grid-area: progress;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
.timer--paused,
|
||||
.progress-container--paused {
|
||||
opacity: 0.6;
|
||||
transition: 0.5s;
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,11 +115,11 @@
|
||||
background-color: $viewer-overlay-bg-color;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -108,7 +131,7 @@
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
color: white;
|
||||
color: $viewer-color;
|
||||
transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
font-size: 15vw;
|
||||
@@ -116,23 +139,4 @@
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================== MAIN ===================*/
|
||||
|
||||
.clock-container {
|
||||
grid-area: clck;
|
||||
padding: 1vh 2vw;
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 4vw;
|
||||
letter-spacing: 0.4vw;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+57
-72
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import TimerDisplay from 'common/components/countdown/TimerDisplay';
|
||||
import MyProgressBar from 'common/components/myProgressBar/MyProgressBar';
|
||||
import { useEffect } from 'react';
|
||||
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 { 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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
const formatOptions = {
|
||||
@@ -20,11 +18,34 @@ const formatOptions = {
|
||||
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 [elapsed, setElapsed] = useState(true);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,42 +57,18 @@ export default function Timer(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eg. http://localhost:3000/timer?progress=up
|
||||
// Check for user options
|
||||
// progress: selector
|
||||
// Should be 'up' or 'down'
|
||||
const progress = searchParams.get('progress');
|
||||
if (progress === 'up') {
|
||||
setElapsed(true);
|
||||
} else if (progress === 'down') {
|
||||
setElapsed(false);
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playstate !== 'pause';
|
||||
const normalisedTime = Math.max(time.running, 0);
|
||||
|
||||
// motion
|
||||
const titleVariants = {
|
||||
hidden: {
|
||||
y: 500,
|
||||
},
|
||||
visible: {
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
y: 500,
|
||||
},
|
||||
};
|
||||
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'>
|
||||
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'}>
|
||||
@@ -86,32 +83,28 @@ export default function Timer(props) {
|
||||
<div className='timer-container'>
|
||||
{time.finished ? (
|
||||
<div className='end-message'>
|
||||
{general.endMessage == null || general.endMessage === '' ? (
|
||||
<TimerDisplay time={time.running} isNegative={time.isNegative} hideZeroHours />
|
||||
{!general.endMessage ? (
|
||||
<TimerDisplay time={time.current} hideZeroHours />
|
||||
) : (
|
||||
general.endMessage
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={isPlaying ? 'timer' : 'timer--paused'}>
|
||||
<TimerDisplay time={normalisedTime} hideZeroHours />
|
||||
</div>
|
||||
<TimerDisplay
|
||||
time={normalisedTime}
|
||||
hideZeroHours
|
||||
className={isPlaying ? 'timer' : 'timer--paused'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!time.finished && (
|
||||
<div
|
||||
className={
|
||||
isPlaying ? 'progress-container' : 'progress-container progress-container--paused'
|
||||
}
|
||||
>
|
||||
<MyProgressBar
|
||||
now={normalisedTime}
|
||||
complete={time.durationSeconds}
|
||||
showElapsed={elapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProgressBar
|
||||
className={
|
||||
isPlaying ? 'progress-container' : 'progress-container progress-container--paused'
|
||||
}
|
||||
now={time.current}
|
||||
complete={time.duration}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{title.showNow && (
|
||||
@@ -124,7 +117,7 @@ export default function Timer(props) {
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='Now'
|
||||
label='now'
|
||||
title={title.titleNow}
|
||||
subtitle={title.subtitleNow}
|
||||
presenter={title.presenterNow}
|
||||
@@ -144,7 +137,7 @@ export default function Timer(props) {
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='Next'
|
||||
label='next'
|
||||
title={title.titleNext}
|
||||
subtitle={title.subtitleNext}
|
||||
presenter={title.presenterNext}
|
||||
@@ -155,11 +148,3 @@ export default function Timer(props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Timer.propTypes = {
|
||||
general: PropTypes.object,
|
||||
pres: PropTypes.object,
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import App from './App';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container as Element);
|
||||
|
||||
@@ -18,6 +17,7 @@ Sentry.init({
|
||||
integrations: [new BrowserTracing()],
|
||||
tracesSampleRate: 1.0,
|
||||
release: ONTIME_VERSION,
|
||||
enabled: import.meta.env.PROD,
|
||||
});
|
||||
|
||||
root.render(
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
|
||||
@@ -23,12 +23,15 @@ $ontime-paused: #c05621;
|
||||
$ontime-stop: #E4281E;
|
||||
$playback-negative: $red-500;
|
||||
$active-indicator: #899948;
|
||||
$text-black: $gray-1350;
|
||||
|
||||
// interface panels
|
||||
// interface panels
|
||||
$bg-container-l1: $gray-1350;
|
||||
$bg-container-l2: $gray-1300;
|
||||
$bg-container-l3: $gray-1350;
|
||||
|
||||
$bg-container-onlight: $gray-100;
|
||||
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
@use 'main' as *;
|
||||
@use 'v2Styles' as *;
|
||||
@use 'viewerCommon' as *;
|
||||
@use "ontimeColours" as *;
|
||||
|
||||
// General
|
||||
$viewer-transition-time: 0.5s;
|
||||
|
||||
// Text
|
||||
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
|
||||
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
|
||||
$viewer-opacity-disabled: 0.6;
|
||||
|
||||
// General styling
|
||||
$accent-color: $ontime-pink; // --accent-color-override
|
||||
$delay-color: $delay-text;
|
||||
$accent-color: $red-500; // --accent-color-override
|
||||
$delay-color: $ontime-delay-text;
|
||||
$viewer-label-color: rgba(white, 25%);
|
||||
|
||||
// Main Properties of a viewer
|
||||
$viewer-background-color: $bg-black; // --background-color-override
|
||||
$viewer-color: $title-white; // --color-override
|
||||
$viewer-outdent-bg-color: $bg-container-l1; // --outdent-background-color-override
|
||||
$viewer-overlay-bg-color: $bg-overlay;
|
||||
$viewer-background-color: $ui-black; // --background-color-override
|
||||
$viewer-color: rgba(white, 80%); // --color-override
|
||||
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
||||
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
||||
$viewer-overlay-bg-color: rgba(black, 90%);
|
||||
$element-border-radius: 8px;
|
||||
|
||||
// Properties related to timer
|
||||
$timer-finished-color: $ontime-pink-variant;
|
||||
|
||||
// View specific - Pip
|
||||
$pip-bg-color: $bg-black-100;
|
||||
$pip-border-color: $bg-black-200;
|
||||
$pip-text-color: $bg-gray-900;
|
||||
$today-item-bg: $bg-gray-700;
|
||||
|
||||
// View specific - Titles
|
||||
$title-color: #ddd;
|
||||
$subtitle-color: #aaa;
|
||||
|
||||
// View specific - Paginator
|
||||
$now-bg-color: #ffffff11;
|
||||
$future-bg-color: #ffffff05;
|
||||
$timer-color: rgba(white, 80%); // --timer-color-override
|
||||
$timer-finished-color: $playback-negative;
|
||||
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
@use 'main' as *;
|
||||
|
||||
@mixin viewer-container {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
background: $bg-black;
|
||||
height: 100vh;
|
||||
color: $title-white;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@mixin viewer-event-title {
|
||||
font-size: 3vw;
|
||||
font-weight: 600;
|
||||
text-decoration: underline $ontime-pink 0.5vh;
|
||||
padding-top: 0.2vh;
|
||||
padding-left: 1vw;
|
||||
}
|
||||
|
||||
@mixin viewer-info-messages {
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
////////////////////////////// Specifics
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: $ontime-pink;
|
||||
}
|
||||
|
||||
.info-container > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.next-container > div,
|
||||
.now-container > div {
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
.now-container,
|
||||
.next-container,
|
||||
.today-container {
|
||||
background-color: $bg-gray-1000;
|
||||
padding: 1vh 2vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clock-container,
|
||||
.countdown-container,
|
||||
.public-container,
|
||||
.public-container--hidden {
|
||||
padding: 1vh 1vw;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.public-container--hidden {
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.today-container,
|
||||
.info-container {
|
||||
background-color: $bg-gray-1100;
|
||||
padding: 2.5vh 2vw;
|
||||
}
|
||||
|
||||
.info-container,
|
||||
.today-container,
|
||||
.public-container,
|
||||
.public-container--hidden {
|
||||
border-radius: 1vw;
|
||||
}
|
||||
|
||||
.now-container,
|
||||
.next-container {
|
||||
margin-left: -1vw;
|
||||
}
|
||||
|
||||
.qr {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 2.25vw;
|
||||
line-height: 3vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25vw;
|
||||
color: $clocks;
|
||||
}
|
||||
|
||||
.info-container {
|
||||
grid-area: info;
|
||||
display: grid;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
}
|
||||
|
||||
.info-messages {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: time;
|
||||
border-radius: 1vw;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 3vw;
|
||||
line-height: 3vw;
|
||||
padding: 0.5vh 0 0.5vh 1vw;
|
||||
}
|
||||
|
||||
.today-container {
|
||||
margin-top: 3vh;
|
||||
height: 95%;
|
||||
|
||||
.today-header-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5vh;
|
||||
|
||||
.nav-item,
|
||||
.nav-item--selected {
|
||||
background-color: $bg-gray-700;
|
||||
width: 0.7vw;
|
||||
height: 0.7vw;
|
||||
border-radius: 0.35vw;
|
||||
}
|
||||
|
||||
.navItemSelected {
|
||||
background-color: $bg-gray-100;
|
||||
}
|
||||
|
||||
.nav {
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 2.5vh;
|
||||
}
|
||||
|
||||
.nav > div {
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-container {
|
||||
display: grid;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
|
||||
.info-messages {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: time;
|
||||
border-radius: 1vw;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
@use 'main' as *;
|
||||
|
||||
@mixin viewer-container {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
background: $bg-black;
|
||||
height: 100vh;
|
||||
color: $title-white;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@mixin viewer-event-title {
|
||||
font-size: 3vw;
|
||||
font-weight: 600;
|
||||
text-decoration: underline $ontime-pink 0.5vh;
|
||||
padding-top: 0.2vh;
|
||||
padding-left: 1vw;
|
||||
}
|
||||
|
||||
@mixin viewer-info-messages {
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
////////////////////////////// Specifics
|
||||
|
||||
.label {
|
||||
font-size: 1.3vw;
|
||||
color: $ontime-pink;
|
||||
}
|
||||
|
||||
.infoContainer > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nextContainer > div,
|
||||
.nowContainer > div {
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
.nowContainer,
|
||||
.nextContainer,
|
||||
.todayContainer {
|
||||
background-color: $bg-gray-1000;
|
||||
padding: 1vh 2vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clockContainer,
|
||||
.countdownContainer,
|
||||
.publicContainer,
|
||||
.publicContainerHidden {
|
||||
padding: 1vh 1vw;
|
||||
}
|
||||
|
||||
.publicContainer {
|
||||
opacity: 1;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.publicContainerHidden {
|
||||
opacity: 0;
|
||||
transition: 0.5s;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.todayContainer,
|
||||
.infoContainer {
|
||||
background-color: $bg-gray-1100;
|
||||
padding: 2.5vh 2vw;
|
||||
}
|
||||
|
||||
.infoContainer,
|
||||
.todayContainer,
|
||||
.publicContainer,
|
||||
.publicContainerHidden {
|
||||
border-radius: 1vw;
|
||||
}
|
||||
|
||||
.nowContainer,
|
||||
.nextContainer {
|
||||
margin-left: -1vw;
|
||||
}
|
||||
|
||||
.qr {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 3vw;
|
||||
line-height: 3vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25vw;
|
||||
color: $clocks;
|
||||
}
|
||||
|
||||
.infoContainer {
|
||||
grid-area: info;
|
||||
display: grid;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
}
|
||||
|
||||
.infoMessages {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.clockContainer {
|
||||
grid-area: time;
|
||||
border-radius: 1vw;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 3vw;
|
||||
line-height: 3vw;
|
||||
padding: 0.5vh 0 0.5vh 1vw;
|
||||
}
|
||||
|
||||
.todayContainer {
|
||||
margin-top: 3vh;
|
||||
height: 95%;
|
||||
|
||||
.todayHeaderBlock {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5vh;
|
||||
|
||||
.navItem,
|
||||
.navItemSelected {
|
||||
background-color: $bg-gray-700;
|
||||
width: 0.7vw;
|
||||
height: 0.7vw;
|
||||
border-radius: 0.35vw;
|
||||
}
|
||||
|
||||
.navItemSelected {
|
||||
background-color: $bg-gray-100;
|
||||
}
|
||||
|
||||
.nav {
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
margin-bottom: 2.5vh;
|
||||
}
|
||||
|
||||
.nav > div {
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.infoContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-rows: 3vh minmax(0, 1fr);
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas:
|
||||
'titl .'
|
||||
'binf qr';
|
||||
gap: 0.5vw;
|
||||
|
||||
.infoMessages {
|
||||
grid-area: binf;
|
||||
font-size: 1.5vw;
|
||||
line-height: 2vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.clockContainer {
|
||||
grid-area: time;
|
||||
border-radius: 1vw;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
export const ontimeTooltip = {
|
||||
_light: {
|
||||
backgroundColor: '#2d2d2d',
|
||||
color: '#779BE7',
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
color: '#ececec', // $gray-100
|
||||
padding: '2px 8px',
|
||||
},
|
||||
_dark: {
|
||||
backgroundColor: '#2d2d2d',
|
||||
color: '#779BE7',
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
color: '#ececec', // $gray-100
|
||||
padding: '2px 8px',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ export const navigatorConstants = [
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/pip', label: 'PiP' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
{ url: '/countdown', label: 'Countdown' },
|
||||
];
|
||||
|
||||
+1556
-1547
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -1,11 +1,15 @@
|
||||
:root {
|
||||
--background-color-override: #f3f4f8;
|
||||
--color-override: #5051f9;
|
||||
--accent-color-override: #23235f;
|
||||
--timer-color-override: #5051f9;
|
||||
--outdent-background-color-override: white;
|
||||
--background-color-override: #ececec;
|
||||
--color-override: #101010;
|
||||
--secondary-color-override: #404040;
|
||||
--accent-color-override: #FA5656;
|
||||
--label-color-override: #6c6c6c;
|
||||
--timer-color-override: #202020;
|
||||
--card-background-color-override: #FFF;
|
||||
--font-family-override: "Open Sans";
|
||||
--font-family-bold-override: "Arial Black";
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-family: "Comic Sans MS", serif !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user