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", "version": "1.9.7",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.4.1", "@chakra-ui/react": "^2.4.5",
"@dnd-kit/core": "^6.0.5", "@dnd-kit/core": "^6.0.6",
"@dnd-kit/sortable": "^7.0.1", "@dnd-kit/sortable": "^7.0.1",
"@dnd-kit/utilities": "^3.2.0", "@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.24.1", "@sentry/react": "^7.28.1",
"@sentry/tracing": "^7.24.1", "@sentry/tracing": "^7.24.1",
"@tanstack/react-query": "^4.18.0", "@tanstack/react-query": "^4.18.0",
"@tanstack/react-query-devtools": "^4.18.0", "@tanstack/react-query-devtools": "^4.18.0",
"autosize": "^5.0.1", "autosize": "^5.0.2",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.2.3", "csv-stringify": "^6.2.3",
"framer-motion": "^7.5.3", "framer-motion": "^8.0.2",
"jotai": "^1.10.0", "jotai": "^1.10.0",
"luxon": "^3.1.0", "luxon": "^3.1.0",
"react": "^18.1.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.1.0", "react-dom": "^18.2.0",
"react-fast-compare": "^3.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-router-dom": "^6.3.0",
"react-table": "^7.7.0", "react-table": "^7.7.0",
"socket.io-client": "^4.5.4", "socket.io-client": "^4.5.4",
@@ -53,40 +53,38 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^0.2.4", "@sentry/vite-plugin": "^0.3.0",
"@tanstack/eslint-plugin-query": "^4.15.1", "@tanstack/eslint-plugin-query": "^4.15.1",
"@testing-library/jest-dom": "^5.16.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1", "@testing-library/react": "^13.1.1",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/user-event": "^14.1.1", "@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/node": "^18.7.16", "@types/node": "^18.11.18",
"@types/react": "^18.0.21", "@types/react": "^18.0.26",
"@types/react-beautiful-dnd": "^13.1.2", "@types/react-beautiful-dnd": "^13.1.3",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "^5.37.0", "@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^5.37.0", "@typescript-eslint/parser": "^5.47.1",
"@vitejs/plugin-react": "^2.2.0", "@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.25.0", "eslint": "^8.25.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.0.4", "eslint-plugin-jest": "^27.1.7",
"eslint-plugin-react": "^7.31.8", "eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.6.4", "eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^20.0.0", "jsdom": "^20.0.3",
"prettier": "^2.7.1", "prettier": "^2.8.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-test-renderer": "^18.1.0", "sass": "^1.57.1",
"sass": "^1.51.0", "stylelint": "^14.16.0",
"stylelint": "^14.9.1", "stylelint-config-prettier": "^9.0.4",
"stylelint-config-prettier": "^9.0.3", "stylelint-config-standard-scss": "^6.1.0",
"stylelint-config-standard-scss": "^4.0.0",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"vite": "^3.2.4", "vite": "^4.0.3",
"vite-plugin-svgr": "^2.2.2", "vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^3.6.0", "vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.25.3" "vitest": "^0.26.2"
}, },
"resolutions": { "resolutions": {
"**/@types/react": "18.0.21" "**/@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 Backstage = lazy(() => import('features/viewers/backstage/Backstage'));
const Public = lazy(() => import('features/viewers/public/Public')); const Public = lazy(() => import('features/viewers/public/Public'));
const Lower = lazy(() => import('features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('features/viewers/lower-thirds/LowerWrapper'));
const Pip = lazy(() => import('features/viewers/picture-in-picture/Pip'));
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
const STimer = withSocket(TimerView); const STimer = withSocket(TimerView);
@@ -25,7 +24,6 @@ const SCountdown = withSocket(Countdown);
const SBackstage = withSocket(Backstage); const SBackstage = withSocket(Backstage);
const SPublic = withSocket(Public); const SPublic = withSocket(Public);
const SLowerThird = withSocket(Lower); const SLowerThird = withSocket(Lower);
const SPip = withSocket(Pip);
const SStudio = withSocket(StudioClock); const SStudio = withSocket(StudioClock);
const FeatureWrapper = lazy(() => import('features/FeatureWrapper')); const FeatureWrapper = lazy(() => import('features/FeatureWrapper'));
@@ -70,7 +68,6 @@ export default function AppRouter() {
<Route path='/backstage' element={<SBackstage />} /> <Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} /> <Route path='/public' element={<SPublic />} />
<Route path='/pip' element={<SPip />} />
<Route path='/studio' element={<SStudio />} /> <Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/} {/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} /> <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 { .errorContainer {
width: 100%; width: 100%;
@@ -9,17 +9,17 @@
color: white; color: white;
.error { .error {
color: $ontime-pink; color: $error-red;
font-weight: 600; font-weight: 600;
} }
.report { .report {
text-decoration: underline $ontime-pink; text-decoration: underline $error-red;
cursor: pointer; cursor: pointer;
} }
.report:hover { .report:hover {
color: $ontime-pink; color: $error-red;
} }
.report:active { .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 { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
@@ -10,6 +10,7 @@ import { useAtom } from 'jotai';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { mirrorViewersAtom } from '../../atoms/ViewerSettings'; import { mirrorViewersAtom } from '../../atoms/ViewerSettings';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen'; import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
@@ -22,6 +23,8 @@ export default function NavigationMenu() {
const [isMirrored, setMirrored] = useAtom(mirrorViewersAtom); const [isMirrored, setMirrored] = useAtom(mirrorViewersAtom);
const [showButton, setShowButton] = useState(false); const [showButton, setShowButton] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const toggleMenu = () => setShowMenu((prev) => !prev); const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' '); useKeyDown(toggleMenu, ' ');
@@ -49,7 +52,7 @@ export default function NavigationMenu() {
const handleMirror = () => setMirrored((prev) => !prev); const handleMirror = () => setMirrored((prev) => !prev);
return createPortal( return createPortal(
<div id='navigation-menu-portal' className={isMirrored ? style.mirror : ''}> <div id='navigation-menu-portal' ref={menuRef} className={isMirrored ? style.mirror : ''}>
<button <button
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
@@ -81,7 +84,7 @@ export default function NavigationMenu() {
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && handleMirror(); isKeyEnter(event) && handleMirror();
}}> }}>
Mirror Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
</div> </div>
{/*<div className={style.link} tabIndex={0}>*/} {/*<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/v2Styles' as *;
@use '../../../theme/mixins' as *;
.container { .container {
background: $bg-black;
display: grid; display: grid;
place-content: center; place-content: center;
height: 100vh; height: 100vh;
padding-bottom: 30vh; padding-bottom: 30vh;
color: $ontime-pink; background: $bg-container-l1;
font-family: 'Open Sans', sans-serif; color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200; font-weight: 200;
text-align: center; text-align: center;
font-size: 3vw; font-size: 3vw;
@@ -37,9 +35,9 @@
@keyframes colourFade { @keyframes colourFade {
from { from {
background: $ontime-pink; background: $action-blue;
} }
to { 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 { interface EmptyProps {
text: string; text: string;
style: CSSProperties; style?: CSSProperties;
} }
export default function Empty(props: EmptyProps) { 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 *; @use '../../../theme/viewerDefs' as *;
.label { .title-card {
font-size: 1.3vw; display: flex;
color: var(--accent-color-override, $accent-color); flex-direction: column;
} gap: 8px;
.title, .inline {
.subtitle, display: flex;
.presenter { }
@include main.ellipsis;
}
.title { .title {
color: $title-color; font-weight: 600;
font-weight: 600; font-size: clamp(32px, 3.5vw, 50px);
font-size: 2.5vw; color: var(--color-override, $viewer-color);
flex: 1; line-height: 1.1em;
} }
.subtitle, .subtitle, .presenter {
.presenter { font-size: clamp(24px, 2vw, 35px);
color: $subtitle-color; color: var(--secondary-color-override, $viewer-secondary-color);
} line-height: 1.1em;
}
.subtitle { .label {
font-size: 1.5vw; font-size: clamp(16px, 1.5vw, 24px);
font-weight: 200; font-weight: 400;
margin-top: -0.8vh; color: var(--secondary-color-override, $viewer-secondary-color);
} margin-left: auto;
text-transform: uppercase;
.presenter { &.accent {
font-size: 2vw; color: var(--accent-color-override, $accent-color);
} }
}
.title:after,
.presenter:after,
.subtitle:after {
content: '\200b';
} }
@@ -1,7 +1,7 @@
import './TitleCard.scss'; import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
label: string; label: 'now' | 'next';
title: string; title: string;
subtitle: string; subtitle: string;
presenter: string; presenter: string;
@@ -9,13 +9,16 @@ interface TitleCardProps {
export default function TitleCard(props: TitleCardProps) { export default function TitleCard(props: TitleCardProps) {
const { label, title, subtitle, presenter } = props; const { label, title, subtitle, presenter } = props;
const accent = label === 'now';
return ( return (
<> <div className='title-card'>
<div className='label'>{label}</div> <div className='inline'>
<span className='presenter'>{presenter}</span>
<span className={accent? 'label accent': 'label'}>{label}</span>
</div>
<div className='title'>{title}</div> <div className='title'>{title}</div>
<div className='presenter'>{presenter}</div>
<div className='subtitle'>{subtitle}</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 { .modalBody {
min-height: 40vh; min-height: 40vh;
@@ -18,11 +18,11 @@
} }
.info { .info {
background-color: $bg-gray; background-color: $bg-container-onlight;
margin: 1em 0; margin: 1em 0;
padding: 0.5em; padding: 0.5em;
border-radius: 2px; border-radius: 2px;
color: black; color: $text-black;
position: relative; 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) * another go at simpler string formatting (counters)
* @description Converts seconds to string representing time * @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 * @param {boolean} [hideZero] - whether to show hours in case its 00
* @returns {string} String representing absolute time 00:12:02 * @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'; import { formatTime } from './time';
@@ -8,24 +8,24 @@ import { formatTime } from './time';
* @returns {Object[]} Filtered events with calculated delays * @returns {Object[]} Filtered events with calculated delays
*/ */
export const getEventsWithDelay = (events: OntimeRundownEntry[]) => { export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[] => {
if (events == null) return []; if (events == null) return [];
const unfilteredEvents = [...events]; const unfilteredEvents = [...events];
// Add running delay // Add running delay
let delay = 0; let delay = 0;
for (const e of unfilteredEvents) { for (const event of unfilteredEvents) {
if (e.type === 'block') delay = 0; if (event.type === SupportedEvent.Block) delay = 0;
else if (e.type === 'delay') delay = delay + e.duration; else if (event.type === SupportedEvent.Delay) delay = delay + event.duration;
else if (e.type === 'event' && delay > 0) { else if (event.type === SupportedEvent.Event && delay > 0) {
e.timeStart += delay; event.timeStart += delay;
e.timeEnd += delay; event.timeEnd += delay;
} }
} }
// filter just events // 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 }; type ClonedEvent = OntimeEvent | { after?: string };
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: 'event', type: SupportedEvent.Event,
title: event.title, title: event.title,
subtitle: event.subtitle, subtitle: event.subtitle,
presenter: event.presenter, presenter: event.presenter,
@@ -1,10 +1,8 @@
@use '../theme/main' as *; @use '../theme/v2Styles' as *;
.wrapper { .wrapper {
background: $bg-black; background: $bg-container-l1;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto;
color: $title-white;
padding: max(16px, 2vh); padding: max(16px, 2vh);
} }
@@ -22,8 +22,8 @@ export default function MessageControl() {
actionHandler={() => setMessage.presenterVisible(!data?.presenter.visible)} actionHandler={() => setMessage.presenterVisible(!data?.presenter.visible)}
/> />
<InputRow <InputRow
label='Public screen message' label='Public / Backstage screen message'
placeholder='Shown in public screens' placeholder='Shown in public and backstage screens'
text={data?.public.text || ''} text={data?.public.text || ''}
visible={data?.public.visible || false} visible={data?.public.visible || false}
changeHandler={(newValue) => setMessage.publicText(newValue)} changeHandler={(newValue) => setMessage.publicText(newValue)}
@@ -1,9 +1,8 @@
import { Tooltip } from '@chakra-ui/react'; 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 { setPlayback, useTimer } from '../../../common/hooks/useSocket';
import { Playback } from '../../../common/models/OntimeTypes'; import { Playback } from '../../../common/models/OntimeTypes';
import { millisToSeconds } from '../../../common/utils/dateConfig';
import { stringFromMillis } from '../../../common/utils/time'; import { stringFromMillis } from '../../../common/utils/time';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -37,8 +36,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
</div> </div>
<div className={style.timer}> <div className={style.timer}>
<TimerDisplay <TimerDisplay
time={isWaiting ? millisToSeconds(timerData.secondaryTimer) : millisToSeconds(timerData.current)} time={isWaiting ? timerData.secondaryTimer : timerData.current}
isNegative={isOvertime}
small small
/> />
</div> </div>
+2 -2
View File
@@ -1,9 +1,9 @@
@use '../../theme/main' as *; @use '../../theme/ontimeColours' as *;
.gap { .gap {
height: 1em; height: 1em;
} }
.open { .open {
background: $light-bg; background: $blue-700;
} }
@@ -1,4 +1,4 @@
@use '../../../../theme/main' as *; @use '../../../../theme/v2Styles' as *;
.progressBar { .progressBar {
// layout // layout
@@ -11,7 +11,7 @@
transition-property: width; transition-property: width;
&.play { &.play {
background-color: $ontime-accent; background-color: $playback-start;
} }
&.pause { &.pause {
@@ -23,7 +23,6 @@
} }
&.overtime { &.overtime {
background-color: $ontime-pink-variant; background-color: $playback-negative;
} }
} }
@@ -1,4 +1,4 @@
@use '../../../theme/main' as *; @use '../../../theme/v2Styles' as *;
.quickAdd { .quickAdd {
display: grid; display: grid;
+9 -6
View File
@@ -17,12 +17,15 @@ const withSocket = (Component) => {
const [publicSelectedId, setPublicSelectedId] = useState(null); const [publicSelectedId, setPublicSelectedId] = useState(null);
const [timer] = useSubscription('timer', { const [timer] = useSubscription('ontime-timer', {
clock: 0, clock: null,
running: 0, current: null,
isNegative: false, elapsed: null ,
startedAt: null,
expectedFinish: null, expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
}); });
const [titles] = useSubscription('titles', { const [titles] = useSubscription('titles', {
titleNow: '', titleNow: '',
@@ -107,7 +110,7 @@ const withSocket = (Component) => {
// get clock string // get clock string
const TimeManagerType = { const TimeManagerType = {
...timer, ...timer,
finished: playback === 'play' && timer.isNegative && timer.startedAt, finished: playback === 'play' && timer.current < 0 && timer.startedAt,
playback, 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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
background: var(--background-color-override, $viewer-background-color);
height: 100vh; 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); color: var(--color-override, $viewer-color);
font-weight: 300; gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 3vw 2fr; grid-template-columns: 1fr 1fr 40vw;
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; grid-template-rows: auto 12px 1fr auto;
grid-template-areas: grid-template-areas:
' titl titl titl . schd' ' header header header'
' now now now . schd' ' progress progress schedule-nav'
' next next .... . schd' ' now now schedule'
' ... .... .... . ....' ' message message info';
' publ publ clck . info'
' publ publ time . info';
gap: 1vw;
padding: 1vw;
.event, /* =================== HEADER + EXTRAS ===================*/
.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;
.label { .event-header {
font-size: 1.3vw; grid-area: header;
color: var(--accent-color-override, $accent-color); font-size: clamp(32px, 4.5vw, 64px);
}
.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;
font-weight: 600; font-weight: 600;
text-decoration: underline 0.5vh; display: flex;
text-decoration-color: var(--accent-color-override, $accent-color);
padding-top: 0.2vh;
padding-left: 1vw;
} }
.today-container { .clock-container {
grid-area: schd; margin-left: auto;
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 { .public-container {
grid-area: publ; grid-area: message;
&--hidden { &--hidden {
opacity: 0; opacity: 0;
transition: 0.5s; transition: $viewer-transition-time;
transition-property: opacity; transition-property: opacity;
} }
} }
.clock-container { .clock-container,
grid-area: time; .public-container {
border-radius: 0 0 $element-border-radius $element-border-radius; .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 { /* =================== MAIN - NOW ===================*/
grid-area: clck;
border-radius: $element-border-radius $element-border-radius 0 0; .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 { .info {
grid-area: info; grid-area: info;
display: grid; display: flex;
padding: 2.5vh 2vw; gap: max(1vw, 16px);
grid-template-rows: 3vh minmax(0, 1fr);
grid-template-columns: 3fr 1fr;
grid-template-areas:
'titl .'
'binf qr';
gap: 0.5vw;
&__message { &__message {
grid-area: binf; font-size: clamp(16px, 1.5vw, 24px);
font-size: 1.5vw; line-height: 1.3em;
line-height: 2vw;
white-space: pre-line; white-space: pre-line;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.qr { .qr {
align-self: center; margin-left: auto;
justify-self: center; padding: 4px;
grid-area: qr; 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 *; @use '../../../theme/viewerDefs' as *;
.clock-view { .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; height: 100vh;
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
display: flex; display: grid;
align-items: center; place-content: center;
justify-content: center;
gap: 1vw;
border: 1vw solid transparent;
.clock { .clock {
font-family: "Arial Black", sans-serif; font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw; font-size: 20vw;
position: relative; position: relative;
color: var(--timer-color-override, $viewer-color); color: var(--timer-color-override, $timer-color);
opacity: 1; letter-spacing: 0.05em;
transition: 0.5s;
transition-property: opacity;
background-color: transparent;
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings'; import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; 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 { ViewSettingsType } from '../../../common/models/ViewSettings.type';
import { OverridableOptions } from '../../../common/models/ViewTypes'; import { OverridableOptions } from '../../../common/models/ViewTypes';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
@@ -5,25 +5,27 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
background: var(--background-color-override, $viewer-background-color);
height: 100vh; 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); color: var(--color-override, $viewer-color);
padding: 1vw;
/* =================== MAIN - SELECT ===================*/
.event-select { .event-select {
display: flex; display: flex;
margin-top: 8vh; margin-top: 8vh;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: max(1.3vw, 12px);
flex-direction: column; flex-direction: column;
&__title { &__title {
font-size: max(2vw, 14px); font-size: clamp(24px, 2vw, 32px);
} }
&__events { &__events {
font-size: clamp(16px, 1.5vw, 24px);
margin-top: 1em; margin-top: 1em;
overflow-y: auto; overflow-y: auto;
height: 70vh; height: 70vh;
@@ -31,21 +33,88 @@
} }
} }
/* =================== MAIN - EVENT CONTAINER ===================*/
.countdown-container { .countdown-container {
height: 100%; height: 100%;
width: 100%; width: 100%;
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid; 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-columns: 100%;
grid-template-areas: grid-template-areas:
'title' 'header'
'status' 'status'
'clock' 'clock'
'y' 'title'
'timers'; 'timers';
gap: 1vw;
justify-content: center; /* =================== HEADER + EXTRAS ===================*/
text-align: center;
.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 { .timer-group {
grid-area: timers; grid-area: timers;
@@ -55,16 +124,17 @@
.aux-timers { .aux-timers {
text-align: center; text-align: center;
font-size: clamp(24px, 1.75vw, 32px);
&__label { &__label {
font-size: max(1.3vw, 12px); color: var(--label-color-override, $viewer-label-color);
color: var(--accent-color-override, $accent-color); text-transform: uppercase;
} }
&__value { &__value {
font-family: 'Open Sans', sans-serif; font-size: clamp(32px, 3.5vw, 50px);
font-size: max(2vw, 14px); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.3px; letter-spacing: 0.05em;
&--delayed { &--delayed {
color: $delay-color; 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 { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants'; import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings'; import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../../common/components/state/Empty';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig'; import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import getDelayTo from '../../../common/utils/getDelayTo'; import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time'; 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'; import './Countdown.scss';
@@ -21,15 +22,28 @@ const formatOptions = {
format: 'hh:mm:ss a', 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) { export default function Countdown(props) {
const { backstageEvents, time, selectedId, viewSettings } = props; const { backstageEvents, time, selectedId, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom); const [isMirrored] = useAtom(mirrorViewersAtom);
const [follow, setFollow] = useState(null); const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0); const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState(''); const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
useEffect(() => { useEffect(() => {
@@ -46,17 +60,17 @@ export default function Countdown(props) {
const eventId = searchParams.get('eventid'); const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event'); const eventIndex = searchParams.get('event');
let followThis; let followThis: OntimeEvent | null = null;
const events = [...backstageEvents].filter((event) => event.type === 'event'); const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
if (eventId !== null) { if (eventId !== null) {
followThis = events.find((event) => event.id === eventId); followThis = events.find((event) => event.id === eventId) || null;
} else if (eventIndex !== null) { } else if (eventIndex !== null) {
followThis = events?.[eventIndex - 1]; followThis = events?.[Number(eventIndex) - 1];
} }
if (typeof followThis !== 'undefined') { if (followThis !== null) {
setFollow(followThis); 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); const delayToEvent = getDelayTo(backstageEvents, idx);
setDelay(delayToEvent); setDelay(delayToEvent);
} }
@@ -77,9 +91,9 @@ export default function Countdown(props) {
return null; return null;
} }
const standby = time.playback !== 'start' && selectedId === follow?.id; const standby = time.playback !== 'play' && selectedId === follow?.id;
const isRunningFinished = time.finished && runningMessage === timerMessages.running; const isRunningFinished = time.finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === timerMessages.running; const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : ''; const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock, formatOptions); const clock = formatTime(time.clock, formatOptions);
@@ -91,36 +105,38 @@ export default function Countdown(props) {
follow === null follow === null
? '...' ? '...'
: formatTime(follow.timeEnd + delay, formatOptions); : formatTime(follow.timeEnd + delay, formatOptions);
const formattedTimer = runningMessage === TimerMessage.ended
? formatTime(runningTimer, formatOptionsFinished)
: formatDisplay(
isSelected ? millisToSeconds(runningTimer) : millisToSeconds(runningTimer + delay),
isSelected || time.waiting,
);
return ( return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'> <div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu /> <NavigationMenu />
{follow === null ? ( {follow === null ? (
<div className='event-select' data-testid='countdown-select'> <CountdownSelect events={backstageEvents} />
<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>
) : ( ) : (
<div className='countdown-container' data-testid='countdown-event'> <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='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'>
<div className='aux-timers__label'>Start Time</div> <div className='aux-timers__label'>Start Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}> <span className={`aux-timers__value ${delayedTimerStyles}`}>
@@ -134,27 +150,9 @@ export default function Countdown(props) {
</span> </span>
</div> </div>
</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>
)} )}
</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 *; @use '../../../theme/viewerDefs' as *;
.minimal-timer { .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; height: 100vh;
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
display: flex; display: grid;
align-items: center; place-content: center;
justify-content: center;
gap: 1vw;
border: 1vw solid transparent;
&--finished { &--finished {
border: 1vw solid $timer-finished-color; outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
transition: 0.3s; outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
} }
.timer { .timer {
font-family: "Arial Black", sans-serif; font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw; font-size: 20vw;
position: relative; position: relative;
color: var(--timer-color-override, $viewer-color); color: var(--timer-color-override, $timer-color);
opacity: 1; opacity: 1;
transition: 0.5s; transition: $viewer-transition-time;
transition-property: opacity; transition-property: opacity;
background-color: transparent; background-color: transparent;
letter-spacing: 0.05em;
&--paused { &--paused {
opacity: 0.6; opacity: $viewer-opacity-disabled;
transition: 0.5s; transition: $viewer-transition-time;
} }
&--finished { &--finished {
@@ -48,11 +52,11 @@
background-color: $viewer-overlay-bg-color; background-color: $viewer-overlay-bg-color;
z-index: -1; z-index: -1;
opacity: 0; opacity: 0;
transition: 0.5s; transition: $viewer-transition-time;
&--active { &--active {
opacity: 1; opacity: 1;
transition: 0.5s; transition: $viewer-transition-time;
transition-property: opacity; transition-property: opacity;
z-index: 2; z-index: 2;
} }
@@ -7,10 +7,10 @@ import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { PresenterMessageType } from '../../../common/models/PresenterMessage.type'; 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 { ViewSettingsType } from '../../../common/models/ViewSettings.type';
import { OverridableOptions } from '../../../common/models/ViewTypes'; import { OverridableOptions } from '../../../common/models/ViewTypes';
import { formatDisplay } from '../../../common/utils/dateConfig'; import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import './MinimalTimer.scss'; import './MinimalTimer.scss';
@@ -125,12 +125,22 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const showOverlay = pres.text !== '' && pres.visible; const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== 'pause'; const isPlaying = time.playback !== 'pause';
const timer = formatDisplay(time.running, true); const isNegative = (time.current ?? 0) < 0;
const clean = timer.replace('/:/g', ''); const showFinished = isNegative && !userOptions?.hideOvertime;
const showFinished = time.isNegative && !userOptions?.hideOvertime;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`; 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 ( return (
<div <div
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses} className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
@@ -155,14 +165,14 @@ export default function MinimalTimer(props: MinimalTimerProps) {
}`} }`}
style={{ style={{
color: userOptions.textColour, color: userOptions.textColour,
fontSize: `${(89 / (clean.length - 1)) * (userOptions.size || 1)}vw`, fontSize: `${(89 / (stageTimerCharacters - 1)) * (userOptions.size || 1)}vw`,
fontFamily: userOptions.font, fontFamily: userOptions.font,
top: userOptions.top, top: userOptions.top,
left: userOptions.left, left: userOptions.left,
backgroundColor: userOptions.textBackground, backgroundColor: userOptions.textBackground,
}} }}
> >
{time.isNegative ? `-${timer}` : timer} {stageTimer}
</div> </div>
</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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
background: var(--background-color-override, $viewer-background-color);
height: 100vh; 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); color: var(--color-override, $viewer-color);
font-weight: 300; gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 3vw 2fr; grid-template-columns: 1fr 1fr 40vw;
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; grid-template-rows: auto 12px 1fr auto;
grid-template-areas: grid-template-areas:
' titl titl titl . schd' ' header header header'
' now now now . schd' ' progress progress schedule-nav'
' next next .... . schd' ' now now schedule'
' ... .... .... . ....' ' message message info';
' publ publ .... . info'
' publ publ time . info';
gap: 1vw;
padding: 1vw;
.event, /* =================== HEADER + EXTRAS ===================*/
.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;
.label { .event-header {
font-size: 1.3vw; grid-area: header;
color: var(--accent-color-override, $accent-color); font-size: clamp(32px, 4.5vw, 64px);
}
.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;
font-weight: 600; font-weight: 600;
text-decoration: underline 0.5vh; display: flex;
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;
}
} }
.clock-container { .clock-container {
grid-area: time; margin-left: auto;
border-radius: $element-border-radius; }
.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 { .info {
grid-area: info; grid-area: info;
display: grid; display: flex;
padding: 2.5vh 2vw; gap: max(1vw, 16px);
grid-template-rows: 3vh minmax(0, 1fr);
grid-template-columns: 3fr 1fr;
grid-template-areas:
'titl .'
'binf qr';
gap: 0.5vw;
&__message { &__message {
grid-area: binf; font-size: clamp(16px, 1.5vw, 24px);
font-size: 1.5vw; line-height: 1.3em;
line-height: 2vw;
white-space: pre-line; white-space: pre-line;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.qr { .qr {
align-self: center; margin-left: auto;
justify-self: center; padding: 4px;
grid-area: qr; 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', 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) { export default function StudioClock(props) {
const { title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = 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 activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()]; const secondsIndicators = [...Array(60).keys()];
const MAX_TITLES = 10; const MAX_TITLES = 12;
useEffect(() => { useEffect(() => {
document.title = 'ontime - Studio Clock'; document.title = 'ontime - Studio Clock';
@@ -42,7 +52,9 @@ export default function StudioClock(props) {
// Prepare event list // Prepare event list
useEffect(() => { useEffect(() => {
if (backstageEvents == null) return; if (!backstageEvents) {
return;
}
const delayed = getEventsWithDelay(backstageEvents); const delayed = getEventsWithDelay(backstageEvents);
const events = delayed.filter((e) => e.type === 'event'); const events = delayed.filter((e) => e.type === 'event');
@@ -55,6 +67,7 @@ export default function StudioClock(props) {
const clock = formatTime(time.clock, formatOptions); const clock = formatTime(time.clock, formatOptions);
const [, , secondsNow] = stringFromMillis(time.clock).split(':'); const [, , secondsNow] = stringFromMillis(time.clock).split(':');
const isNegative = (time.current ?? 0) < 0;
return ( return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'> <div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
@@ -64,13 +77,13 @@ export default function StudioClock(props) {
<div <div
ref={titleRef} ref={titleRef}
className='next-title' className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '82%' }} style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
> >
{title.titleNext} {title.titleNext}
</div> </div>
<div <div
className={time.isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}> className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId != null && formatDisplay(time.running)} {selectedId != null && formatDisplay(time.current)}
</div> </div>
<div className='clock-indicators'> <div className='clock-indicators'>
{activeIndicators.map((i) => ( {activeIndicators.map((i) => (
@@ -106,8 +119,8 @@ export default function StudioClock(props) {
<li <li
key={s.id} key={s.id}
className={s.isNow ? 'now' : s.isNext ? 'next' : ''} 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}`} {`${s.time} ${s.title}`}
</li> </li>
))} ))}
@@ -117,13 +130,3 @@ export default function StudioClock(props) {
</div> </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 { li {
margin-bottom: 1.5vh; margin-bottom: 1.5vh;
padding-left: 0.25em; padding-left: 0.25em;
display: flex;
align-items: center;
} }
.now { .now {
@@ -151,6 +153,14 @@ $cyan-idle: #0aa;
.next { .next {
color: $red-active; 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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
background: var(--background-color-override, $viewer-background-color);
height: 100vh; 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); color: var(--color-override, $viewer-color);
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid; display: grid;
grid-template-columns: 1fr 1fr 5vw 1fr 1fr; grid-template-columns: auto;
grid-template-rows: auto 1fr auto minmax(25vh, auto); grid-template-rows: auto 1fr auto auto auto;
grid-template-areas: grid-template-areas:
' clck clck .... .... ....' 'clock'
' timr timr timr timr timr' 'timer'
' prog prog prog prog prog' 'progress'
' now now .... next next'; 'now'
gap: 1vw; 'next';
padding: 1vw;
&--finished { &--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 ===================*/ /* =================== TITLES ===================*/
.event { .event {
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 1vh 2vw; padding: 16px 24px;
border-radius: $element-border-radius; border-radius: 8px;
max-width: 100%;
&.now { &.now {
grid-area: now; grid-area: now;
} }
&.next { &.next {
grid-area: next; grid-area: next;
} }
@@ -45,15 +68,15 @@
/* =================== MAIN ===================*/ /* =================== MAIN ===================*/
.timer-container { .timer-container {
grid-area: timr; grid-area: timer;
justify-self: center; justify-self: center;
align-self: center; align-self: center;
color: white; color: var(--timer-color-override, $timer-color);
.end-message { .end-message {
text-align: center; text-align: center;
font-size: 12vw; font-size: 12vw;
line-height: 18vw; line-height: 0.9em;
font-weight: 600; font-weight: 600;
color: $timer-finished-color; color: $timer-finished-color;
padding: 0; padding: 0;
@@ -65,17 +88,17 @@
} }
.progress-container { .progress-container {
grid-area: prog; grid-area: progress;
width: 80%; width: 100%;
margin: 0 auto; margin: 0 auto;
opacity: 1; opacity: 1;
transition: 0.5s; transition: $viewer-transition-time;
} }
.timer--paused, .timer--paused,
.progress-container--paused { .progress-container--paused {
opacity: 0.6; opacity: $viewer-opacity-disabled;
transition: 0.5s; transition: $viewer-transition-time;
} }
@@ -92,11 +115,11 @@
background-color: $viewer-overlay-bg-color; background-color: $viewer-overlay-bg-color;
z-index: -1; z-index: -1;
opacity: 0; opacity: 0;
transition: 0.5s; transition: $viewer-transition-time;
&--active { &--active {
opacity: 1; opacity: 1;
transition: 0.5s; transition: $viewer-transition-time;
transition-property: opacity; transition-property: opacity;
z-index: 2; z-index: 2;
} }
@@ -108,7 +131,7 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
color: white; color: $viewer-color;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%); -ms-transform: translate(-50%, -50%);
font-size: 15vw; font-size: 15vw;
@@ -116,23 +139,4 @@
text-align: center; text-align: center;
font-weight: 600; 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 { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { overrideStylesURL } from 'common/api/apiConstants';
import TimerDisplay from 'common/components/countdown/TimerDisplay'; import { mirrorViewersAtom } from 'common/atoms/ViewerSettings';
import MyProgressBar from 'common/components/myProgressBar/MyProgressBar'; 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 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 { AnimatePresence, motion } from 'framer-motion';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import PropTypes from 'prop-types'; 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'; import './Timer.scss';
const formatOptions = { const formatOptions = {
@@ -20,11 +18,34 @@ const formatOptions = {
format: 'hh:mm:ss a', 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) { export default function Timer(props) {
const { general, pres, title, time, viewSettings } = props; const { general, pres, title, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [elapsed, setElapsed] = useState(true);
const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom); const [isMirrored] = useAtom(mirrorViewersAtom);
useEffect(() => { useEffect(() => {
@@ -36,42 +57,18 @@ export default function Timer(props) {
return null; 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 clock = formatTime(time.clock, formatOptions);
const showOverlay = pres.text !== '' && pres.visible; const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playstate !== 'pause'; const isPlaying = time.playback !== 'pause';
const normalisedTime = Math.max(time.running, 0); const normalisedTime = time.current === null ? null : Math.max(time.current, 0);
const showProgress = time.playback !== 'stop';
// motion
const titleVariants = {
hidden: {
y: 500,
},
visible: {
y: 0,
transition: {
duration: 1,
},
},
exit: {
y: 500,
},
};
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
return ( return (
<div className={time.finished ? `${baseClasses} stage-timer--finished` : baseClasses} <div
data-testid='timer-view'> className={time.finished ? `${baseClasses} stage-timer--finished` : baseClasses}
data-testid='timer-view'
>
<NavigationMenu /> <NavigationMenu />
<div <div
className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}> className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
@@ -86,32 +83,28 @@ export default function Timer(props) {
<div className='timer-container'> <div className='timer-container'>
{time.finished ? ( {time.finished ? (
<div className='end-message'> <div className='end-message'>
{general.endMessage == null || general.endMessage === '' ? ( {!general.endMessage ? (
<TimerDisplay time={time.running} isNegative={time.isNegative} hideZeroHours /> <TimerDisplay time={time.current} hideZeroHours />
) : ( ) : (
general.endMessage general.endMessage
)} )}
</div> </div>
) : ( ) : (
<div className={isPlaying ? 'timer' : 'timer--paused'}> <TimerDisplay
<TimerDisplay time={normalisedTime} hideZeroHours /> time={normalisedTime}
</div> hideZeroHours
className={isPlaying ? 'timer' : 'timer--paused'} />
)} )}
</div> </div>
{!time.finished && ( <ProgressBar
<div className={
className={ isPlaying ? 'progress-container' : 'progress-container progress-container--paused'
isPlaying ? 'progress-container' : 'progress-container progress-container--paused' }
} now={time.current}
> complete={time.duration}
<MyProgressBar hidden={!showProgress}
now={normalisedTime} />
complete={time.durationSeconds}
showElapsed={elapsed}
/>
</div>
)}
<AnimatePresence> <AnimatePresence>
{title.showNow && ( {title.showNow && (
@@ -124,7 +117,7 @@ export default function Timer(props) {
exit='exit' exit='exit'
> >
<TitleCard <TitleCard
label='Now' label='now'
title={title.titleNow} title={title.titleNow}
subtitle={title.subtitleNow} subtitle={title.subtitleNow}
presenter={title.presenterNow} presenter={title.presenterNow}
@@ -144,7 +137,7 @@ export default function Timer(props) {
exit='exit' exit='exit'
> >
<TitleCard <TitleCard
label='Next' label='next'
title={title.titleNext} title={title.titleNext}
subtitle={title.subtitleNext} subtitle={title.subtitleNext}
presenter={title.presenterNext} presenter={title.presenterNext}
@@ -155,11 +148,3 @@ export default function Timer(props) {
</div> </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'; import './index.scss';
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container as Element); const root = createRoot(container as Element);
@@ -18,6 +17,7 @@ Sentry.init({
integrations: [new BrowserTracing()], integrations: [new BrowserTracing()],
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
release: ONTIME_VERSION, release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
}); });
root.render( root.render(
+1 -1
View File
@@ -24,7 +24,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 16px;
transition-property: color; transition-property: color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
+4 -1
View File
@@ -23,12 +23,15 @@ $ontime-paused: #c05621;
$ontime-stop: #E4281E; $ontime-stop: #E4281E;
$playback-negative: $red-500; $playback-negative: $red-500;
$active-indicator: #899948; $active-indicator: #899948;
$text-black: $gray-1350;
// interface panels // interface panels
$bg-container-l1: $gray-1350; $bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300; $bg-container-l2: $gray-1300;
$bg-container-l3: $gray-1350; $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-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-l2: 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 'viewerCommon' as *;
@use "ontimeColours" as *;
// General
$viewer-transition-time: 0.5s;
// Text // 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 // General styling
$accent-color: $ontime-pink; // --accent-color-override $accent-color: $red-500; // --accent-color-override
$delay-color: $delay-text; $delay-color: $ontime-delay-text;
$viewer-label-color: rgba(white, 25%);
// Main Properties of a viewer // Main Properties of a viewer
$viewer-background-color: $bg-black; // --background-color-override $viewer-background-color: $ui-black; // --background-color-override
$viewer-color: $title-white; // --color-override $viewer-color: rgba(white, 80%); // --color-override
$viewer-outdent-bg-color: $bg-container-l1; // --outdent-background-color-override $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-overlay-bg-color: $bg-overlay; $viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$viewer-overlay-bg-color: rgba(black, 90%);
$element-border-radius: 8px; $element-border-radius: 8px;
// Properties related to timer // Properties related to timer
$timer-finished-color: $ontime-pink-variant; $timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
// View specific - Pip $timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
$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;
-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 = { export const ontimeTooltip = {
_light: { _light: {
backgroundColor: '#2d2d2d', backgroundColor: '#2d2d2d', // $gray-1100
color: '#779BE7', color: '#ececec', // $gray-100
padding: '2px 8px', padding: '2px 8px',
}, },
_dark: { _dark: {
backgroundColor: '#2d2d2d', backgroundColor: '#2d2d2d', // $gray-1100
color: '#779BE7', color: '#ececec', // $gray-100
padding: '2px 8px', padding: '2px 8px',
}, },
}; };
-1
View File
@@ -5,7 +5,6 @@ export const navigatorConstants = [
{ url: '/backstage', label: 'Backstage' }, { url: '/backstage', label: 'Backstage' },
{ url: '/public', label: 'Public' }, { url: '/public', label: 'Public' },
{ url: '/lower', label: 'Lower Thirds' }, { url: '/lower', label: 'Lower Thirds' },
{ url: '/pip', label: 'PiP' },
{ url: '/studio', label: 'Studio Clock' }, { url: '/studio', label: 'Studio Clock' },
{ url: '/countdown', label: 'Countdown' }, { 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 { :root {
--background-color-override: #f3f4f8; --background-color-override: #ececec;
--color-override: #5051f9; --color-override: #101010;
--accent-color-override: #23235f; --secondary-color-override: #404040;
--timer-color-override: #5051f9; --accent-color-override: #FA5656;
--outdent-background-color-override: white; --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 { .timer {
font-family: "Comic Sans MS", serif !important; color: black !important;
} }