Studio clock (#663)

* refactor: trim rundown shows only upcoming

* style: small tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2023-12-28 13:25:45 +01:00
committed by GitHub
parent dc4ba4012c
commit b2c01a141a
7 changed files with 251 additions and 355 deletions
@@ -1,182 +1,209 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/viewerDefs' as *;
@font-face {
font-family: "digital-clock";
src: local('digital-7'), url('./../../../assets/fonts/digital-7.monoitalic.ttf') format('truetype') ;
font-family: 'seven-seg';
src:
local('DSEG7'),
url('./../../../assets/fonts/DSEG7ClassicMini-Regular.ttf') format('truetype');
}
/* =============== CLOCK STUFF ==================*/
$clock-size: 90vh;
$size-hours: min(3vh, 20px);
$half-hours: min(1.5vh, 10px);
$size-min: min(2.5vh, 18px);
$half-min: min(1.25vh, 9px);
$red-active: #c53030;
$red-idle: #000000;
$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 {
margin: 0;
box-sizing: border-box; /* reset */
--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%; /* restrict the page width to viewport */
width: 100%;
height: 100vh;
padding: 1vw;
background: var(--background-color-override, #000);
display: grid;
grid-template-columns: 95vh 1fr;
gap: 2vw;
grid-template-areas: "clck schd";
// 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;
}
.clock-container {
display: flex;
flex-direction: column;
align-items: center;
grid-area: clck;
width: $clock-size;
height: $clock-size;
aspect-ratio: 1;
text-align: center;
position: relative;
margin: 4vh auto;
font-family: digital-clock, monospace;
text-transform: uppercase;
.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%;
.clock-indicators {
position: absolute;
top: 0;
width: 100%;
height: 100%;
.min,
.hours {
border-radius: 50%;
position: absolute;
background: var(--studio-idle, $red-idle);
.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 {
color: var(--studio-active, $red-active);
font-size: calc(#{$clock-size} / 3);
margin-top: calc(50% - calc(#{$clock-size} / 7));
line-height: 0.8em;
&--with-seconds {
font-size: calc(#{$clock-size} / 4.6);
margin-top: calc(50% - calc(#{$clock-size} / 11));
}
}
.next-title:after,
.next-title:after,
.next-countdown--overtime:after {
content: '\200b';
}
.next-title {
color: var(--studio-idle-label, $cyan-idle);
text-align: center;
}
.next-countdown {
color: var(--studio-active-label, $cyan-active);
font-size: 10vh;
line-height: 1em;
&--overtime {
color: darken($red-active, 10%);
}
}
.next-countdown::before {
content: '-';
&--active {
background: var(--studio-active, $red-active);
}
}
/* ============= SCHEDULE STUFF =================*/
.min {
min-height: $size-min;
width: $size-min;
top: calc(50% - #{$half_min});
left: calc(50% - #{$half_min});
}
.schedule-container {
grid-area: schd;
margin: 4vh 0;
font-family: digital-clock, monospace;
text-transform: uppercase;
.hours {
min-height: $size-hours;
width: $size-hours;
top: calc(50% - #{$half_hours});
left: calc(50% - #{$half_hours});
}
}
.onAir {
padding-bottom: 2vh;
font-size: 15vh;
line-height: 0.9em;
color: var(--studio-active, $red-active);
.studio-timer {
font-size: calc(var(--clock-size) / 5);
line-height: 1em;
&--idle {
color: var(--studio-idle, $red-active);
}
}
color: var(--studio-active, $red-active);
.schedule {
ul {
color: var(--studio-idle-label, $cyan-idle);
font-size: 3.75vh;
line-height: 1em;
list-style: none;
}
&--with-seconds {
font-size: calc(var(--clock-size) / 6.5);
}
li {
margin-bottom: 1.5vh;
padding-left: 0.25em;
display: flex;
align-items: center;
}
&::before {
content: '88:88';
mix-blend-mode: hard-light;
opacity: 0.1;
position: absolute;
}
}
.now {
color: var(--studio-active-label, $cyan-active);
}
.ampmIndicator {
font-family: monospace;
font-size: calc(var(--clock-size) / 20);
position: relative;
top: -2em;
margin-bottom: -2em;
color: var(--studio-active, $red-active);
}
.next {
color: var(--studio-active, $red-active);
}
.next-title {
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;
}
.user-colour {
width: 0.35em;
height: 0.35em;
aspect-ratio: 1;
background-color: var(--studio-idle, $red-idle);
margin-right: 0.35em;
}
}
.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;
}
.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;
white-space: nowrap;
&--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);
}
}
@media only screen and (max-width: 1200px) {
.studio-clock {
display: grid;
grid-template-areas: "clck";
grid-template-areas: 'clock';
grid-template-columns: 100%;
place-content: center;
.schedule-container {
display: none;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -12,9 +12,11 @@ import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig';
import { formatTime } from '../../../common/utils/time';
import { formatTime, resolveTimeFormat } from '../../../common/utils/time';
import { mth } from '../../../common/utils/timeConstants';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
import { trimRundown } from './studioClock.utils';
import './StudioClock.scss';
@@ -42,14 +44,15 @@ export default function StudioClock(props: StudioClockProps) {
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
const [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
const MAX_TITLES = 12;
// TODO: fit titles on screen
const MAX_TITLES = 11;
const [searchParams] = useSearchParams();
const showSeconds = searchParams.get('seconds');
const timeFormat = resolveTimeFormat();
formatOptions.showSeconds = Boolean(showSeconds);
formatOptions.format = `hh:mm${formatOptions.showSeconds ? ':ss' : ''}`;
@@ -57,42 +60,38 @@ export default function StudioClock(props: StudioClockProps) {
document.title = 'ontime - Studio Clock';
}, []);
// Prepare event list
useEffect(() => {
if (!backstageEvents) {
return;
}
const delayed = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const trimmed = trimRundown(delayed as OntimeEvent[], selectedId || '', MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
showEnd: false,
});
setSchedule(formatted);
}, [backstageEvents, nextId, selectedId]);
const clock = formatTime(time.clock, formatOptions);
const secondsNow = secondsInMillis(time.clock);
const isNegative = (time.current ?? 0) < 0;
const isPaused = time.playback === Playback.Pause;
const studioClockOptions = getStudioClockOptions(settings?.timeFormat ?? '24');
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
const isAm = time.clock / (mth * 12) > 12;
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={studioClockOptions} />
<div className='clock-container'>
{timeFormat == '12' && <div className='ampmIndicator'>{isAm ? 'am' : 'pm'}</div>}
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
ref={titleRef}
className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
style={{ fontSize: titleFontSize, height: '12.5vh', width: '100%', maxWidth: '80%' }}
>
{eventNext?.title ?? ''}
{eventNext?.title ?? '---'}
</div>
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId !== null && formatDisplay(time.current)}
<div
className={`
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
`}
>
{isNegative ? '-' : ''}
{formatDisplay(time.current)}
</div>
<div className='clock-indicators'>
{activeIndicators.map((i) => (
@@ -100,7 +99,7 @@ export default function StudioClock(props: StudioClockProps) {
key={i}
className='hours hours--active'
style={{
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(40vh)`,
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(var(--smaller-half-size))`,
}}
/>
))}
@@ -109,7 +108,7 @@ export default function StudioClock(props: StudioClockProps) {
key={i}
className={i <= secondsNow ? 'min min--active' : 'min'}
style={{
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(43vh)`,
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(var(--half-size))`,
}}
/>
))}
@@ -122,16 +121,23 @@ export default function StudioClock(props: StudioClockProps) {
>
ON AIR
</div>
<div className='schedule'>
<ul>
{schedule.map((s) => (
<li key={s.id} className={s.isNow ? 'now' : s.isNext ? 'next' : ''}>
<div className='user-colour' style={{ backgroundColor: `${s.colour !== '' ? s.colour : ''}` }} />
{`${s.time} ${s.title}`}
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0));
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>
);
})}
</ul>
</div>
</div>
);
@@ -1,6 +1,6 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { OntimeEvent } from 'ontime-types';
import { formatEventList, trimRundown } from '../studioClock.utils';
import { trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
@@ -40,14 +40,14 @@ describe('test trimEventlist function', () => {
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
@@ -58,14 +58,14 @@ describe('test trimEventlist function', () => {
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
@@ -73,6 +73,15 @@ describe('test trimEventlist function', () => {
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 = [
@@ -90,114 +99,22 @@ describe('test trimEventlist function', () => {
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
it('if there is nothing selected', () => {
const selectedId = null;
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
@@ -1,76 +1,21 @@
import { OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @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 const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
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;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
// format list
return rundown.map((event) => {
const start = formatTime(event.timeStart + (event.delay || 0));
const end = formatTime(event.timeEnd + (event.delay || 0));
return {
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
};
});
};
}
+1
View File
@@ -23,6 +23,7 @@
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
}
.timer {