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 *; @use '../../../theme/viewerDefs' as *;
@font-face { @font-face {
font-family: "digital-clock"; font-family: 'seven-seg';
src: local('digital-7'), url('./../../../assets/fonts/digital-7.monoitalic.ttf') format('truetype') ; src:
local('DSEG7'),
url('./../../../assets/fonts/DSEG7ClassicMini-Regular.ttf') format('truetype');
} }
/* =============== CLOCK STUFF ==================*/ /* =============== CLOCK STUFF ==================*/
$clock-size: 90vh; $size-hours: clamp(12px, 2.5vmin, 22px);
$size-hours: min(3vh, 20px); $half-hours: calc($size-hours / 2);
$half-hours: min(1.5vh, 10px); $size-min: clamp(10px, 2vmin, 18px);
$size-min: min(2.5vh, 18px); $half-min: calc($size-min / 2);
$half-min: min(1.25vh, 9px);
$red-active: #c53030; $red-active: $red-700;
$red-idle: #000000; $red-idle: $red-1350;
$cyan-active: #0ff; $cyan-active: #0ff;
$cyan-idle: #0aa; $cyan-idle: #0aa;
$orange-active: #f60;
.studio-clock { .studio-clock {
margin: 0; --clock-size: min(90vh, 90vw);
box-sizing: border-box; /* reset */ --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; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%;
height: 100vh; height: 100vh;
padding: 1vw; padding: 1vw;
background: var(--background-color-override, #000); background: var(--background-color-override, #000);
display: grid; display: grid;
grid-template-columns: 95vh 1fr; // we use vh since we are dealing with a square and the smaller dimension is the height
gap: 2vw; grid-template-columns: calc(var(--clock-size) + 5vw) 1fr;
grid-template-areas: "clck schd"; gap: 2.5vw;
grid-template-areas: 'clock schedule';
text-transform: uppercase;
}
.clock-container { .clock-container {
display: flex; grid-area: clock;
flex-direction: column; display: grid;
align-items: center; place-content: center;
grid-area: clck; justify-items: center;
width: $clock-size; padding-top: 3em;
height: $clock-size; gap: 0.5em;
aspect-ratio: 1; width: 100%;
text-align: center; height: 100%;
position: relative; position: relative;
margin: 4vh auto; font-family: seven-seg;
font-family: digital-clock, monospace; }
text-transform: uppercase;
.clock-indicators { .clock-indicators {
position: absolute; position: absolute;
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
.min, .min,
.hours { .hours {
border-radius: 50%; border-radius: 50%;
position: absolute; position: absolute;
background: var(--studio-idle, $red-idle); background: var(--studio-idle, $red-idle);
&--active { &--active {
background: var(--studio-active, $red-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: '-';
} }
} }
/* ============= SCHEDULE STUFF =================*/ .min {
min-height: $size-min;
width: $size-min;
top: calc(50% - #{$half_min});
left: calc(50% - #{$half_min});
}
.schedule-container { .hours {
grid-area: schd; min-height: $size-hours;
margin: 4vh 0; width: $size-hours;
font-family: digital-clock, monospace; top: calc(50% - #{$half_hours});
text-transform: uppercase; left: calc(50% - #{$half_hours});
}
}
.onAir { .studio-timer {
padding-bottom: 2vh; font-size: calc(var(--clock-size) / 5);
font-size: 15vh; line-height: 1em;
line-height: 0.9em;
color: var(--studio-active, $red-active);
&--idle { color: var(--studio-active, $red-active);
color: var(--studio-idle, $red-active);
}
}
.schedule { &--with-seconds {
ul { font-size: calc(var(--clock-size) / 6.5);
color: var(--studio-idle-label, $cyan-idle); }
font-size: 3.75vh;
line-height: 1em;
list-style: none;
}
li { &::before {
margin-bottom: 1.5vh; content: '88:88';
padding-left: 0.25em; mix-blend-mode: hard-light;
display: flex; opacity: 0.1;
align-items: center; position: absolute;
} }
}
.now { .ampmIndicator {
color: var(--studio-active-label, $cyan-active); font-family: monospace;
} font-size: calc(var(--clock-size) / 20);
position: relative;
top: -2em;
margin-bottom: -2em;
color: var(--studio-active, $red-active);
}
.next { .next-title {
color: var(--studio-active, $red-active); 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 { .next-countdown {
width: 0.35em; color: var(--studio-active, $red-active);
height: 0.35em; font-size: calc(var(--clock-size) / 10);
aspect-ratio: 1; line-height: 1em;
background-color: var(--studio-idle, $red-idle);
margin-right: 0.35em; &--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) { @media only screen and (max-width: 1200px) {
.studio-clock { .studio-clock {
display: grid; grid-template-areas: 'clock';
grid-template-areas: "clck";
grid-template-columns: 100%; grid-template-columns: 100%;
place-content: center;
.schedule-container { .schedule-container {
display: none; display: none;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types'; 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 { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants'; import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -12,9 +12,11 @@ import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig'; 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'; import './StudioClock.scss';
@@ -42,14 +44,15 @@ export default function StudioClock(props: StudioClockProps) {
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 }); const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
const [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
const activeIndicators = [...Array(12).keys()]; const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()]; const secondsIndicators = [...Array(60).keys()];
const MAX_TITLES = 12;
// TODO: fit titles on screen
const MAX_TITLES = 11;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const showSeconds = searchParams.get('seconds'); const showSeconds = searchParams.get('seconds');
const timeFormat = resolveTimeFormat();
formatOptions.showSeconds = Boolean(showSeconds); formatOptions.showSeconds = Boolean(showSeconds);
formatOptions.format = `hh:mm${formatOptions.showSeconds ? ':ss' : ''}`; formatOptions.format = `hh:mm${formatOptions.showSeconds ? ':ss' : ''}`;
@@ -57,42 +60,38 @@ export default function StudioClock(props: StudioClockProps) {
document.title = 'ontime - Studio Clock'; 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 clock = formatTime(time.clock, formatOptions);
const secondsNow = secondsInMillis(time.clock); const secondsNow = secondsInMillis(time.clock);
const isNegative = (time.current ?? 0) < 0; const isNegative = (time.current ?? 0) < 0;
const isPaused = time.playback === Playback.Pause;
const studioClockOptions = getStudioClockOptions(settings?.timeFormat ?? '24'); 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 ( return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'> <div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<NavigationMenu /> <NavigationMenu />
<ViewParamsEditor paramFields={studioClockOptions} /> <ViewParamsEditor paramFields={studioClockOptions} />
<div className='clock-container'> <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 className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div <div
ref={titleRef} ref={titleRef}
className='next-title' 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>
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}> <div
{selectedId !== null && formatDisplay(time.current)} className={`
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
`}
>
{isNegative ? '-' : ''}
{formatDisplay(time.current)}
</div> </div>
<div className='clock-indicators'> <div className='clock-indicators'>
{activeIndicators.map((i) => ( {activeIndicators.map((i) => (
@@ -100,7 +99,7 @@ export default function StudioClock(props: StudioClockProps) {
key={i} key={i}
className='hours hours--active' className='hours hours--active'
style={{ 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} key={i}
className={i <= secondsNow ? 'min min--active' : 'min'} className={i <= secondsNow ? 'min min--active' : 'min'}
style={{ 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 ON AIR
</div> </div>
<div className='schedule'> <ul className='schedule'>
<ul> {trimmedRundown.map((event) => {
{schedule.map((s) => ( const start = formatTime(event.timeStart + (event?.delay ?? 0));
<li key={s.id} className={s.isNow ? 'now' : s.isNext ? 'next' : ''}> const isSelected = event.id === selectedId;
<div className='user-colour' style={{ backgroundColor: `${s.colour !== '' ? s.colour : ''}` }} /> const isNext = event.id === nextId;
{`${s.time} ${s.title}`} 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> </li>
))} );
</ul> })}
</div> </ul>
</div> </div>
</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', () => { describe('test trimEventlist function', () => {
const limit = 8; const limit = 8;
@@ -40,14 +40,14 @@ describe('test trimEventlist function', () => {
it('when we use the third item', () => { it('when we use the third item', () => {
const selectedId = '3'; const selectedId = '3';
const expected = [ const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' }, { id: '3' },
{ id: '4' }, { id: '4' },
{ id: '5' }, { id: '5' },
{ id: '6' }, { id: '6' },
{ id: '7' }, { id: '7' },
{ id: '8' }, { id: '8' },
{ id: '9' },
{ id: '10' },
]; ];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit); const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
@@ -58,14 +58,14 @@ describe('test trimEventlist function', () => {
it('when we use the fourth item', () => { it('when we use the fourth item', () => {
const selectedId = '4'; const selectedId = '4';
const expected = [ const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' }, { id: '4' },
{ id: '5' }, { id: '5' },
{ id: '6' }, { id: '6' },
{ id: '7' }, { id: '7' },
{ id: '8' }, { id: '8' },
{ id: '9' }, { id: '9' },
{ id: '10' },
{ id: '11' },
]; ];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit); const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
@@ -73,6 +73,15 @@ describe('test trimEventlist function', () => {
expect(l).toStrictEqual(expected); 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', () => { it('if selected is not found', () => {
const selectedId = '15'; const selectedId = '15';
const expected = [ const expected = [
@@ -90,114 +99,22 @@ describe('test trimEventlist function', () => {
expect(l.length).toBe(limit); expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
});
describe('test formatEvents function', () => { it('if there is nothing selected', () => {
const testEvent = [ const selectedId = null;
{
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';
const expected = [ const expected = [
{ { id: '1' },
id: '5946', { id: '2' },
time: '08:00 - 08:30', { id: '3' },
title: 'Welcome to Ontime', { id: '4' },
isNow: false, { id: '5' },
isNext: false, { id: '6' },
colour: '', { id: '7' },
}, { id: '8' },
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
]; ];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true }); const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(parsed).toStrictEqual(expected); expect(l.length).toBe(limit);
}); expect(l).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);
}); });
}); });
@@ -1,76 +1,21 @@
import { OntimeEvent } from 'ontime-types'; 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 * @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown * @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 * @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects * @returns {Object[]} Event list with maximum <limit> objects
*/ */
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => { export function trimRundown(rundown: OntimeEvent[], selectedId: string | null, limit: number): OntimeEvent[] {
if (rundown == null) return []; if (rundown.length < 1) return [];
const startIndex = selectedId
const BEFORE = 2; ? Math.max(
const trimmedRundown = [...rundown]; rundown.findIndex((event) => event.id === selectedId),
0,
// limit events length if necessary )
if (limit != null) { : 0;
while (trimmedRundown.length > limit) { const endIndex = Math.min(startIndex + limit, rundown.length);
const idx = trimmedRundown.findIndex((e) => e.id === selectedId); const trimmedRundown = rundown.slice(startIndex, endIndex);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown; 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-idle: #cfcfcf;
--studio-active-label: #101010; --studio-active-label: #101010;
--studio-idle-label: #595959; --studio-idle-label: #595959;
--studio-overtime: #101010;
} }
.timer { .timer {