mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 18:33:53 +00:00
refactor: review studio design
This commit is contained in:
committed by
Carlos Valente
parent
3cd6cd2f55
commit
e0f2830072
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
|
||||
data-testid='studio-view'
|
||||
>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
<ViewParamsEditor viewOptions={studioClockOptions} />
|
||||
<div className='clock-container'>
|
||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
||||
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
||||
<FitText className='next-title'>{eventNext?.title}</FitText>
|
||||
<div
|
||||
className={`
|
||||
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
|
||||
`}
|
||||
>
|
||||
{timer}
|
||||
</div>
|
||||
<div className='clock-indicators'>
|
||||
{activeIndicators.map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='hours hours--active'
|
||||
style={{
|
||||
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(var(--smaller-half-size))`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{secondsIndicators.map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i <= secondsNow ? 'min min--active' : 'min'}
|
||||
style={{
|
||||
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(var(--half-size))`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRight && (
|
||||
<StudioClockSchedule rundown={events} selectedId={selectedId} nextId={nextId} onAir={onAir} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className='schedule-container'>
|
||||
<div className={onAir ? 'onAir' : 'onAir onAir--idle'} data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}>
|
||||
ON AIR
|
||||
</div>
|
||||
<ul className='schedule'>
|
||||
{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 (
|
||||
<li key={event.id} className={classes}>
|
||||
<span className='event'>
|
||||
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
|
||||
<SuperscriptTime time={start} />
|
||||
</span>
|
||||
<span>{event.title}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 <limit> 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user