+
{cue}
diff --git a/apps/client/src/features/operator/status-bar/StatusBar.module.scss b/apps/client/src/features/operator/status-bar/StatusBar.module.scss
index 30582f2f3..47757a8c3 100644
--- a/apps/client/src/features/operator/status-bar/StatusBar.module.scss
+++ b/apps/client/src/features/operator/status-bar/StatusBar.module.scss
@@ -15,16 +15,19 @@
background-color: $gray-1350;
z-index: 2;
- padding: 0.5rem 1rem;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
+}
+.timers {
display: grid;
+ padding: 0.5rem 1rem;
grid-template-areas:
"playback timer1B timer2B timer3B";
grid-template-columns: 1fr auto auto auto;
column-gap: 1.5rem;
align-items: center;
+
}
.playbackIcon {
@@ -83,7 +86,7 @@
// tablet
@media (min-width: $min-tablet) {
- .statusBar {
+ .timers {
grid-template-areas:
"playback timer1B timer2A timer3A"
"title title timer2B timer3B";
@@ -103,3 +106,7 @@
display: flex;
}
}
+
+.progressOverride {
+ border-radius: 0;
+}
diff --git a/apps/client/src/features/operator/status-bar/StatusBar.tsx b/apps/client/src/features/operator/status-bar/StatusBar.tsx
index cf76d886f..c6504c950 100644
--- a/apps/client/src/features/operator/status-bar/StatusBar.tsx
+++ b/apps/client/src/features/operator/status-bar/StatusBar.tsx
@@ -1,11 +1,9 @@
-import { useMemo } from 'react';
import { Playback } from 'ontime-types';
-import { millisToString } from 'ontime-utils';
-import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
-import { useTimer } from '../../../common/hooks/useSocket';
-import { cx } from '../../../common/utils/styleUtils';
-import { formatTime } from '../../../common/utils/time';
+import useViewSettings from '../../../common/hooks-query/useViewSettings';
+
+import StatusBarProgress from './StatusBarProgress';
+import StatusBarTimers from './StatusBarTimers';
import styles from './StatusBar.module.scss';
@@ -22,73 +20,20 @@ interface StatusBarProps {
export default function StatusBar(props: StatusBarProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
- const timer = useTimer();
-
- const getTimeStart = () => {
- if (firstStart === undefined) {
- return '...';
- }
-
- if (selectedEventId) {
- if (firstId === selectedEventId) {
- return millisToString(timer.expectedFinish);
- }
- }
- return millisToString(firstStart);
- };
-
- const getTimeEnd = () => {
- if (lastEnd === undefined) {
- return '...';
- }
-
- if (selectedEventId) {
- if (lastId === selectedEventId) {
- return millisToString(timer.expectedFinish);
- }
- }
- return millisToString(lastEnd);
- };
-
- // use user defined format
- const timeNow = formatTime(timer.clock, {
- showSeconds: true,
- });
-
- const runningTime = millisToString(timer.current);
- const elapsedTime = millisToString(timer.elapsed);
-
- const PlaybackIconComponent = useMemo(() => {
- const isPlaying = playback === Playback.Play || playback === Playback.Roll;
- const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
- return
;
- }, [playback]);
+ const { data } = useViewSettings();
return (
- {PlaybackIconComponent}
-
- Time now
- {timeNow}
-
-
- Elapsed time
- {elapsedTime}
-
-
- Running timer
- {runningTime}
-
-
-
{projectTitle}
-
- Scheduled start
- {getTimeStart()}
-
-
- Scheduled end
- {getTimeEnd()}
-
+
+ {data &&
}
);
}
diff --git a/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx b/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx
new file mode 100644
index 000000000..bb4f06e8f
--- /dev/null
+++ b/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx
@@ -0,0 +1,30 @@
+import { ViewSettings } from 'ontime-types';
+
+import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
+import { useTimer } from '../../../common/hooks/useSocket';
+
+import styles from './StatusBar.module.scss';
+
+interface StatusBarProgressProps {
+ viewSettings: ViewSettings;
+}
+
+export default function StatusBarProgress(props: StatusBarProgressProps) {
+ const { viewSettings } = props;
+
+ const timer = useTimer();
+ const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx b/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx
new file mode 100644
index 000000000..581a1ffc0
--- /dev/null
+++ b/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx
@@ -0,0 +1,94 @@
+import { useMemo } from 'react';
+import { Playback } from 'ontime-types';
+import { millisToString } from 'ontime-utils';
+
+import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
+import { useTimer } from '../../../common/hooks/useSocket';
+import { cx } from '../../../common/utils/styleUtils';
+import { formatTime } from '../../../common/utils/time';
+
+import styles from './StatusBar.module.scss';
+
+interface StatusBarTimersProps {
+ projectTitle: string;
+ playback: Playback;
+ selectedEventId: string | null;
+ firstStart?: number;
+ firstId?: string;
+ lastEnd?: number;
+ lastId?: string;
+}
+
+export default function StatusBarTimers(props: StatusBarTimersProps) {
+ const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
+
+ const timer = useTimer();
+
+ const getTimeStart = () => {
+ if (firstStart === undefined) {
+ return '...';
+ }
+
+ if (selectedEventId) {
+ if (firstId === selectedEventId) {
+ return millisToString(timer.expectedFinish);
+ }
+ }
+ return millisToString(firstStart);
+ };
+
+ const getTimeEnd = () => {
+ if (lastEnd === undefined) {
+ return '...';
+ }
+
+ if (selectedEventId) {
+ if (lastId === selectedEventId) {
+ return millisToString(timer.expectedFinish);
+ }
+ }
+ return millisToString(lastEnd);
+ };
+
+ const PlaybackIconComponent = useMemo(() => {
+ const isPlaying = playback === Playback.Play || playback === Playback.Roll;
+ const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
+ return
;
+ }, [playback]);
+
+ // use user defined format
+ const timeNow = formatTime(timer.clock, {
+ showSeconds: true,
+ });
+
+ const runningTime = millisToString(timer.current);
+ const elapsedTime = millisToString(timer.elapsed);
+
+ return (
+
+ {PlaybackIconComponent}
+
+ Time now
+ {timeNow}
+
+
+ Elapsed time
+ {elapsedTime}
+
+
+ Running timer
+ {runningTime}
+
+
+
{projectTitle}
+
+ Scheduled start
+ {getTimeStart()}
+
+
+ Scheduled end
+ {getTimeEnd()}
+
+
+ );
+}
diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx
index c51069d67..53503c8b9 100644
--- a/apps/client/src/features/viewers/ViewWrapper.tsx
+++ b/apps/client/src/features/viewers/ViewWrapper.tsx
@@ -14,6 +14,7 @@ type WithDataProps = {
pres: TimerMessage;
publ: Message;
lower: Message;
+ external: Message;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
@@ -51,12 +52,12 @@ const withData =
(Component: ComponentType
) => {
}, [rundownData]);
// websocket data
- const data = useStore(runtime);
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
+ externalMessage,
playback,
onAir,
eventNext,
@@ -64,7 +65,7 @@ const withData =
(Component: ComponentType
) => {
publicEventNow,
eventNow,
loaded,
- } = data;
+ } = useStore(runtime);
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
@@ -92,6 +93,7 @@ const withData =
(Component: ComponentType
) => {
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
+ external={externalMessage}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
diff --git a/apps/client/src/features/viewers/backstage/Backstage.scss b/apps/client/src/features/viewers/backstage/Backstage.scss
index 208ed1aee..45d549e50 100644
--- a/apps/client/src/features/viewers/backstage/Backstage.scss
+++ b/apps/client/src/features/viewers/backstage/Backstage.scss
@@ -60,6 +60,7 @@
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
+ line-height: 0.95em;
}
.message {
diff --git a/apps/client/src/features/viewers/backstage/Backstage.tsx b/apps/client/src/features/viewers/backstage/Backstage.tsx
index 94fb848b1..01e1dab6f 100644
--- a/apps/client/src/features/viewers/backstage/Backstage.tsx
+++ b/apps/client/src/features/viewers/backstage/Backstage.tsx
@@ -18,6 +18,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
+import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Backstage.scss';
@@ -97,7 +98,7 @@ export default function Backstage(props: BackstageProps) {
{general.title}
{getLocalizedString('common.time_now')}
-
{clock}
+
@@ -128,12 +129,16 @@ export default function Backstage(props: BackstageProps) {
{getLocalizedString('common.started_at')}
-
{startedAt}
+
{getLocalizedString('common.expected_finish')}
-
{expectedFinish}
+ {isNegative ? (
+
{expectedFinish}
+ ) : (
+
+ )}
diff --git a/apps/client/src/features/viewers/clock/Clock.tsx b/apps/client/src/features/viewers/clock/Clock.tsx
index ef40764a2..871a053e8 100644
--- a/apps/client/src/features/viewers/clock/Clock.tsx
+++ b/apps/client/src/features/viewers/clock/Clock.tsx
@@ -10,6 +10,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime } from '../../../common/utils/time';
+import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Clock.scss';
@@ -135,7 +136,8 @@ export default function Clock(props: ClockProps) {
>
-
- {clock}
-
+ />
);
}
diff --git a/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.scss b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.scss
new file mode 100644
index 000000000..301b9d7da
--- /dev/null
+++ b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.scss
@@ -0,0 +1,4 @@
+sup.period {
+ top: -1em;
+ font-size: 0.4em;
+}
diff --git a/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx
new file mode 100644
index 000000000..6a1b99054
--- /dev/null
+++ b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx
@@ -0,0 +1,23 @@
+import { CSSProperties } from 'react';
+
+import './SuperscriptTime.scss';
+
+interface SuperscriptTimeProps {
+ time: string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function SuperscriptTime(props: SuperscriptTimeProps) {
+ const { time, className, style } = props;
+
+ // we assume anything after space is a period tag
+ const [timeString, period] = time.split(' ');
+
+ return (
+
+ {timeString}
+ {period && {period}}
+
+ );
+}
diff --git a/apps/client/src/features/viewers/countdown/Countdown.scss b/apps/client/src/features/viewers/countdown/Countdown.scss
index 7859e460e..c2c33a57b 100644
--- a/apps/client/src/features/viewers/countdown/Countdown.scss
+++ b/apps/client/src/features/viewers/countdown/Countdown.scss
@@ -68,6 +68,7 @@
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
+ line-height: 0.95em;
}
}
@@ -135,6 +136,7 @@
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
+ line-height: 0.95em;
&--delayed {
color: $delay-color;
diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx
index 441ded578..b6ac0658b 100644
--- a/apps/client/src/features/viewers/countdown/Countdown.tsx
+++ b/apps/client/src/features/viewers/countdown/Countdown.tsx
@@ -11,6 +11,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
+import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { fetchTimerData, TimerMessage } from './countdown.helpers';
import CountdownSelect from './CountdownSelect';
@@ -118,26 +119,27 @@ export default function Countdown(props: CountdownProps) {
{getLocalizedString('common.time_now')}
-
{clock}
+
{runningMessage !== TimerMessage.unhandled && (
{getLocalizedString(`countdown.${runningMessage}`)}
)}
-
- {formattedTimer}
-
+
{follow?.title || 'Untitled Event'}
{getLocalizedString('common.start_time')}
-
{startTime}
+
{getLocalizedString('common.end_time')}
-
{endTime}
+
diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
index 056a05673..16aa5c9b6 100644
--- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
+++ b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
@@ -9,6 +9,10 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
+const formatOptions = {
+ format: 'hh:mm a',
+};
+
interface CountdownSelectProps {
events: OntimeRundownEntry[];
}
@@ -31,8 +35,8 @@ export default function CountdownSelect(props: CountdownSelectProps) {
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' });
+ const start = formatTime(event.timeStart, formatOptions);
+ const end = formatTime(event.timeEnd, formatOptions);
return (
diff --git a/apps/client/src/features/viewers/public/Public.scss b/apps/client/src/features/viewers/public/Public.scss
index 504cb24ce..d38f72110 100644
--- a/apps/client/src/features/viewers/public/Public.scss
+++ b/apps/client/src/features/viewers/public/Public.scss
@@ -58,6 +58,7 @@
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
letter-spacing: 0.05em;
+ line-height: 0.95em;
}
.message {
diff --git a/apps/client/src/features/viewers/public/Public.tsx b/apps/client/src/features/viewers/public/Public.tsx
index 78d97a4f4..a9b779e99 100644
--- a/apps/client/src/features/viewers/public/Public.tsx
+++ b/apps/client/src/features/viewers/public/Public.tsx
@@ -16,6 +16,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
+import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Public.scss';
@@ -63,7 +64,7 @@ export default function Public(props: BackstageProps) {
{general.title}
{getLocalizedString('common.time_now')}
-
{clock}
+
diff --git a/apps/client/src/features/viewers/studio/StudioClock.scss b/apps/client/src/features/viewers/studio/StudioClock.scss
index 6421a99f6..cfb997e49 100644
--- a/apps/client/src/features/viewers/studio/StudioClock.scss
+++ b/apps/client/src/features/viewers/studio/StudioClock.scss
@@ -13,7 +13,7 @@ $half-hours: min(1.5vh, 10px);
$size-min: min(2.5vh, 18px);
$half-min: min(1.25vh, 9px);
$red-active: #c53030;
-$red-idle: #300000;
+$red-idle: #000000;
$cyan-active: #0ff;
$cyan-idle: #0aa;
@@ -56,11 +56,10 @@ $cyan-idle: #0aa;
.hours {
border-radius: 50%;
position: absolute;
- background: $red-idle;
+ background: var(--studio-idle, $red-idle);
&--active {
- background: $red-active;
- box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25);
+ background: var(--studio-active, $red-active);
}
}
@@ -80,7 +79,7 @@ $cyan-idle: #0aa;
}
.studio-timer {
- color: $red-active;
+ color: var(--studio-active, $red-active);
font-size: calc(#{$clock-size} / 3);
margin-top: calc(50% - calc(#{$clock-size} / 7));
line-height: 0.8em;
@@ -88,9 +87,8 @@ $cyan-idle: #0aa;
&--with-seconds {
font-size: calc(#{$clock-size} / 4.6);
margin-top: calc(50% - calc(#{$clock-size} / 11));
+ }
}
- }
-
.next-title:after,
@@ -100,14 +98,15 @@ $cyan-idle: #0aa;
}
.next-title {
- color: $cyan-idle;
+ color: var(--studio-idle-label, $cyan-idle);
text-align: center;
}
.next-countdown {
+ color: var(--studio-active-label, $cyan-active);
+
font-size: 10vh;
line-height: 1em;
- color: $cyan-active;
&--overtime {
color: darken($red-active, 10%);
@@ -131,16 +130,16 @@ $cyan-idle: #0aa;
padding-bottom: 2vh;
font-size: 15vh;
line-height: 0.9em;
- color: $red-active;
+ color: var(--studio-active, $red-active);
&--idle {
- color: $red-idle;
+ color: var(--studio-idle, $red-active);
}
}
.schedule {
ul {
- color: $cyan-idle;
+ color: var(--studio-idle-label, $cyan-idle);
font-size: 3.75vh;
line-height: 1em;
list-style: none;
@@ -154,18 +153,18 @@ $cyan-idle: #0aa;
}
.now {
- color: $cyan-active;
+ color: var(--studio-active-label, $cyan-active);
}
.next {
- color: $red-active;
+ color: var(--studio-active, $red-active);
}
.user-colour {
width: 0.35em;
height: 0.35em;
aspect-ratio: 1;
- background-color: $red-idle;
+ background-color: var(--studio-idle, $red-idle);
margin-right: 0.35em;
}
}
diff --git a/apps/client/src/features/viewers/timer/Timer.scss b/apps/client/src/features/viewers/timer/Timer.scss
index 5d53cd3cb..7cd00751e 100644
--- a/apps/client/src/features/viewers/timer/Timer.scss
+++ b/apps/client/src/features/viewers/timer/Timer.scss
@@ -48,6 +48,7 @@
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
+ line-height: 0.95em;
}
&--hidden {
@@ -78,6 +79,9 @@
justify-self: center;
align-self: center;
+ width: 100%;
+ overflow: hidden;
+
.end-message {
text-align: center;
font-size: 11.5vw;
@@ -89,7 +93,6 @@
.timer {
opacity: 1;
- font-size: 20vw;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
line-height: 0.9em;
@@ -97,6 +100,9 @@
letter-spacing: 0.05em;
font-weight: 600;
+ transition-property: font-size;
+ transition-duration: $viewer-transition-time;
+
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
@@ -108,6 +114,27 @@
}
}
+ .external {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ font-weight: 600;
+ text-align: center;
+ color: var(--external-color-override, $external-color);
+ letter-spacing: 0.5px;
+ line-height: 0.9em;
+ padding-bottom: 0.2em;
+ transition-property: opacity, height;
+ transition-duration: $viewer-transition-time;
+ border-top: 1px solid rgba(white, 0.1);
+
+ &--hidden {
+ opacity: 0;
+ height: 0;
+ }
+ }
+
.progress-container {
grid-area: progress;
width: 100%;
@@ -115,12 +142,11 @@
opacity: 1;
transition: $viewer-transition-time;
- &--paused {
- opacity: $viewer-opacity-disabled;
- transition: $viewer-transition-time;
+ &--paused {
+ opacity: $viewer-opacity-disabled;
+ transition: $viewer-transition-time;
+ }
}
-}
-
/* =================== OVERLAY ===================*/
diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx
index aaccf4c63..1b9b7a6ce 100644
--- a/apps/client/src/features/viewers/timer/Timer.tsx
+++ b/apps/client/src/features/viewers/timer/Timer.tsx
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
+import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
-import { OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
+import { Message, OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
@@ -11,7 +12,9 @@ import ViewParamsEditor from '../../../common/components/view-params-editor/View
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
+import { isStringBoolean } from '../../../common/utils/viewUtils';
import { useTranslation } from '../../../translation/TranslationProvider';
+import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import './Timer.scss';
@@ -40,6 +43,7 @@ const titleVariants = {
interface TimerProps {
isMirrored: boolean;
pres: TimerMessage;
+ external: Message;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
@@ -47,9 +51,10 @@ interface TimerProps {
}
export default function Timer(props: TimerProps) {
- const { isMirrored, pres, eventNow, eventNext, time, viewSettings } = props;
+ const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
+ const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Timer';
@@ -60,6 +65,26 @@ export default function Timer(props: TimerProps) {
return null;
}
+ // USER OPTIONS
+ const userOptions = {
+ hideClock: false,
+ hideCards: false,
+ hideProgress: false,
+ hideMessage: false,
+ };
+
+ const hideClock = searchParams.get('hideClock');
+ userOptions.hideClock = isStringBoolean(hideClock);
+
+ const hideCards = searchParams.get('hideCards');
+ userOptions.hideCards = isStringBoolean(hideCards);
+
+ const hideProgress = searchParams.get('hideProgress');
+ userOptions.hideProgress = isStringBoolean(hideProgress);
+
+ const hideMessage = searchParams.get('hideMessage');
+ userOptions.hideMessage = isStringBoolean(hideMessage);
+
const clock = formatTime(time.clock, formatOptions);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
@@ -77,6 +102,7 @@ export default function Timer(props: TimerProps) {
const showBlinking = pres.timerBlink;
const showBlackout = pres.timerBlackout;
const showClock = time.timerType !== TimerType.Clock;
+ const showExternal = external.visible && external.text;
const timerColor =
showProgress && showDanger
@@ -93,23 +119,33 @@ export default function Timer(props: TimerProps) {
}
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
- const timerFontSize = 89 / (stageTimerCharacters - 1);
+ let timerFontSize = 89 / (stageTimerCharacters - 1);
+ // we need to shrink the timer if the external is going to be there
+ if (showExternal) {
+ timerFontSize *= 0.8;
+ }
+ const externalFontSize = timerFontSize * 0.4;
+ const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
return (
-
+ {!userOptions.hideMessage && (
+
+ )}
-
-
{getLocalizedString('common.time_now')}
-
{clock}
-
+ {!userOptions.hideClock && (
+
+
{getLocalizedString('common.time_now')}
+
+
+ )}
-
+
{showEndMessage ? (
{viewSettings.endMessage}
) : (
@@ -123,54 +159,71 @@ export default function Timer(props: TimerProps) {
{display}
)}
+
+ {external.text}
+
-
+ {!userOptions.hideProgress && (
+
+ )}
-
- {eventNow && !finished && (
-
-
-
- )}
-
+ {!userOptions.hideCards && (
+ <>
+
+ {eventNow && !finished && (
+
+
+
+ )}
+
-
- {eventNext && (
-
-
-
- )}
-
+
+ {eventNext && (
+
+
+
+ )}
+
+ >
+ )}
);
}
diff --git a/apps/client/src/theme/_mixins.scss b/apps/client/src/theme/_mixins.scss
index be53bdf5b..ef3481175 100644
--- a/apps/client/src/theme/_mixins.scss
+++ b/apps/client/src/theme/_mixins.scss
@@ -2,23 +2,6 @@
//////////////////////////////////// general app elements
-@mixin main-container {
- background-color: $bg-container-l1;
- border: 1px solid $bg-container-l1;
- border-radius: 4px;
-}
-
-@mixin second-container {
- background-color: $bg-container-l2;
- border-radius: 4px;
-}
-
-@mixin third-container {
- background-color: $bg-container-l3;
- border: 1px solid rgba(0, 0, 0, 0.05);
- border-radius: 2px;
-}
-
@mixin action-link {
color: $action-text-color;
display: flex;
diff --git a/apps/client/src/theme/_viewerDefs.scss b/apps/client/src/theme/_viewerDefs.scss
index 54dda0329..e52135469 100644
--- a/apps/client/src/theme/_viewerDefs.scss
+++ b/apps/client/src/theme/_viewerDefs.scss
@@ -5,7 +5,6 @@
// General
$viewer-transition-time: 0.5s;
-
// Text
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
$viewer-opacity-disabled: 0.6;
@@ -28,3 +27,4 @@ $timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
+$external-color: rgba(white, 70%); // --external-color-override
diff --git a/apps/client/src/theme/ontimeTextInputs.ts b/apps/client/src/theme/ontimeTextInputs.ts
index e64381c52..00e000762 100644
--- a/apps/client/src/theme/ontimeTextInputs.ts
+++ b/apps/client/src/theme/ontimeTextInputs.ts
@@ -12,6 +12,11 @@ const commonStyles = {
border: '1px solid #578AF4', // $blue-500
},
_placeholder: { color: '#9d9d9d' }, // $gray-500
+ _disabled: {
+ _hover: {
+ backgroundColor: '#262626', // $gray-1200
+ },
+ },
};
export const ontimeInputFilled = {
@@ -30,6 +35,11 @@ export const ontimeInputFilledOnLight = {
_focus: {
border: '2px solid #578AF4', // $blue-500
},
+ _disabled: {
+ _hover: {
+ backgroundColor: 'white',
+ },
+ },
},
};
@@ -58,4 +68,9 @@ export const ontimeTextAreaFilledOnLight = {
border: '2px solid #578AF4', // $blue-500
},
_placeholder: { color: '#9d9d9d' }, // $gray-500
+ _disabled: {
+ _hover: {
+ backgroundColor: 'white',
+ },
+ },
};
diff --git a/apps/electron/package.json b/apps/electron/package.json
index 92fce964f..33e8c5d24 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -21,6 +21,7 @@
"scripts": {
"postinstall": "",
"lint": "eslint . --quiet",
+ "lint-staged": "eslint",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
diff --git a/apps/server/package.json b/apps/server/package.json
index 561100835..6a380ee3e 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -56,6 +56,7 @@
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint . --quiet",
+ "lint-staged": "eslint",
"test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run",
"typecheck": "tsc --noEmit",
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 812ea0205..9f142c381 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -33,6 +33,7 @@ import { populateStyles } from './modules/loadStyles.js';
import { eventStore, getInitialPayload } from './stores/EventStore.js';
import { PlaybackService } from './services/PlaybackService.js';
import { RestorePoint, restoreService } from './services/RestoreService.js';
+import { messageService } from './services/message-service/MessageService.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -155,6 +156,9 @@ export const startServer = async () => {
const initialPayload = getInitialPayload();
eventStore.init(initialPayload);
+ // eventStore set is a dependency of the services that publish to it
+ messageService.init(eventStore.set.bind(eventStore));
+
expressServer.listen(serverPort, '0.0.0.0');
return { message: returnMessage, serverPort };
diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts
index a8f5bd95d..a37579a37 100644
--- a/apps/server/src/controllers/integrationController.ts
+++ b/apps/server/src/controllers/integrationController.ts
@@ -109,6 +109,20 @@ export function dispatchFromAdapter(
break;
}
+ case 'set-external-message-text': {
+ if (typeof payload !== 'string') {
+ throw new Error(`Unable to parse payload: ${payload}`);
+ }
+ messageService.setExternalText(payload);
+ return;
+ }
+ case 'set-external-message-visible': {
+ if (typeof payload === 'undefined') {
+ throw new Error(`Unable to parse payload: ${payload}`);
+ }
+ messageService.setExternalVisibility(Boolean(payload));
+ break;
+ }
case 'start': {
PlaybackService.start();
break;
diff --git a/apps/server/src/external/styles/override.css b/apps/server/src/external/styles/override.css
index e4ab0deec..cc5a7cf21 100644
--- a/apps/server/src/external/styles/override.css
+++ b/apps/server/src/external/styles/override.css
@@ -12,10 +12,17 @@
--timer-progress-bg-override: #fff;
--timer-progress-override: #202020;
+ --external-color-override: #161616;
+
--cuesheet-running-bg-override: #D20300;
--operator-running-bg-override: #D20300;
--operator-highlight-override: #FFAB33;
+
+ --studio-active: #101010;
+ --studio-idle: #cfcfcf;
+ --studio-active-label: #101010;
+ --studio-idle-label: #595959;
}
.timer {
diff --git a/apps/server/src/services/message-service/MessageService.ts b/apps/server/src/services/message-service/MessageService.ts
index c1878cee8..5ab3bf048 100644
--- a/apps/server/src/services/message-service/MessageService.ts
+++ b/apps/server/src/services/message-service/MessageService.ts
@@ -1,7 +1,9 @@
import { Message } from 'ontime-types';
-import { eventStore } from '../../stores/EventStore.js';
import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js';
+import { throttle } from '../../utils/throttle.js';
+
+import type { PublishFn } from '../../stores/EventStore.js';
let instance;
@@ -9,8 +11,12 @@ class MessageService {
timerMessage: TimerMessage;
publicMessage: Message;
lowerMessage: Message;
+ externalMessage: Message;
onAir: boolean;
+ private throttledSet: PublishFn;
+ private publish: PublishFn | null;
+
constructor() {
if (instance) {
throw new Error('There can be only one');
@@ -36,7 +42,40 @@ class MessageService {
visible: false,
};
+ this.externalMessage = {
+ text: '',
+ visible: false,
+ };
+
this.onAir = false;
+ this.throttledSet = () => {
+ throw new Error('Published called before initialisation');
+ };
+ }
+
+ init(publish: PublishFn) {
+ this.publish = publish;
+ this.throttledSet = throttle((key, value) => this.publish(key, value), 100);
+ }
+
+ /**
+ * @description sets message on stage timer screen
+ */
+ setExternalText(payload: string) {
+ if (this.externalMessage.text !== payload) {
+ this.externalMessage.text = payload;
+ this.throttledSet('externalMessage', this.externalMessage);
+ }
+ return this.getAll();
+ }
+
+ /**
+ * @description sets message visibility on stage timer screen
+ */
+ setExternalVisibility(status: boolean) {
+ this.externalMessage.visible = status;
+ this.throttledSet('externalMessage', this.externalMessage);
+ return this.getAll();
}
/**
@@ -44,7 +83,7 @@ class MessageService {
*/
setTimerText(payload: string) {
this.timerMessage.text = payload;
- eventStore.set('timerMessage', this.timerMessage);
+ this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
@@ -53,7 +92,7 @@ class MessageService {
*/
setTimerVisibility(status: boolean) {
this.timerMessage.visible = status;
- eventStore.set('timerMessage', this.timerMessage);
+ this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
@@ -62,7 +101,7 @@ class MessageService {
*/
setPublicText(payload: string) {
this.publicMessage.text = payload;
- eventStore.set('publicMessage', this.publicMessage);
+ this.throttledSet('publicMessage', this.publicMessage);
return this.getAll();
}
@@ -71,7 +110,7 @@ class MessageService {
*/
setPublicVisibility(status: boolean) {
this.publicMessage.visible = status;
- eventStore.set('publicMessage', this.publicMessage);
+ this.throttledSet('publicMessage', this.publicMessage);
return this.getAll();
}
@@ -80,7 +119,7 @@ class MessageService {
*/
setLowerText(payload: string) {
this.lowerMessage.text = payload;
- eventStore.set('lowerMessage', this.lowerMessage);
+ this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll();
}
@@ -89,7 +128,7 @@ class MessageService {
*/
setLowerVisibility(status: boolean) {
this.lowerMessage.visible = status;
- eventStore.set('lowerMessage', this.lowerMessage);
+ this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll();
}
@@ -102,7 +141,7 @@ class MessageService {
} else {
this.onAir = status;
}
- eventStore.set('onAir', this.onAir);
+ this.throttledSet('onAir', this.onAir);
return this.getAll();
}
@@ -116,7 +155,7 @@ class MessageService {
} else {
this.timerMessage.timerBlink = status;
}
- eventStore.set('timerMessage', this.timerMessage);
+ this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
@@ -130,7 +169,7 @@ class MessageService {
} else {
this.timerMessage.timerBlackout = status;
}
- eventStore.set('timerMessage', this.timerMessage);
+ this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts
index 19e59ded9..a6410b0bf 100644
--- a/apps/server/src/stores/EventStore.ts
+++ b/apps/server/src/stores/EventStore.ts
@@ -4,6 +4,8 @@ import { eventTimer } from '../services/TimerService.js';
import { messageService } from '../services/message-service/MessageService.js';
import { eventLoader } from '../classes/event-loader/EventLoader.js';
+export type PublishFn =
(key: T, value: RuntimeStore[T]) => void;
+
let store: Partial = {};
/**
@@ -68,6 +70,7 @@ export const getInitialPayload = () => ({
timerMessage: messageService.timerMessage,
publicMessage: messageService.publicMessage,
lowerMessage: messageService.lowerMessage,
+ externalMessage: messageService.externalMessage,
onAir: messageService.onAir,
loaded: eventLoader.loaded,
eventNow: eventLoader.eventNow,
diff --git a/apps/server/src/utils/throttle.ts b/apps/server/src/utils/throttle.ts
new file mode 100644
index 000000000..275d9772b
--- /dev/null
+++ b/apps/server/src/utils/throttle.ts
@@ -0,0 +1,32 @@
+/**
+ * Creates a throttled version of the passed function
+ * This function uses a leading algorithm
+ * which means that the function will be executed immediately on first call
+ * @param {Function} cb - function to throttle
+ * @param {number} delay - time (in ms) to throttle
+ * @returns {Function}
+ */
+export function throttle(cb: (...args: T) => U, delay: number) {
+ let shouldWait = false;
+ let waitingArgs;
+ const timeoutFunc = () => {
+ if (waitingArgs == null) {
+ shouldWait = false;
+ } else {
+ cb(...waitingArgs);
+ waitingArgs = null;
+ setTimeout(timeoutFunc, delay);
+ }
+ };
+
+ return (...args: T) => {
+ if (shouldWait) {
+ waitingArgs = args;
+ return;
+ }
+
+ cb(...args);
+ shouldWait = true;
+ setTimeout(timeoutFunc, delay);
+ };
+}
diff --git a/e2e/tests/features/201-message-control.spec.ts b/e2e/tests/features/201-message-control.spec.ts
index b36d3c85a..dbe55e417 100644
--- a/e2e/tests/features/201-message-control.spec.ts
+++ b/e2e/tests/features/201-message-control.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-test('test', async ({ context }) => {
+test('message control sends messages to screens', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
@@ -25,9 +25,9 @@ test('test', async ({ context }) => {
await featurePage.getByText('testing lower').click({ timeout: 5000 });
// stage timer message
- await editorPage.getByPlaceholder('Shown in stage timer').click();
- await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage');
- await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
+ await editorPage.getByPlaceholder('Timer').click();
+ await editorPage.getByPlaceholder('Timer').fill('testing stage');
+ await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
await featurePage.goto('http://localhost:4001/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
diff --git a/e2e/tests/features/206-alias.spec.ts b/e2e/tests/features/206-alias.spec.ts
new file mode 100644
index 000000000..2888c5ab5
--- /dev/null
+++ b/e2e/tests/features/206-alias.spec.ts
@@ -0,0 +1,21 @@
+import { test, expect } from '@playwright/test';
+
+test('test aliases feature, it should redirect to given alias', async ({ page }) => {
+ await page.goto('http://localhost:4001/editor');
+
+ // open settings
+ await page.getByRole('button', { name: 'Settings' }).click();
+ await page.getByRole('tab', { name: 'URL Aliases' }).click();
+
+ // create alias
+ await page.getByRole('button', { name: 'Add new' }).click();
+ await page.getByTestId('field__alias_1').fill('testing');
+ await page.getByTestId('field__url_1').fill('countdown');
+ await page.getByTestId('field__enable_1').click();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // make sure alias works
+ await page.goto('http://localhost:4001/testing');
+ await page.getByText('Select an event to follow').click();
+});
diff --git a/packages/types/package.json b/packages/types/package.json
index 71f33b3d7..2b0cfef29 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -7,7 +7,8 @@
"description": "shared typings for ontime",
"scripts": {
"cleanup": "rm -rf .turbo && rm -rf node_modules",
- "lint": "eslint . --quiet"
+ "lint": "eslint . --quiet",
+ "lint-staged": "eslint"
},
"keywords": [],
"author": "",
diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts
index d9f68cbba..37d1c2d03 100644
--- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts
+++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts
@@ -13,6 +13,7 @@ export type RuntimeStore = {
timerMessage: TimerMessage;
publicMessage: Message;
lowerMessage: Message;
+ externalMessage: Message;
onAir: boolean;
// event loader
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 9ad7cc1a9..96d41f8ce 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -6,6 +6,7 @@
"description": "shared logic for ontime",
"scripts": {
"lint": "eslint . --quiet",
+ "lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules"