diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx
index 6db7ca7b5..ea78c44b3 100644
--- a/apps/client/src/AppRouter.tsx
+++ b/apps/client/src/AppRouter.tsx
@@ -30,7 +30,7 @@ const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
-const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
+const StudioClock = React.lazy(() => import('./views/studio/Studio'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView));
diff --git a/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx
index 18e7b3a7c..4aa5fc969 100644
--- a/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx
+++ b/apps/client/src/features/viewers/common/superscript-time/SuperscriptTime.tsx
@@ -24,9 +24,7 @@ function getTimerParts(time: string) {
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> the seconds become a subscript
*/
-export default function SuperscriptTime(props: SuperscriptTimeProps) {
- const { time, className, style } = props;
-
+export default function SuperscriptTime({ time, className, style }: SuperscriptTimeProps) {
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
const [mainTime, subscript] = getTimerParts(timeString);
diff --git a/apps/client/src/features/viewers/studio/StudioClock.scss b/apps/client/src/features/viewers/studio/StudioClock.scss
deleted file mode 100644
index 09f063f21..000000000
--- a/apps/client/src/features/viewers/studio/StudioClock.scss
+++ /dev/null
@@ -1,226 +0,0 @@
-@use '../../../theme/viewerDefs' as *;
-
-@font-face {
- font-family: 'seven-seg';
- src:
- local('DSEG7'),
- url('./../../../assets/fonts/DSEG7ClassicMini-Regular.ttf') format('truetype');
-}
-
-/* =============== CLOCK STUFF ==================*/
-
-$size-hours: clamp(12px, 2.5vmin, 22px);
-$half-hours: calc($size-hours / 2);
-$size-min: clamp(10px, 2vmin, 18px);
-$half-min: calc($size-min / 2);
-
-$red-active: $red-700;
-$red-idle: $red-1350;
-
-$cyan-active: #0ff;
-$cyan-idle: #0aa;
-
-$orange-active: #f60;
-
-.studio-clock {
- --clock-size: min(90vh, 90vw);
- --size-hours-gap: #{calc($size-hours * 1.5)};
- --half-size: calc(var(--clock-size) / 2);
- --smaller-half-size: calc(var(--half-size) - var(--size-hours-gap));
-
- overflow: hidden;
- width: 100%;
- height: 100vh;
- padding: 1vw;
-
- background: var(--background-color-override, #000);
-
- display: grid;
- // we use vh since we are dealing with a square and the smaller dimension is the height
- grid-template-columns: calc(var(--clock-size) + 5vw) 1fr;
- gap: 2.5vw;
- grid-template-areas: 'clock schedule';
- text-transform: uppercase;
-
- &.hide-right {
- grid-template-columns: 1fr;
- grid-template-areas: 'clock';
- }
-
- .clock-container {
- grid-area: clock;
- display: grid;
- place-content: center;
- justify-items: center;
- padding-top: 3em;
- gap: 0.5em;
- width: 100%;
- height: 100%;
- position: relative;
- font-family: seven-seg;
- }
-
- .clock-indicators {
- position: absolute;
- top: 0;
- width: 100%;
- height: 100%;
-
- .min,
- .hours {
- border-radius: 50%;
- position: absolute;
- background: var(--studio-idle, $red-idle);
-
- &--active {
- background: var(--studio-active, $red-active);
- }
- }
-
- .min {
- min-height: $size-min;
- width: $size-min;
- top: calc(50% - #{$half_min});
- left: calc(50% - #{$half_min});
- }
-
- .hours {
- min-height: $size-hours;
- width: $size-hours;
- top: calc(50% - #{$half_hours});
- left: calc(50% - #{$half_hours});
- }
- }
-
- .studio-timer {
- font-size: calc(var(--clock-size) / 6);
- line-height: 1em;
-
- color: var(--studio-active, $red-active);
-
- &--with-seconds {
- font-size: calc(var(--clock-size) / 6.5);
- }
-
- &::before {
- content: '88:88';
- mix-blend-mode: hard-light;
- opacity: 0.1;
- position: absolute;
- }
- }
-
- .clock__ampm {
- font-family: monospace;
- font-size: calc(var(--clock-size) / 20);
- position: relative;
- top: -2em;
- margin-bottom: -2em;
- color: var(--studio-active, $red-active);
- }
-
- .next-title {
- height: 12.5vh;
- width: 70%;
-
- font-family: monospace;
- font-weight: 400;
- color: var(--studio-active-label, $cyan-active);
- font-size: calc(var(--clock-size) / 10);
- text-align: center;
- overflow: clip hidden;
- white-space: nowrap;
- }
-
- .next-countdown {
- color: var(--studio-active, $red-active);
- font-size: calc(var(--clock-size) / 10);
- line-height: 1em;
-
- &--overtime {
- color: var(--studio-active, $red-active);
- }
- &--paused {
- color: var(--studio-overtime, $orange-active);
- }
- }
-
- .schedule-container {
- grid-area: schedule;
- font-family: monospace;
- margin-right: 2vw;
- overflow: hidden;
- }
-
- .onAir {
- font-size: 13vh;
- padding-top: 6vh;
- padding-bottom: 0.25em;
- line-height: 0.8em;
- color: var(--studio-active, $red-active);
-
- &--idle {
- color: var(--studio-idle, $red-idle);
- }
- }
-
- .schedule {
- color: var(--studio-idle-label, $red-idle);
- font-size: 3.5vh;
- line-height: 1.25em;
- list-style: none;
- }
-
- .schedule__item {
- margin-bottom: 1.5vh;
- padding-left: 0.25em;
- display: flex;
- align-items: start;
- gap: 0.5em;
-
- &--now {
- color: var(--studio-active-label, $red-active);
- }
-
- &--next {
- color: var(--studio-active, $cyan-active);
- padding-bottom: 1em;
- }
-
- &--future {
- color: var(--studio-idle-label, $cyan-idle);
- }
- }
-
- .event {
- display: flex;
- align-items: center;
- gap: 0.25em;
-
- &__colour {
- width: 0.35em;
- height: 0.35em;
- aspect-ratio: 1;
- border-radius: 0.35em;
- background-color: var(--studio-idle, $red-idle);
- }
- }
- .logo {
- position: absolute;
- top: 2vw;
- right: 2vw;
- max-width: min(200px, 20vw);
- max-height: min(100px, 20vh);
- }
-}
-
-@media only screen and (max-width: 1200px) {
- .studio-clock {
- grid-template-areas: 'clock';
- grid-template-columns: 100%;
-
- .schedule-container {
- display: none;
- }
- }
-}
diff --git a/apps/client/src/features/viewers/studio/StudioClock.tsx b/apps/client/src/features/viewers/studio/StudioClock.tsx
deleted file mode 100644
index 8c8949530..000000000
--- a/apps/client/src/features/viewers/studio/StudioClock.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { useSearchParams } from 'react-router-dom';
-import type { MaybeString, OntimeEntry, OntimeEvent, ProjectData, Settings } from 'ontime-types';
-import { Playback } from 'ontime-types';
-import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
-
-import { FitText } from '../../../common/components/fit-text/FitText';
-import ViewLogo from '../../../common/components/view-logo/ViewLogo';
-import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
-import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
-import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
-import { formatTime, getDefaultFormat } from '../../../common/utils/time';
-import { isStringBoolean } from '../common/viewUtils';
-
-import { getStudioClockOptions } from './studioClock.options';
-import StudioClockSchedule from './StudioClockSchedule';
-
-import './StudioClock.scss';
-
-interface StudioClockProps {
- events: OntimeEntry[];
- eventNext: OntimeEvent | null;
- general: ProjectData;
- isMirrored: boolean;
- time: ViewExtendedTimer;
- selectedId: MaybeString;
- nextId: MaybeString;
- onAir: boolean;
- settings: Settings | undefined;
-}
-
-export default function StudioClock(props: StudioClockProps) {
- const { events, eventNext, general, isMirrored, time, selectedId, nextId, onAir, settings } = props;
-
- const [searchParams] = useSearchParams();
-
- useWindowTitle('Studio Clock');
-
- const activeIndicators = [...Array(12).keys()];
- const secondsIndicators = [...Array(60).keys()];
-
- let clock = formatTime(time.clock);
- let hasAmPm = '';
- if (clock.includes('AM')) {
- clock = clock.replace('PM', '');
- hasAmPm = 'AM';
- } else if (clock.includes('PM')) {
- clock = clock.replace('PM', '');
- hasAmPm = 'PM';
- }
-
- const secondsNow = secondsInMillis(time.clock);
- const isNegative = (time.current ?? 0) < 0;
- const isPaused = time.playback === Playback.Pause;
-
- const defaultFormat = getDefaultFormat(settings?.timeFormat);
- const studioClockOptions = getStudioClockOptions(defaultFormat);
-
- const hideRight = isStringBoolean(searchParams.get('hideRight'));
- let timer = millisToString(time.current, { fallback: '---' });
- const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds'));
- if (time.current != null && hideSeconds) {
- timer = removeSeconds(timer);
- }
-
- return (
-
- {general?.projectLogo &&
}
-
-
- {hasAmPm &&
{hasAmPm}
}
-
{clock}
-
{eventNext?.title}
-
- {timer}
-
-
- {activeIndicators.map((i) => (
-
- ))}
- {secondsIndicators.map((i) => (
-
- ))}
-
-
- {!hideRight && (
-
- )}
-
- );
-}
diff --git a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx b/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx
deleted file mode 100644
index b1ba3a54c..000000000
--- a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent } from 'ontime-types';
-
-import { formatTime } from '../../../common/utils/time';
-import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
-
-import { trimRundown } from './studioClock.utils';
-
-import './StudioClock.scss';
-
-interface StudioClockScheduleProps {
- rundown: OntimeEntry[];
- selectedId: MaybeString;
- nextId: MaybeString;
- onAir: boolean;
-}
-
-// TODO: fit titles on screen
-const MAX_TITLES = 11;
-
-export default function StudioClockSchedule(props: StudioClockScheduleProps) {
- const { rundown, selectedId, nextId, onAir } = props;
-
- const delayed = rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
- const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
-
- return (
-
-
- ON AIR
-
-
- {trimmedRundown.map((event) => {
- const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
- const isSelected = event.id === selectedId;
- const isNext = event.id === nextId;
- const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
- return (
- -
-
-
-
-
- {event.title}
-
- );
- })}
-
-
- );
-}
diff --git a/apps/client/src/features/viewers/studio/__tests__/studioClock.utils.test.ts b/apps/client/src/features/viewers/studio/__tests__/studioClock.utils.test.ts
deleted file mode 100644
index 99b6868f4..000000000
--- a/apps/client/src/features/viewers/studio/__tests__/studioClock.utils.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { OntimeEvent } from 'ontime-types';
-
-import { trimRundown } from '../studioClock.utils';
-
-describe('test trimEventlist function', () => {
- const limit = 8;
- const testData = [
- { id: '1' },
- { id: '2' },
- { id: '3' },
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- { id: '9' },
- { id: '10' },
- { id: '11' },
- { id: '12' },
- ];
-
- it('when we use the first item', () => {
- const selectedId = '1';
- const expected = [
- { id: '1' },
- { id: '2' },
- { id: '3' },
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- ];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(limit);
- expect(l).toStrictEqual(expected);
- });
-
- it('when we use the third item', () => {
- const selectedId = '3';
- const expected = [
- { id: '3' },
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- { id: '9' },
- { id: '10' },
- ];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(limit);
- expect(l).toStrictEqual(expected);
- });
-
- it('when we use the fourth item', () => {
- const selectedId = '4';
- const expected = [
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- { id: '9' },
- { id: '10' },
- { id: '11' },
- ];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(limit);
- expect(l).toStrictEqual(expected);
- });
-
- it('result array is smaller than limit', () => {
- const selectedId = '8';
- const expected = [{ id: '8' }, { id: '9' }, { id: '10' }, { id: '11' }, { id: '12' }];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(expected.length);
- expect(l).toStrictEqual(expected);
- });
-
- it('if selected is not found', () => {
- const selectedId = '15';
- const expected = [
- { id: '1' },
- { id: '2' },
- { id: '3' },
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- ];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(limit);
- expect(l).toStrictEqual(expected);
- });
-
- it('if there is nothing selected', () => {
- const selectedId = null;
- const expected = [
- { id: '1' },
- { id: '2' },
- { id: '3' },
- { id: '4' },
- { id: '5' },
- { id: '6' },
- { id: '7' },
- { id: '8' },
- ];
-
- const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
- expect(l.length).toBe(limit);
- expect(l).toStrictEqual(expected);
- });
-});
diff --git a/apps/client/src/features/viewers/studio/studioClock.options.ts b/apps/client/src/features/viewers/studio/studioClock.options.ts
deleted file mode 100644
index 8c29eb0eb..000000000
--- a/apps/client/src/features/viewers/studio/studioClock.options.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/common.options';
-import { OptionTitle } from '../../../common/components/view-params-editor/constants';
-import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
-
-export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
- { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
- { title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds] },
- {
- title: OptionTitle.ElementVisibility,
- collapsible: true,
- options: [
- {
- id: 'hideRight',
- title: 'Hide right section',
- description: 'Hides the right section with On Air indicator and the schedule',
- type: 'boolean',
- defaultValue: false,
- },
- ],
- },
-];
diff --git a/apps/client/src/features/viewers/studio/studioClock.utils.ts b/apps/client/src/features/viewers/studio/studioClock.utils.ts
deleted file mode 100644
index a3d8068b1..000000000
--- a/apps/client/src/features/viewers/studio/studioClock.utils.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { OntimeEvent } from 'ontime-types';
-
-/**
- * @description Returns trimmed event list array
- * @param {Object[]} rundown - given rundown
- * @param {string | null} selectedId - id of currently selected event
- * @param {number} limit - max number of events to return
- * @returns {Object[]} Event list with maximum objects
- */
-export function trimRundown(rundown: OntimeEvent[], selectedId: string | null, limit: number): OntimeEvent[] {
- if (rundown.length < 1) return [];
- const startIndex = selectedId
- ? Math.max(
- rundown.findIndex((event) => event.id === selectedId),
- 0,
- )
- : 0;
- const endIndex = Math.min(startIndex + limit, rundown.length);
- const trimmedRundown = rundown.slice(startIndex, endIndex);
- return trimmedRundown;
-}
diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss
index b569915b3..32e8beaab 100644
--- a/apps/client/src/theme/_ontimeStyles.scss
+++ b/apps/client/src/theme/_ontimeStyles.scss
@@ -65,6 +65,7 @@ $aux-text-size: calc(1rem - 3px);
// media queries
$min-tablet: 500px;
$small-screen: 800px;
+$small-desktop: 1440px;
.blink {
animation: blink 1s step-start infinite;
diff --git a/apps/client/src/theme/_viewerDefs.scss b/apps/client/src/theme/_viewerDefs.scss
index ec66aa723..fe90467dc 100644
--- a/apps/client/src/theme/_viewerDefs.scss
+++ b/apps/client/src/theme/_viewerDefs.scss
@@ -41,3 +41,6 @@ $timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --font-family-bold-override
$external-color: rgba(white, 85%); // --external-color-override
+
+// properties related to the studio clock
+$studio-breakpoint: 1200px;
diff --git a/apps/client/src/views/backstage/Backstage.tsx b/apps/client/src/views/backstage/Backstage.tsx
index 152d2b042..30ed1b42d 100644
--- a/apps/client/src/views/backstage/Backstage.tsx
+++ b/apps/client/src/views/backstage/Backstage.tsx
@@ -36,18 +36,7 @@ interface BackstageProps {
}
export default function Backstage(props: BackstageProps) {
- const {
- events,
- customFields,
- eventNext,
- eventNow,
- general,
- time,
- isMirrored,
- runtime,
- selectedId,
- settings,
- } = props;
+ const { events, customFields, eventNext, eventNow, general, time, isMirrored, runtime, selectedId, settings } = props;
const { getLocalizedString } = useTranslation();
const { secondarySource } = useBackstageOptions();
diff --git a/apps/client/src/views/studio/Studio.scss b/apps/client/src/views/studio/Studio.scss
new file mode 100644
index 000000000..e5807e93d
--- /dev/null
+++ b/apps/client/src/views/studio/Studio.scss
@@ -0,0 +1,77 @@
+@use '../../theme/viewerDefs' as *;
+
+.studio {
+ margin: 0;
+ box-sizing: border-box; /* reset */
+ overflow: hidden;
+ width: 100%; /* restrict the page width to viewport */
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+
+ font-family: var(--font-family-override, $viewer-font-family);
+ background: var(--background-color-override, #000);
+ color: var(--color-override, $viewer-color);
+ padding: $view-outer-padding;
+ font-size: $base-font-size;
+
+ .studio-contents {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ place-content: center;
+ }
+
+ .studio-contents--onecol {
+ height: 100%;
+ grid-template-columns: 1fr;
+ place-content: center;
+ }
+
+ /* =================== HEADER + EXTRAS ===================*/
+
+ .project-header {
+ grid-area: header;
+ font-size: $header-font-size;
+ font-weight: 600;
+ display: flex;
+ gap: 1rem;
+ }
+
+ .logo {
+ max-width: min(200px, 20vw);
+ }
+
+ .title {
+ line-height: 1.1em;
+ }
+}
+
+/* =================== MOBILE ===================*/
+@media screen and (max-width: $small-screen) {
+ .studio {
+ .logo img {
+ height: min(50px, 10vh);
+ }
+
+ .studio-contents {
+ margin-top: $view-element-gap;
+ gap: $view-element-gap;
+ }
+ }
+}
+
+/* ================ SMALL SCREEN ================*/
+@media screen and (max-width: $studio-breakpoint) {
+ .studio {
+ .studio-contents {
+ display: flex;
+ flex-direction: column;
+ justify-content: start;
+ }
+
+ .studio-contents--onecol {
+ justify-content: center;
+ }
+ }
+}
diff --git a/apps/client/src/views/studio/Studio.tsx b/apps/client/src/views/studio/Studio.tsx
new file mode 100644
index 000000000..2369ece8f
--- /dev/null
+++ b/apps/client/src/views/studio/Studio.tsx
@@ -0,0 +1,84 @@
+import {
+ MessageState,
+ OntimeEvent,
+ ProjectData,
+ Runtime,
+ Settings,
+ SimpleTimerState,
+ ViewSettings,
+} from 'ontime-types';
+
+import ViewLogo from '../../common/components/view-logo/ViewLogo';
+import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
+import { useWindowTitle } from '../../common/hooks/useWindowTitle';
+import type { ViewExtendedTimer } from '../../common/models/TimeManager.type';
+import { cx } from '../../common/utils/styleUtils';
+import { getDefaultFormat } from '../../common/utils/time';
+
+import { getStudioOptions, useStudioOptions } from './studio.options';
+import StudioClock from './StudioClock';
+import StudioTimers from './StudioTimers';
+
+import './Studio.scss';
+
+interface StudioProps {
+ auxTimer: SimpleTimerState;
+ eventNow: OntimeEvent | null;
+ eventNext: OntimeEvent | null;
+ general: ProjectData;
+ isMirrored: boolean;
+ message: MessageState;
+ time: ViewExtendedTimer;
+ runtime: Runtime;
+ onAir: boolean;
+ settings: Settings | undefined;
+ viewSettings: ViewSettings;
+}
+
+export default function Studio({
+ auxTimer,
+ eventNow,
+ eventNext,
+ general,
+ isMirrored,
+ message,
+ time,
+ runtime,
+ onAir,
+ settings,
+ viewSettings,
+}: StudioProps) {
+ useWindowTitle('Studio Clock');
+ const { hideCards } = useStudioOptions();
+
+ // gather option data
+ const defaultFormat = getDefaultFormat(settings?.timeFormat);
+ const studioOptions = getStudioOptions(defaultFormat);
+
+ return (
+
+
+
+
+ {general?.projectLogo &&
}
+
{general.title}
+
+
+
+
+ {!hideCards && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/views/studio/StudioClock.scss b/apps/client/src/views/studio/StudioClock.scss
new file mode 100644
index 000000000..10bf95ef1
--- /dev/null
+++ b/apps/client/src/views/studio/StudioClock.scss
@@ -0,0 +1,98 @@
+@use '../../theme/viewerDefs' as *;
+
+$studio-red: $red-600;
+$studio-idle: $red-1300;
+
+.studio__clock {
+ margin: auto;
+
+ --full-size: min(75vh, 45vw);
+ --half-size: calc(var(--full-size) / 2);
+ --smaller-half-size: calc(var(--half-size) - 2rem);
+ --timer-font-size: calc(var(--half-size) / 2.5);
+ --small-font-size: calc(var(--half-size) / 10);
+
+ .clock-container {
+ position: relative;
+ width: max-content;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: var(--full-size);
+ padding: 10vh;
+ }
+
+ .time {
+ display: grid;
+ place-content: center;
+ color: $studio-red;
+ font-size: var(--timer-font-size);
+ }
+
+ .time--small {
+ position: relative;
+ display: block;
+ text-align: center;
+ }
+
+ .tick {
+ background: $studio-idle;
+ width: 1rem;
+ height: 1rem;
+ border-radius: 99px;
+
+ position: absolute;
+ top: 0;
+ top: 50%;
+ left: 50%;
+ }
+
+ .tick--active {
+ background: $studio-red;
+ }
+
+ .ampm,
+ .on-air {
+ margin: 0 auto;
+ text-align: center;
+ padding: 0.125em 0.25em;
+ line-height: 1em;
+ border-radius: 3px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ font-size: var(--small-font-size);
+ }
+
+ .ampm {
+ color: $studio-red;
+ }
+
+ .on-air {
+ opacity: 0;
+ }
+
+ .on-air--active {
+ background: $studio-red;
+ color: $ui-white;
+ opacity: 1;
+ }
+}
+
+/* =================== MOBILE ===================*/
+@media screen and (max-width: $small-screen) {
+ .studio__clock {
+ --full-size: min(75vh, 75vw);
+ }
+}
+
+/* ================ SMALL SCREEN ================*/
+@media (min-width: $small-screen) and (max-width: $small-desktop) {
+ .studio__clock {
+ padding-block: 2.5vh;
+ margin-block: 0;
+ }
+}
+
+.studio__clock--small {
+ margin: 0;
+}
diff --git a/apps/client/src/views/studio/StudioClock.tsx b/apps/client/src/views/studio/StudioClock.tsx
new file mode 100644
index 000000000..3504e1557
--- /dev/null
+++ b/apps/client/src/views/studio/StudioClock.tsx
@@ -0,0 +1,68 @@
+import { useViewportSize } from '@mantine/hooks';
+
+import { cx } from '../../common/utils/styleUtils';
+import { formatTime } from '../../common/utils/time';
+import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
+
+import { getLargeClockData } from './studioClock.utils';
+
+import './StudioClock.scss';
+
+const activeIndicators = [...Array(12).keys()];
+const secondsIndicators = [...Array(60).keys()];
+
+interface StudioClockProps {
+ onAir: boolean;
+ clock: number;
+ hideCards: boolean;
+}
+
+export default function StudioClock({ onAir, clock, hideCards }: StudioClockProps) {
+ const { width } = useViewportSize();
+
+ // if we are on mobile and have to show the cards
+ if (width < 800 && !hideCards) {
+ return ;
+ }
+
+ const { seconds, display, meridian } = getLargeClockData(clock);
+
+ return (
+
+
+ {secondsIndicators.map((i) => {
+ return (
+
+ );
+ })}
+ {activeIndicators.map((i) => (
+
+ ))}
+
{meridian}
+
{display}
+
ON AIR
+
+
+ );
+}
+
+function StudioClockMobile({ clock, onAir }: Omit) {
+ const displayClock = formatTime(clock);
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/views/studio/StudioTimers.scss b/apps/client/src/views/studio/StudioTimers.scss
new file mode 100644
index 000000000..cd199d91d
--- /dev/null
+++ b/apps/client/src/views/studio/StudioTimers.scss
@@ -0,0 +1,101 @@
+@use '../../theme/viewerDefs' as *;
+
+.studio__timers {
+ display: flex;
+ flex-direction: column;
+ gap: $view-element-gap;
+ margin-top: 5%;
+ font-size: $base-font-size;
+
+ .card {
+ background-color: var(--card-background-color-override, $viewer-card-bg-color);
+ padding: $view-card-padding;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .card__row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .label {
+ font-size: $timer-label-size;
+ color: var(--label-color-override, $viewer-label-color);
+ text-transform: uppercase;
+
+ &::after {
+ content: '\200b';
+ }
+ }
+
+ .runtime-timer {
+ font-size: $base-font-size;
+ line-height: 1;
+ }
+
+ .event-timer {
+ opacity: 1;
+ text-align: center;
+ line-height: 1;
+ letter-spacing: 0.05em;
+ font-size: $timer-value-size;
+ color: var(--timer-color-override, var(--phase-color));
+
+ &--paused {
+ opacity: $viewer-opacity-disabled;
+ transition: $viewer-transition-time;
+ }
+
+ // use a class instead of a phase, to allow suppressing overtime style
+ &--finished {
+ color: var(--timer-overtime-color-override, $timer-finished-color);
+ }
+
+ &[data-phase='warning'] {
+ color: var(--timer-warning-color-override, var(--phase-color));
+ }
+ &[data-phase='danger'] {
+ color: var(--timer-danger-color-override, var(--phase-color));
+ }
+ }
+
+ .ahead {
+ color: $playback-ahead;
+ }
+
+ .behind {
+ color: $ontime-delay-text;
+ }
+
+ .muted {
+ color: $muted-gray;
+ }
+
+ .extra {
+ font-size: $base-font-size;
+ line-height: 1;
+ }
+
+ .title {
+ margin-bottom: 0.25em;
+ }
+
+ .right {
+ text-align: right;
+ }
+
+ .center {
+ text-align: center;
+ }
+}
+
+/* =================== MOBILE ===================*/
+@media screen and (max-width: $studio-breakpoint) {
+ .studio__timers {
+ margin: 0;
+ }
+}
diff --git a/apps/client/src/views/studio/StudioTimers.tsx b/apps/client/src/views/studio/StudioTimers.tsx
new file mode 100644
index 000000000..35ebb7686
--- /dev/null
+++ b/apps/client/src/views/studio/StudioTimers.tsx
@@ -0,0 +1,147 @@
+import { OntimeEvent, Playback, Runtime, TimerPhase, TimerState, ViewSettings } from 'ontime-types';
+import { millisToString } from 'ontime-utils';
+
+import { cx } from '../../common/utils/styleUtils';
+import { useTranslation } from '../../translation/TranslationProvider';
+import { getTimerColour } from '../utils/presentation.utils';
+
+import { getFormattedEventData, getFormattedScheduleTimes } from './studioTimers.utils';
+
+import './StudioTimers.scss';
+
+interface StudioTimersProps {
+ runtime: Runtime;
+ time: TimerState;
+ eventNow: OntimeEvent | null;
+ eventNext: OntimeEvent | null;
+ auxTimer: number;
+ timerMessage: string;
+ secondaryMessage: string;
+ viewSettings: ViewSettings;
+}
+
+export default function StudioTimers({
+ runtime,
+ time,
+ eventNow,
+ eventNext,
+ auxTimer,
+ timerMessage,
+ secondaryMessage,
+ viewSettings,
+}: StudioTimersProps) {
+ const { getLocalizedString } = useTranslation();
+
+ const schedule = getFormattedScheduleTimes(runtime);
+ const event = getFormattedEventData(eventNow, time);
+ const eventNextTitle = eventNext?.title || '-';
+ const formattedAuxTimer = millisToString(auxTimer);
+ const formattedTimerMessage = timerMessage || '-';
+ const formattedSecondaryMessage = secondaryMessage || '-';
+
+ // gather presentation styles
+ const timerColour = getTimerColour(viewSettings, time.phase === TimerPhase.Warning, time.phase === TimerPhase.Danger);
+
+ return (
+
+
+
+
+
{getLocalizedString('common.started_at')}
+
{schedule.actualStart}
+
+
+
Offset
+
+ {schedule.offset}
+
+
+
+
{getLocalizedString('common.projected_end')}
+
{schedule.expectedEnd}
+
+
+
+
+
+
+
+
{getLocalizedString('common.now')}
+
{event.title}
+
+
+
{getLocalizedString('common.next')}
+
{eventNextTitle}
+
+
+
+
+
+
{getLocalizedString('common.started_at')}
+
{event.startedAt}
+
+
+
+
{getLocalizedString('common.projected_end')}
+
{event.expectedEnd}
+
+
+
+
+
+
+
+
Aux 1
+
{formattedAuxTimer}
+
+
+
+
+
+
+
+
+
+
+
Timer message
+
{formattedTimerMessage}
+
+
+
+
+
+
Secondary message
+
{formattedSecondaryMessage}
+
+
+
+ );
+}
diff --git a/apps/client/src/views/studio/studio.options.ts b/apps/client/src/views/studio/studio.options.ts
new file mode 100644
index 000000000..846ae3772
--- /dev/null
+++ b/apps/client/src/views/studio/studio.options.ts
@@ -0,0 +1,48 @@
+import { useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import { getTimeOption } from '../../common/components/view-params-editor/common.options';
+import { OptionTitle } from '../../common/components/view-params-editor/constants';
+import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
+import { isStringBoolean } from '../../features/viewers/common/viewUtils';
+
+export const getStudioOptions = (timeFormat: string): ViewOption[] => [
+ { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
+ {
+ title: OptionTitle.ElementVisibility,
+ collapsible: true,
+ options: [
+ {
+ id: 'hideCards',
+ title: 'Hide cards section',
+ description: 'Hides the card section with the timers',
+ type: 'boolean',
+ defaultValue: false,
+ },
+ ],
+ },
+];
+
+type StudioOptions = {
+ hideCards: boolean;
+};
+
+/**
+ * Utility extract the view options from URL Params
+ * the names and fallback are manually matched with timerOptions
+ */
+function getOptionsFromParams(searchParams: URLSearchParams): StudioOptions {
+ // we manually make an object that matches the key above
+ return {
+ hideCards: isStringBoolean(searchParams.get('hideCards')),
+ };
+}
+
+/**
+ * Hook exposes the backstage view options
+ */
+export function useStudioOptions(): StudioOptions {
+ const [searchParams] = useSearchParams();
+ const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ return options;
+}
diff --git a/apps/client/src/views/studio/studioClock.utils.ts b/apps/client/src/views/studio/studioClock.utils.ts
new file mode 100644
index 000000000..6dcd0812c
--- /dev/null
+++ b/apps/client/src/views/studio/studioClock.utils.ts
@@ -0,0 +1,21 @@
+import { secondsInMillis } from 'ontime-utils';
+
+import { formatTime } from '../../common/utils/time';
+
+/**
+ * Gathers display elements for the large studio clock
+ */
+export function getLargeClockData(clock: number) {
+ const [display, meridian] = (() => {
+ const formatted = formatTime(clock);
+ if (formatted.endsWith('AM')) {
+ return [formatted.slice(0, -2), 'AM'];
+ }
+ if (formatted.endsWith('PM')) {
+ return [formatted.slice(0, -2), 'PM'];
+ }
+ return [formatted, undefined];
+ })();
+
+ return { seconds: secondsInMillis(clock), display, meridian };
+}
diff --git a/apps/client/src/views/studio/studioTimers.utils.ts b/apps/client/src/views/studio/studioTimers.utils.ts
new file mode 100644
index 000000000..76984891c
--- /dev/null
+++ b/apps/client/src/views/studio/studioTimers.utils.ts
@@ -0,0 +1,22 @@
+import { OntimeEvent, Runtime, TimerState } from 'ontime-types';
+import { millisToString } from 'ontime-utils';
+
+import { formatTime } from '../../common/utils/time';
+
+const timeFormat = { format12: 'h:mm a', format24: 'HH:mm' };
+export function getFormattedScheduleTimes(runtime: Runtime) {
+ return {
+ actualStart: formatTime(runtime.actualStart, timeFormat),
+ expectedEnd: formatTime(runtime.expectedEnd, timeFormat),
+ offset: millisToString(runtime.offset),
+ };
+}
+
+export function getFormattedEventData(eventNow: OntimeEvent | null, timer: TimerState) {
+ return {
+ title: eventNow?.title || '-',
+ startedAt: formatTime(timer.startedAt, timeFormat),
+ expectedEnd: formatTime(timer.expectedFinish, timeFormat),
+ timer: millisToString(timer.current),
+ };
+}