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
@@ -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;
}