From e0f2830072fcc105c8e13207213a49123a66bbc3 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 24 Feb 2025 22:19:36 +0100 Subject: [PATCH] refactor: review studio design --- apps/client/src/AppRouter.tsx | 2 +- .../superscript-time/SuperscriptTime.tsx | 4 +- .../features/viewers/studio/StudioClock.scss | 226 ------------------ .../features/viewers/studio/StudioClock.tsx | 109 --------- .../viewers/studio/StudioClockSchedule.tsx | 50 ---- .../__tests__/studioClock.utils.test.ts | 120 ---------- .../viewers/studio/studioClock.options.ts | 21 -- .../viewers/studio/studioClock.utils.ts | 21 -- apps/client/src/theme/_ontimeStyles.scss | 1 + apps/client/src/theme/_viewerDefs.scss | 3 + apps/client/src/views/backstage/Backstage.tsx | 13 +- apps/client/src/views/studio/Studio.scss | 77 ++++++ apps/client/src/views/studio/Studio.tsx | 84 +++++++ apps/client/src/views/studio/StudioClock.scss | 98 ++++++++ apps/client/src/views/studio/StudioClock.tsx | 68 ++++++ .../client/src/views/studio/StudioTimers.scss | 101 ++++++++ apps/client/src/views/studio/StudioTimers.tsx | 147 ++++++++++++ .../client/src/views/studio/studio.options.ts | 48 ++++ .../src/views/studio/studioClock.utils.ts | 21 ++ .../src/views/studio/studioTimers.utils.ts | 22 ++ 20 files changed, 673 insertions(+), 563 deletions(-) delete mode 100644 apps/client/src/features/viewers/studio/StudioClock.scss delete mode 100644 apps/client/src/features/viewers/studio/StudioClock.tsx delete mode 100644 apps/client/src/features/viewers/studio/StudioClockSchedule.tsx delete mode 100644 apps/client/src/features/viewers/studio/__tests__/studioClock.utils.test.ts delete mode 100644 apps/client/src/features/viewers/studio/studioClock.options.ts delete mode 100644 apps/client/src/features/viewers/studio/studioClock.utils.ts create mode 100644 apps/client/src/views/studio/Studio.scss create mode 100644 apps/client/src/views/studio/Studio.tsx create mode 100644 apps/client/src/views/studio/StudioClock.scss create mode 100644 apps/client/src/views/studio/StudioClock.tsx create mode 100644 apps/client/src/views/studio/StudioTimers.scss create mode 100644 apps/client/src/views/studio/StudioTimers.tsx create mode 100644 apps/client/src/views/studio/studio.options.ts create mode 100644 apps/client/src/views/studio/studioClock.utils.ts create mode 100644 apps/client/src/views/studio/studioTimers.utils.ts 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 ( +
+ +
ON AIR
+
+ ); +} 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}
+
+
+
+
+ {event.timer} +
+
+
+
{getLocalizedString('common.projected_end')}
+
{event.expectedEnd}
+
+
+
+ +
+
+
+
Aux 1
+
{formattedAuxTimer}
+
+ +
+
Aux 2
+
NOT YET
+
+ +
+
Aux 3
+
NOT YET
+
+
+
+ +
+
+
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), + }; +}