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:
Carlos Valente
2022-12-30 11:41:03 +01:00
committed by GitHub
parent 110d3fb7a3
commit 832c060940
71 changed files with 3114 additions and 3657 deletions
+31 -33
View File
@@ -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"
-3
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
*/
+10 -10
View File
@@ -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>
+2 -2
View File
@@ -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;
+9 -6
View File
@@ -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>
);
}
+11 -12
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
}
}
}
}
@@ -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,
};
+85 -121
View File
@@ -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;
}
}
}
}
+51 -47
View File
@@ -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;
}
}
}
}
@@ -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,
};
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+18 -23
View File
@@ -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
-189
View File
@@ -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;
}
}
-189
View File
@@ -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;
}
}
+4 -4
View File
@@ -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',
},
};
-1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -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;
}