refactor: review studio design

This commit is contained in:
Carlos Valente
2025-02-24 22:19:36 +01:00
committed by Carlos Valente
parent 3cd6cd2f55
commit e0f2830072
20 changed files with 673 additions and 563 deletions
+1 -12
View File
@@ -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();
+77
View File
@@ -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;
}
}
}
+84
View File
@@ -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 (
<div className={cx(['studio', isMirrored && 'mirror'])} data-testid='studio-view'>
<ViewParamsEditor viewOptions={studioOptions} />
<div className='project-header'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<div className='title'>{general.title}</div>
</div>
<div className={cx(['studio-contents', hideCards && 'studio-contents--onecol'])}>
<StudioClock onAir={onAir} clock={time.clock} hideCards={hideCards} />
{!hideCards && (
<StudioTimers
eventNow={eventNow}
eventNext={eventNext}
auxTimer={auxTimer.current}
timerMessage={message.timer.visible ? message.timer.text : ''}
secondaryMessage={message.secondary}
runtime={runtime}
time={time}
viewSettings={viewSettings}
/>
)}
</div>
</div>
);
}
@@ -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;
}
@@ -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 <StudioClockMobile clock={clock} onAir={onAir} />;
}
const { seconds, display, meridian } = getLargeClockData(clock);
return (
<div className='studio__clock'>
<div className='clock-container'>
{secondsIndicators.map((i) => {
return (
<div
key={i}
className={cx(['tick', i <= seconds && 'tick--active'])}
style={{ transform: `rotate(${180 + i * 6}deg) translateY(var(--half-size))` }}
/>
);
})}
{activeIndicators.map((i) => (
<div
key={i}
className='tick tick--active'
style={{
transform: `rotate(${180 + i * 30}deg) translateX(var(--smaller-half-size))`,
}}
/>
))}
<div className={cx(['ampm', Boolean(meridian) && 'ampm--active'])}>{meridian}</div>
<div className='time time--large'>{display}</div>
<div className={cx(['on-air', onAir && 'on-air--active'])}>ON AIR</div>
</div>
</div>
);
}
function StudioClockMobile({ clock, onAir }: Omit<StudioClockProps, 'hideCards'>) {
const displayClock = formatTime(clock);
return (
<div className='studio__clock studio__clock--small'>
<SuperscriptTime className='time time--small' time={displayClock} />
<div className={cx(['on-air', onAir && 'on-air--active'])}>ON AIR</div>
</div>
);
}
@@ -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;
}
}
@@ -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 (
<div className='studio__timers'>
<div className='card' id='card-schedule'>
<div className='card__row'>
<div>
<div className='label'>{getLocalizedString('common.started_at')}</div>
<div className='runtime-timer'>{schedule.actualStart}</div>
</div>
<div>
<div className='label center'>Offset</div>
<div
className={cx([
'runtime-timer',
'center',
!eventNow && 'muted',
runtime.offset <= 0 ? 'behind' : 'ahead',
])}
>
{schedule.offset}
</div>
</div>
<div>
<div className='label right'>{getLocalizedString('common.projected_end')}</div>
<div className='runtime-timer right'>{schedule.expectedEnd}</div>
</div>
</div>
</div>
<div className='card' id='card-event-now'>
<div className='card__row'>
<div>
<div className='label'>{getLocalizedString('common.now')}</div>
<div className='title'>{event.title}</div>
</div>
<div>
<div className='label right'>{getLocalizedString('common.next')}</div>
<div className='title right'>{eventNextTitle}</div>
</div>
</div>
<div className='card__row'>
<div>
<div className='label'>{getLocalizedString('common.started_at')}</div>
<div className='runtime-timer'>{event.startedAt}</div>
</div>
<div>
<div className='label' />
<div
className={cx([
'event-timer',
time.phase === TimerPhase.Overtime && 'event-timer--finished',
time.playback === Playback.Pause && 'event-timer--paused',
])}
style={{
'--phase-color': timerColour,
}}
data-phase={time.phase}
>
{event.timer}
</div>
</div>
<div>
<div className='label right'>{getLocalizedString('common.projected_end')}</div>
<div className='runtime-timer right'>{event.expectedEnd}</div>
</div>
</div>
</div>
<div className='card' id='card-aux'>
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='extra'>{formattedAuxTimer}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='extra center'>NOT YET</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='extra right'>NOT YET</div>
</div>
</div>
</div>
<div className='card' id='card-timer-message'>
<div>
<div className='label'>Timer message</div>
<div className={cx(['extra', !formattedTimerMessage && 'muted'])}>{formattedTimerMessage}</div>
</div>
</div>
<div className='card' id='card-secondary-message'>
<div>
<div className='label'>Secondary message</div>
<div className={cx(['extra', !formattedSecondaryMessage && 'muted'])}>{formattedSecondaryMessage}</div>
</div>
</div>
</div>
);
}
@@ -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;
}
@@ -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 };
}
@@ -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),
};
}