diff --git a/client/package.json b/client/package.json
index f808f26fc..e2b338fcc 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
- "version": "1.8.0",
+ "version": "1.9.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.3.2",
diff --git a/client/src/AppRouter.jsx b/client/src/AppRouter.jsx
index bd61c8e55..c44168e4b 100644
--- a/client/src/AppRouter.jsx
+++ b/client/src/AppRouter.jsx
@@ -12,6 +12,7 @@ const Table = lazy(() => import('features/table/ProtectedTable'));
const TimerView = lazy(() => import('features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('features/viewers/minimal-timer/MinimalTimer'));
+const ClockView = lazy(() => import('features/viewers/clock/Clock'));
const Countdown = lazy(() => import('features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('features/viewers/backstage/Backstage'));
@@ -22,6 +23,7 @@ const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
const STimer = withSocket(TimerView);
const SMinimalTimer = withSocket(MinimalTimerView);
+const SClock = withSocket(ClockView);
const SCountdown = withSocket(Countdown);
const SBackstage = withSocket(Backstage);
const SPublic = withSocket(Public);
@@ -64,6 +66,8 @@ export default function AppRouter() {
} />
} />
+ } />
+
} />
} />
diff --git a/client/src/common/components/nav/navigatorConstants.js b/client/src/common/components/nav/navigatorConstants.js
index 93ea057d8..b9d37b44e 100644
--- a/client/src/common/components/nav/navigatorConstants.js
+++ b/client/src/common/components/nav/navigatorConstants.js
@@ -1,5 +1,6 @@
const navigatorConstants = [
{ url: '/timer', label: 'Timer' },
+ { url: '/clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' },
{ url: '/public', label: 'Public' },
diff --git a/client/src/common/models/OntimeTypes.ts b/client/src/common/models/OntimeTypes.ts
new file mode 100644
index 000000000..0bf03c655
--- /dev/null
+++ b/client/src/common/models/OntimeTypes.ts
@@ -0,0 +1,20 @@
+type Playstate = 'roll' | 'start' | 'pause' | 'stop';
+
+export type TimeManager = {
+ clock: number,
+ running: number,
+ isNegative: boolean;
+ startedAt: null | number;
+ expectedFinish: null | number;
+ finished: boolean;
+ playstate: Playstate
+}
+
+export type PresenterMessageData = {
+ text: string;
+ visible: boolean;
+}
+
+export type ViewSettings = {
+ overrideStyles: boolean;
+}
\ No newline at end of file
diff --git a/client/src/common/models/ViewTypes.ts b/client/src/common/models/ViewTypes.ts
new file mode 100644
index 000000000..105a4f72d
--- /dev/null
+++ b/client/src/common/models/ViewTypes.ts
@@ -0,0 +1,14 @@
+export type OverridableOptions = {
+ keyColour?: string;
+ textColour?: string;
+ textBackground?: string;
+ font?: string;
+ size?: number;
+ justifyContent?: 'start' | 'center' | 'end';
+ alignItems?: 'start' | 'center' | 'end';
+ left?: string;
+ top?: string;
+ hideNav?: boolean;
+ hideOvertime?: boolean;
+ hideMessagesOverlay?: boolean;
+}
diff --git a/client/src/features/viewers/clock/Clock.scss b/client/src/features/viewers/clock/Clock.scss
new file mode 100644
index 000000000..ebc4eb5c3
--- /dev/null
+++ b/client/src/features/viewers/clock/Clock.scss
@@ -0,0 +1,23 @@
+@use '../../../theme/viewerDefs' as *;
+
+.clock-view {
+ background: var(--background-color-override, $viewer-background-color);
+ height: 100vh;
+ color: var(--color-override, $viewer-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1vw;
+ border: 1vw solid transparent;
+
+ .clock {
+ font-family: "Arial Black", sans-serif;
+ font-size: 20vw;
+ position: relative;
+ color: var(--timer-color-override, $viewer-color);
+ opacity: 1;
+ transition: 0.5s;
+ transition-property: opacity;
+ background-color: transparent;
+ }
+}
diff --git a/client/src/features/viewers/clock/Clock.tsx b/client/src/features/viewers/clock/Clock.tsx
new file mode 100644
index 000000000..17d6b39c3
--- /dev/null
+++ b/client/src/features/viewers/clock/Clock.tsx
@@ -0,0 +1,154 @@
+import { useEffect } from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import { overrideStylesURL } from '../../../common/api/apiConstants';
+import NavLogo from '../../../common/components/nav/NavLogo';
+import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
+import {
+ TimeManager,
+ ViewSettings,
+} from '../../../common/models/OntimeTypes';
+import { OverridableOptions } from '../../../common/models/ViewTypes';
+import { formatTime } from '../../../common/utils/time';
+
+import './Clock.scss';
+
+interface ClockProps {
+ time: TimeManager;
+ viewSettings: ViewSettings;
+}
+
+const formatOptions = {
+ showSeconds: true,
+ format: 'hh:mm:ss a',
+};
+
+export default function Clock(props: ClockProps) {
+ const { time, viewSettings } = props;
+ const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
+ const [searchParams] = useSearchParams();
+
+ useEffect(() => {
+ document.title = 'ontime - Clock';
+ }, []);
+
+ // defer rendering until we load stylesheets
+ if (!shouldRender) {
+ return null;
+ }
+
+ // get config from url: key, text, font, size, hidenav, hideovertime
+ // eg. http://localhost:3000/minimal?key=f00&text=fff
+ // Check for user options
+ const userOptions: OverridableOptions = {
+ size: 1,
+ };
+
+ // key: string
+ // Should be a hex string '#00FF00' with key colour
+ const key = searchParams.get('key');
+ if (key) {
+ userOptions.keyColour = `#${key}`;
+ }
+
+ // textColour: string
+ // Should be a hex string '#ffffff'
+ const textColour = searchParams.get('text');
+ if (textColour) {
+ userOptions.textColour = `#${textColour}`;
+ }
+
+ // textBackground: string
+ // Should be a hex string '#ffffff'
+ const textBackground = searchParams.get('textbg');
+ if (textBackground) {
+ userOptions.textBackground = `#${textBackground}`;
+ }
+
+ // font: string
+ // Should be a string with a font name 'arial'
+ const font = searchParams.get('font');
+ if (font) {
+ userOptions.font = font;
+ }
+
+ // size: multiplier
+ // Should be a number 0.0-n
+ const size = searchParams.get('size');
+ if (size !== null && typeof size !== 'undefined') {
+ if (!Number.isNaN(Number(size))) {
+ userOptions.size = Number(size);
+ }
+ }
+
+ // alignX: flex justification
+ // start | center | end
+ const alignX = searchParams.get('alignx');
+ if (alignX) {
+ if (alignX === 'start' || alignX === 'center' || alignX === 'end') {
+ userOptions.justifyContent = alignX;
+ }
+ }
+
+ // alignX: flex alignment
+ // start | center | end
+ const alignY = searchParams.get('aligny');
+ if (alignY) {
+ if (alignY === 'start' || alignY === 'center' || alignY === 'end') {
+ userOptions.alignItems = alignY;
+ }
+ }
+
+ // offsetX: position in pixels
+ // Should be a number 0 - 1920
+ const offsetX = searchParams.get('offsetx');
+ if (offsetX) {
+ const pixels = Number(offsetX);
+ if (!isNaN(pixels)) {
+ userOptions.left = `${pixels}px`;
+ }
+ }
+
+ // offsetX: position in pixels
+ // Should be a number 0 - 1920
+ const offsetY = searchParams.get('offsety');
+ if (offsetY) {
+ const pixels = Number(offsetY);
+ if (!isNaN(pixels)) {
+ userOptions.top = `${pixels}px`;
+ }
+ }
+
+ const hideNav = searchParams.get('hidenav');
+ userOptions.hideNav = Boolean(hideNav);
+
+ const clock = formatTime(time.clock, formatOptions);
+ const clean = clock.replace('/:/g', '');
+
+ return (
+
+ {!userOptions?.hideNav &&
}
+
+ {clock}
+
+
+ );
+}
diff --git a/client/src/features/viewers/minimal-timer/MinimalTimer.scss b/client/src/features/viewers/minimal-timer/MinimalTimer.scss
index f8fa2574f..a143cd953 100644
--- a/client/src/features/viewers/minimal-timer/MinimalTimer.scss
+++ b/client/src/features/viewers/minimal-timer/MinimalTimer.scss
@@ -23,6 +23,7 @@
opacity: 1;
transition: 0.5s;
transition-property: opacity;
+ background-color: transparent;
&--paused {
opacity: 0.6;
diff --git a/client/src/features/viewers/minimal-timer/MinimalTimer.jsx b/client/src/features/viewers/minimal-timer/MinimalTimer.tsx
similarity index 78%
rename from client/src/features/viewers/minimal-timer/MinimalTimer.jsx
rename to client/src/features/viewers/minimal-timer/MinimalTimer.tsx
index 3db3c3896..ea723c158 100644
--- a/client/src/features/viewers/minimal-timer/MinimalTimer.jsx
+++ b/client/src/features/viewers/minimal-timer/MinimalTimer.tsx
@@ -1,15 +1,26 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
-import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavLogo from '../../../common/components/nav/NavLogo';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
+import {
+ PresenterMessageData,
+ TimeManager,
+ ViewSettings,
+} from '../../../common/models/OntimeTypes';
+import { OverridableOptions } from '../../../common/models/ViewTypes';
import { formatDisplay } from '../../../common/utils/dateConfig';
import './MinimalTimer.scss';
-export default function MinimalTimer(props) {
+interface MinimalTimerProps {
+ pres: PresenterMessageData;
+ time: TimeManager;
+ viewSettings: ViewSettings;
+}
+
+export default function MinimalTimer(props: MinimalTimerProps) {
const { pres, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
@@ -26,7 +37,9 @@ export default function MinimalTimer(props) {
// get config from url: key, text, font, size, hidenav, hideovertime
// eg. http://localhost:3000/minimal?key=f00&text=fff
// Check for user options
- const userOptions = {};
+ const userOptions: OverridableOptions = {
+ size: 1,
+ };
// key: string
// Should be a hex string '#00FF00' with key colour
@@ -42,6 +55,13 @@ export default function MinimalTimer(props) {
userOptions.textColour = `#${textColour}`;
}
+ // textBackground: string
+ // Should be a hex string '#ffffff'
+ const textBackground = searchParams.get('textbg');
+ if (textBackground) {
+ userOptions.textBackground = `#${textBackground}`;
+ }
+
// font: string
// Should be a string with a font name 'arial'
const font = searchParams.get('font');
@@ -52,8 +72,10 @@ export default function MinimalTimer(props) {
// size: multiplier
// Should be a number 0.0-n
const size = searchParams.get('size');
- if (size) {
- userOptions.size = size;
+ if (size !== null && typeof size !== 'undefined') {
+ if (!Number.isNaN(Number(size))) {
+ userOptions.size = Number(size);
+ }
}
// alignX: flex justification
@@ -95,19 +117,13 @@ export default function MinimalTimer(props) {
}
const hideNav = searchParams.get('hidenav');
- if (hideNav) {
- userOptions.hideNav = hideNav;
- }
+ userOptions.hideNav = Boolean(hideNav);
const hideOvertime = searchParams.get('hideovertime');
- if (hideOvertime) {
- userOptions.hideOvertime = hideOvertime;
- }
+ userOptions.hideOvertime = Boolean(hideOvertime);
const hideMessagesOverlay = searchParams.get('hidemessages');
- if (hideMessagesOverlay) {
- userOptions.hideMessagesOverlay = hideMessagesOverlay;
- }
+ userOptions.hideMessagesOverlay = Boolean(hideMessagesOverlay);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playstate !== 'pause';
@@ -139,10 +155,11 @@ export default function MinimalTimer(props) {
showFinished ? 'timer--finished' : ''
}`}
style={{
- fontSize: `${(89 / (clean.length - 1)) * userOptions.size}vw`,
+ fontSize: `${(89 / (clean.length - 1)) * (userOptions.size || 1)}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
left: userOptions.left,
+ backgroundColor: userOptions.textBackground,
}}
>
{time.isNegative ? `-${timer}` : timer}
@@ -150,9 +167,3 @@ export default function MinimalTimer(props) {
);
}
-
-MinimalTimer.propTypes = {
- pres: PropTypes.object,
- time: PropTypes.object,
- viewSettings: PropTypes.object,
-};
diff --git a/server/cypress/integration/navigation.spec.js b/server/cypress/integration/navigation.spec.js
index f23e1305b..3eb9f8a8c 100644
--- a/server/cypress/integration/navigation.spec.js
+++ b/server/cypress/integration/navigation.spec.js
@@ -92,6 +92,16 @@ describe('validate routes', () => {
cy.contains('Running Timer');
});
+ describe('clock view timer', () => {
+ it('renders base route', () => {
+ // base route
+ cy.visit('http://localhost:4001/clock');
+ cy.get('[data-testid="clock-view"]').should('exist');
+ // ontime fallsback to stage timer on errors, so we check for that
+ cy.get('.App').should('not.contain', 'Time Now');
+ });
+ });
+
describe('minimal timer and options', () => {
it('renders base route', () => {
// base route