* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
@@ -1,35 +1,47 @@
import { ComponentType, useMemo } from 'react';
import { TimeManagerType } from 'common/models/TimeManager.type';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
import {
CustomFields,
Message,
OntimeEvent,
ProjectData,
Settings,
SupportedEvent,
TimerMessage,
ViewSettings,
} from 'ontime-types';
import { useStore } from 'zustand';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { runtimeStore } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
type WithDataProps = {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
events: OntimeEvent[];
external: Message;
general: ProjectData;
isMirrored: boolean;
lower: Message;
nextId: string | null;
onAir: boolean;
pres: TimerMessage;
publ: Message;
lower: Message;
external: Message;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: TimeManagerType;
events: OntimeEvent[];
backstageEvents: OntimeEvent[];
selectedId: string | null;
publicEventNow: OntimeEvent | null;
publicSelectedId: string | null;
nextId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
selectedId: string | null;
settings: Settings | undefined;
onAir: boolean;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
};
function getDisplayName(Component: React.ComponentType<any>): string {
@@ -42,10 +54,11 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData } = useRundown();
const { data: rundownData } = useFlatRundown();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const { data: settings } = useSettings();
const { data: customFields } = useCustomFields();
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
@@ -55,23 +68,11 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]);
// websocket data
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
externalMessage,
playback,
onAir,
eventNext,
publicEventNext,
publicEventNow,
eventNow,
loaded,
} = useStore(runtime);
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } =
useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null;
const nextId = eventNext?.id ?? null;
/******************************************/
/*** + TimeManagerType ***/
@@ -81,7 +82,10 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const TimeManagerType = {
...timer,
playback,
clock,
timerType: eventNow?.timerType ?? null,
timeWarning: eventNow?.timeWarning ?? null,
timeDanger: eventNow?.timeWarning ?? null,
};
// prevent render until we get all the data we need
@@ -90,28 +94,32 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}
return (
<Component
{...props}
isMirrored={isMirrored}
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
external={externalMessage}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
publicEventNext={publicEventNext}
time={TimeManagerType}
events={publicEvents}
backstageEvents={rundownData}
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
settings={settings}
nextId={nextId}
general={project}
onAir={onAir}
/>
<>
<ViewNavigationMenu />
<Component
{...props}
backstageEvents={rundownData}
customFields={customFields}
eventNext={eventNext}
eventNow={eventNow}
events={publicEvents}
external={message.external}
general={project}
isMirrored={isMirrored}
lower={message.lower}
nextId={nextId}
onAir={onAir}
pres={message.timer}
publ={message.public}
publicEventNext={publicEventNext}
publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId}
selectedId={selectedId}
settings={settings}
time={TimeManagerType}
viewSettings={viewSettings}
/>
</>
);
};
@@ -1,5 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '../../../theme/v2Styles' as *;
.backstage {
margin: 0;
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { overrideStylesURL } from '../../../common/api/constants';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
@@ -14,25 +14,24 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import './Backstage.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
interface BackstageProps {
customFields: CustomFields;
isMirrored: boolean;
publ: Message;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
time: ViewExtendedTimer;
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: ProjectData;
@@ -41,16 +40,26 @@ interface BackstageProps {
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings, settings } =
props;
const {
customFields,
isMirrored,
publ,
eventNow,
eventNext,
time,
backstageEvents,
selectedId,
general,
viewSettings,
settings,
} = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams();
// Set window title
useEffect(() => {
document.title = 'ontime - Backstage Screen';
}, []);
useWindowTitle('Backstage');
// blink on change
useEffect(() => {
@@ -68,34 +77,29 @@ export default function Backstage(props: BackstageProps) {
return null;
}
const clock = formatTime(time.clock, formatOptions);
const startedAt = formatTime(time.startedAt, formatOptions);
const clock = formatTime(time.clock);
const startedAt = formatTime(time.startedAt);
const isNegative = (time.current ?? 0) < 0;
const expectedFinish = isNegative
? getLocalizedString('countdown.overtime')
: formatTime(time.expectedFinish, formatOptions);
const expectedFinish = isNegative ? getLocalizedString('countdown.overtime') : formatTime(time.expectedFinish);
const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const showPublicMessage = publ.text && publ.visible;
const showProgress = time.playback !== 'stop';
let stageTimer;
if (time.current === null) {
stageTimer = '- - : - -';
} else {
stageTimer = formatDisplay(Math.abs(time.current), true);
if (isNegative) {
stageTimer = `-${stageTimer}`;
}
}
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const backstageOptions = getBackstageOptions(settings?.timeFormat ?? '24');
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={backstageOptions} />
<div className='project-header'>
{general.title}
@@ -123,12 +127,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
/>
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
@@ -163,12 +162,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
</motion.div>
)}
</AnimatePresence>
@@ -1,39 +1,31 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime } from '../../../common/utils/time';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Clock.scss';
interface ClockProps {
isMirrored: boolean;
time: TimeManagerType;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
export default function Clock(props: ClockProps) {
const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Clock';
}, []);
useWindowTitle('Clock');
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -122,10 +114,11 @@ export default function Clock(props: ClockProps) {
}
}
const clock = formatTime(time.clock, formatOptions);
const clock = formatTime(time.clock);
const clean = clock.replace('/:/g', '');
const clockOptions = getClockOptions(settings?.timeFormat ?? '24');
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const clockOptions = getClockOptions(defaultFormat);
return (
<div
@@ -137,7 +130,6 @@ export default function Clock(props: ClockProps) {
}}
data-testid='clock-view'
>
<NavigationMenu />
<ViewParamsEditor paramFields={clockOptions} />
<SuperscriptTime
time={clock}
@@ -0,0 +1,25 @@
/**
* encapsulate logic related to showing a clock timer
*/
import { MaybeNumber } from 'ontime-types';
import { formatTime } from '../../../../common/utils/time';
import { FORMAT_12, FORMAT_24 } from '../../../../viewerConfig';
import SuperscriptTime from '../superscript-time/SuperscriptTime';
interface ClockTimeProps {
value: MaybeNumber;
preferredFormat12?: string;
preferredFormat24?: string;
className?: string;
}
export default function ClockTime(props: ClockTimeProps) {
const { value, preferredFormat12 = FORMAT_12, preferredFormat24 = FORMAT_24, className } = props;
// TODO: should we get the params from URL here to see if the user is overriding the default?
const formattedTime = formatTime(value, { format12: preferredFormat12, format24: preferredFormat24 });
return <SuperscriptTime className={className} time={formattedTime} />;
}
@@ -0,0 +1,28 @@
/**
* encapsulate logic related to showing a running timer
*/
import { MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
interface RunningTimeProps {
value: MaybeNumber;
hideSeconds?: boolean;
hideLeadingZero?: boolean;
className?: string;
}
export default function RunningTime(props: RunningTimeProps) {
const { value, hideSeconds, hideLeadingZero, className } = props;
let formattedTime = millisToString(value);
if (hideLeadingZero) {
formattedTime = removeLeadingZero(formattedTime);
}
if (hideSeconds) {
formattedTime = removeSeconds(formattedTime);
}
return <div className={className}>{formattedTime}</div>;
}
@@ -8,6 +8,9 @@ interface SuperscriptTimeProps {
style?: CSSProperties;
}
/**
* @description receives a string like 12:00 AM and adds the period part to the superscript
*/
export default function SuperscriptTime(props: SuperscriptTimeProps) {
const { time, className, style } = props;
@@ -0,0 +1,101 @@
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
if (!timerObject) {
return null;
}
switch (timerObject.timerType) {
case TimerType.CountDown:
case TimerType.TimeToEnd:
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
case TimerType.CountUp:
return Math.abs(timerObject.elapsed ?? 0);
case TimerType.Clock:
return timerObject.clock;
default: {
const exhaustiveCheck: never = timerObject.timerType;
return exhaustiveCheck;
}
}
}
export function isStringBoolean(text: string | null) {
if (text === null) {
return false;
}
return text?.toLowerCase() === 'true' || text === '1';
}
/**
* Retrieves a dynamic property from an event
* Considers custom fields
*/
export function getPropertyValue(event: OntimeEvent | null, property: MaybeString): string | undefined {
if (!event) {
return undefined;
}
if (typeof property !== 'string') {
return undefined;
}
if (property.startsWith('custom-')) {
const field = property.split('custom-')[1];
return event.custom?.[field]?.value;
}
return event[property as keyof OntimeEvent] as string;
}
export function capitaliseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
type FormattingOptions = {
removeSeconds: boolean;
removeLeadingZero: boolean;
};
export function getFormattedTimer(
timer: MaybeNumber,
timerType: TimerType,
localisedMinutes: string,
options: FormattingOptions,
): string {
if (timer == null) {
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
}
let timeToParse = timer;
if (options.removeSeconds) {
const timerIsTimeOfDay = timerType === TimerType.Clock;
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
if (isNegative) {
// in negative numbers, we need to round down
timeToParse -= MILLIS_PER_MINUTE;
}
}
let display = millisToString(timeToParse);
display = removeLeadingZero(display);
if (options.removeSeconds) {
display = formatDisplayWithMinutes(display, localisedMinutes);
}
return display;
}
function formatDisplayWithMinutes(display: string, localisedMinutes: string): string {
display = removeSeconds(display);
return display.length < 3 ? `${display} ${localisedMinutes}` : display;
}
@@ -1,43 +0,0 @@
import { TimerType } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
type TimerTypeParams = Pick<TimeManagerType, 'timerType' | 'current' | 'elapsed' | 'clock'>;
export function getTimerByType(timerObject?: TimerTypeParams): string | number | null {
let timer = null;
if (!timerObject) {
return timer;
}
if (timerObject.timerType === TimerType.CountDown || timerObject.timerType === TimerType.TimeToEnd) {
timer = timerObject.current;
} else if (timerObject.timerType === TimerType.CountUp) {
timer = timerObject.elapsed;
} else if (timerObject.timerType === TimerType.Clock) {
timer = formatTime(timerObject.clock, formatOptions);
}
return timer;
}
export function formatTimerDisplay(timer?: string | number | null): string {
let display = '';
if (typeof timer === 'string') {
display = timer;
} else if (timer === null || typeof timer === 'undefined' || isNaN(timer)) {
display = '-- : -- : --';
} else {
display = formatDisplay(timer, true);
}
return display;
}
@@ -1,15 +1,15 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -18,20 +18,10 @@ import CountdownSelect from './CountdownSelect';
import './Countdown.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
const formatOptionsFinished = {
showSeconds: false,
format: 'hh:mm a',
};
interface CountdownProps {
isMirrored: boolean;
backstageEvents: OntimeEvent[];
time: TimeManagerType;
time: ViewExtendedTimer;
selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined;
@@ -48,9 +38,7 @@ export default function Countdown(props: CountdownProps) {
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0);
useEffect(() => {
document.title = 'ontime - Countdown';
}, []);
useWindowTitle('Countdown');
// eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options
@@ -99,23 +87,28 @@ export default function Countdown(props: CountdownProps) {
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock, formatOptions);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay, formatOptions);
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay, formatOptions);
const formattedTimer =
runningMessage === TimerMessage.ended
? formatTime(runningTimer, formatOptionsFinished)
: formatDisplay(
isSelected ? runningTimer : runningTimer + delay,
isSelected || runningMessage === TimerMessage.waiting,
);
const clock = formatTime(time.clock);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
const timeOption = getTimeOption(settings?.timeFormat ?? '24');
const formatTimer = (): string => {
if (runningMessage === TimerMessage.ended) {
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' });
}
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay);
if (isSelected || runningMessage === TimerMessage.waiting) {
formattedTime = removeLeadingZero(formattedTime);
}
return formattedTime;
};
const formattedTimer = formatTimer();
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timeOption = getCountdownOptions(defaultFormat);
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[timeOption]} />
<ViewParamsEditor paramFields={timeOption} />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
@@ -133,7 +126,7 @@ export default function Countdown(props: CountdownProps) {
time={formattedTimer}
className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}
/>
<div className='title'>{follow?.title || 'Untitled Event'}</div>
{follow?.title && <div className='title'>{follow.title}</div>}
<div className='timer-group'>
<div className='aux-timers'>
@@ -9,14 +9,12 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
const formatOptions = {
format: 'hh:mm a',
};
interface CountdownSelectProps {
events: OntimeRundownEntry[];
}
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const { getLocalizedString } = useTranslation();
@@ -26,7 +24,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
) as OntimeEvent[];
return (
<div className='event-select' data-testid='countdown-select'>
<div className='event-select' data-testid='countdown__select'>
<span className='event-select__title'>{getLocalizedString('countdown.select_event')}</span>
<ul className='event-select__events'>
{!events.length ? (
@@ -35,8 +33,8 @@ export default function CountdownSelect(props: CountdownSelectProps) {
filteredEvents.map((event: OntimeEvent, counter: number) => {
const index = counter + 1;
const title = sanitiseTitle(event.title);
const start = formatTime(event.timeStart, formatOptions);
const end = formatTime(event.timeEnd, formatOptions);
const start = formatTime(event.timeStart, scheduleFormat);
const end = formatTime(event.timeEnd, scheduleFormat);
return (
<li key={event.id}>
@@ -1,6 +1,6 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
export enum TimerMessage {
toStart = 'to_start',
@@ -19,7 +19,7 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
* Returns a parsed timer and relevant status message
*/
export const fetchTimerData = (
time: TimeManagerType,
time: ViewExtendedTimer,
follow: OntimeEvent,
selectedId: string | null,
): { message: TimerMessage; timer: number } => {
@@ -1,67 +0,0 @@
.lower-third.lines {
margin: 0;
box-sizing: border-box;
overflow: hidden;
width: 100%;
height: 100%;
color: #fffffa;
font-size: 4vh;
background-color: white;
.lower-container {
position: absolute;
top: 75vh;
background-color: #0003;
padding: 1vh 1vw 1vh 0;
display: flex;
flex-direction: column;
width: 45vw;
border-radius: 0 8px 8px 0;
}
.message-container {
position: absolute;
bottom: 2vh;
width: 100%;
text-align: center;
background-color: #0003;
.message {
font-size: 3.5vh;
}
}
.title-container,
.subtitle-container {
display: grid;
grid-template-columns: auto max-content;
grid-template-areas: 'decor text';
align-items: center;
position: relative;
margin-bottom: 1vh;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-decor,
.sub-decor {
grid-area: decor;
height: 4vh;
width: 100%;
background-color: #ff6969;
background: linear-gradient(
90deg,
rgb(255 105 105 / 100%) 0%,
rgb(255 132 132 / 100%) 100%
);
}
.title,
.subtitle {
grid-area: text;
padding-left: 1vw;
justify-self: right;
width: fit-content;
}
}
@@ -1,180 +0,0 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Message } from 'ontime-types';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { LowerOptions } from './LowerWrapper';
import './LowerLines.scss';
interface LowerLinesProps {
lower: Message;
heading: string;
subheading: string;
options: LowerOptions;
doShow: boolean;
}
export default function LowerLines(props: LowerLinesProps) {
const { lower, heading, subheading, options, doShow } = props;
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
useEffect(() => {
if (!options.fadeOut) return;
// Calculate time
const fadeOutTime = (options.fadeOut + options.transitionIn) * 1000;
const timeout = setTimeout(() => {
setShowLower(false);
}, fadeOutTime);
return () => clearTimeout(timeout);
}, [options.fadeOut, options.transitionIn]);
useEffect(() => {
setShowLower(doShow);
}, [doShow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
// motion
// transition segments
const t = options.transitionIn;
const eight = t / 8;
const quarter = t / 4;
const third = t / 3;
const half = t / 2;
const lowerThirdVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: third,
},
},
exit: {
opacity: 0,
x: -1000,
transition: {
duration: third,
},
},
};
const titleContainerVariants = {
hidden: {
left: -1000,
},
visible: {
left: 0,
transition: {
duration: third,
},
},
};
const titleVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
delay: quarter,
duration: half,
},
},
};
const subtitleContainerVariants = {
hidden: {
left: -1000,
},
visible: {
left: 0,
transition: {
delay: eight,
duration: third,
},
},
};
const subtitleVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
delay: third,
duration: third * 2,
},
},
};
const sizeMultiplier = (options.size || 1) * 4;
return (
<div
className='lower-third lines'
style={{
backgroundColor: options.keyColour,
color: options.textColour,
fontSize: `${sizeMultiplier}vh`,
}}
>
<NavigationMenu />
<ViewParamsEditor paramFields={LOWER_THIRDS_OPTIONS} />
<AnimatePresence>
{showLower && (
<motion.div
className='lower-container'
style={{ backgroundColor: options.bgColour }}
variants={lowerThirdVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<motion.div className='title-container' variants={titleContainerVariants}>
<motion.div className='title' variants={titleVariants}>
{heading}
</motion.div>
<div className='title-decor' />
</motion.div>
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
<div className='sub-decor' />
<motion.div className='subtitle' variants={subtitleVariants}>
{subheading}
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showLowerMessage && (
<motion.div
className='message-container'
key='modal'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div className='message'>{lower.text}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,152 @@
.lower-third {
margin: 0;
width: 100%;
height: 100%;
.container {
position: absolute;
display: block;
top: 75vh;
line-height: normal;
animation-fill-mode: both;
animation-timing-function: ease-out;
&--pre {
animation-name: in;
animation-play-state: paused;
.data-top {
animation-play-state: paused;
animation-name: top-in;
}
.data-lower {
animation-play-state: paused;
animation-name: bottom-in;
}
}
&--in {
animation-name: in;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-in;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-in;
}
}
&--out {
animation-name: out;
animation-play-state: running;
.data-top {
animation-play-state: running;
animation-name: top-out;
}
.data-bottom {
animation-play-state: running;
animation-name: bottom-out;
}
}
.line {
width: 100%;
height: var(--bottomThird-line-height-override, 0.5vh);
}
.clip {
overflow: hidden;
}
.data-bottom,
.data-top {
padding: 0 3vw;
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
text-align: var(--lowerThird-text-align-override, left);
white-space: nowrap;
}
.data-top {
font-weight: var(--lowerThird-top-font-weight-override, 600);
font-style: var(--lowerThird-top-font-style-override, normal);
}
.data-bottom {
font-weight: var(--lowerThird-bottom-font-weight-override, normal);
font-style: var(--lowerThird-bottom-font-style-override, normal);
}
}
}
@keyframes in {
0% {
transform: translateX(-100%);
opacity: 0%;
}
50%,
100% {
transform: translateX(0%);
opacity: 100%;
}
}
@keyframes out {
0%,
30% {
transform: translateX(0%);
opacity: 100%;
}
100% {
transform: translateX(-100%);
opacity: 0%;
}
}
@keyframes top-in {
0%,
50% {
transform: translateY(100%);
}
100% {
transform: translateY(0%);
}
}
@keyframes top-out {
0% {
transform: translateY(0%);
}
50%,
100% {
transform: translateY(100%);
}
}
@keyframes bottom-in {
0%,
50% {
transform: translateY(-100%);
}
100% {
transform: translateY(0%);
}
}
@keyframes bottom-out {
0% {
transform: translateY(0%);
}
50%,
100% {
transform: translateY(-100%);
}
}
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { getPropertyValue } from '../common/viewUtils';
import './LowerThird.scss';
enum TriggerType {
Event = 'event',
Manual = 'manual',
}
type LowerOptions = {
width: number;
trigger: TriggerType;
topSrc: string;
bottomSrc: string;
topColour: string;
bottomColour: string;
topBg: string;
bottomBg: string;
topSize: string;
bottomSize: string;
transition: number;
delay: number;
key: string;
lineColour: string;
lineHeight: string;
};
interface LowerProps {
customFields: CustomFields;
eventNow: OntimeEvent | null;
viewSettings: ViewSettings;
lower: Message;
}
const defaultOptions: Readonly<LowerOptions> = {
width: 45,
trigger: TriggerType.Event,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000ff',
bottomColour: '000000ff',
topBg: '00000000',
bottomBg: '00000000',
topSize: '65px',
bottomSize: '40px',
transition: 3,
delay: 3,
key: 'ffffffff',
lineColour: 'ff0000ff',
lineHeight: '0.4em',
};
export default function LowerThird(props: LowerProps) {
const { customFields, eventNow, lower, viewSettings } = props;
const [searchParams] = useSearchParams();
const options = useMemo(() => {
const newOptions = { ...defaultOptions };
const width = searchParams.get('width');
if (width !== null) {
newOptions.width = Number(width);
}
const trigger = Object.values(TriggerType).find((s) => s === searchParams.get('trigger'));
if (trigger) {
newOptions.trigger = trigger;
}
const topSrc = searchParams.get('top-src');
if (topSrc) {
newOptions.topSrc = topSrc;
}
const bottomSrc = searchParams.get('bottom-src');
if (bottomSrc) {
newOptions.bottomSrc = bottomSrc;
}
const topColour = searchParams.get('top-colour');
if (topColour !== null) {
newOptions.topColour = topColour;
}
const bottomColour = searchParams.get('bottom-colour');
if (bottomColour !== null) {
newOptions.bottomColour = bottomColour;
}
const topBg = searchParams.get('top-bg');
if (topBg !== null) {
newOptions.topBg = topBg;
}
const bottomBg = searchParams.get('bottom-bg');
if (bottomBg !== null) {
newOptions.bottomBg = bottomBg;
}
const topSize = searchParams.get('top-size');
if (topSize !== null) {
newOptions.topSize = topSize;
}
const bottomSize = searchParams.get('bottom-size');
if (bottomSize && bottomSize != newOptions.bottomSize) {
newOptions.bottomSize = bottomSize;
}
const transition = searchParams.get('transition');
if (transition !== null) {
newOptions.transition = Number(transition);
}
const delay = searchParams.get('delay');
if (delay !== null) {
newOptions.delay = Number(delay);
}
const key = searchParams.get('key');
if (key !== null) {
newOptions.key = key;
}
const lineColour = searchParams.get('line-colour');
if (lineColour !== null) {
newOptions.lineColour = lineColour;
}
return newOptions;
}, [searchParams]);
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
useWindowTitle('Lower Third');
const trigger = useMemo(() => {
if (options.trigger === TriggerType.Event) {
return eventNow?.id;
} else if (options.trigger === TriggerType.Manual) {
return lower.visible;
}
return false;
}, [eventNow?.id, lower.visible, options.trigger]);
// coordinate load-unload of lower third
useEffect(() => {
if (options.trigger === TriggerType.Event && trigger) {
setPlayState('in');
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
const timeout = setTimeout(() => {
setPlayState('out');
}, animateOutInMs);
return () => clearTimeout(timeout);
} else if (options.trigger === TriggerType.Manual) {
setPlayState(trigger ? 'in' : 'out');
} else {
setPlayState('pre');
}
return () => null;
}, [options.delay, options.transition, options.trigger, trigger]);
const topText = useMemo(() => {
if (options.topSrc === 'lowerMsg') {
return lower.text;
}
return getPropertyValue(eventNow, options.topSrc) ?? '';
}, [eventNow, lower.text, options]);
const bottomText = useMemo(() => {
if (options.bottomSrc === 'lowerMsg') {
return lower.text;
}
return getPropertyValue(eventNow, options.bottomSrc) ?? '';
}, [eventNow, lower.text, options]);
const transition = `${options.transition}s`;
return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} />
<div
className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
>
<div className='clip'>
<div
className='data-top'
style={{
animationDuration: transition,
color: `#${options.topColour}`,
backgroundColor: `#${options.topBg}`,
fontSize: options.topSize,
}}
>
{topText}
</div>
</div>
<div
className='line'
style={{
backgroundColor: `#${options.lineColour}`,
}}
/>
<div className='clip'>
<div
className='data-bottom'
style={{
animationDuration: transition,
color: `#${options.bottomColour}`,
backgroundColor: `#${options.bottomBg}`,
fontSize: options.bottomSize,
}}
>
{bottomText}
</div>
</div>
</div>
</div>
);
}
@@ -1,137 +0,0 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import LowerLines from './LowerLines';
export type LowerOptions = {
size: number;
transitionIn: number;
textColour: string;
bgColour: string;
keyColour?: string;
fadeOut: number;
};
interface LowerProps {
eventNow: OntimeEvent | null;
lower: Message;
viewSettings: ViewSettings;
}
// prevent triggering animation without a content change
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props: LowerProps) => {
const { eventNow, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [heading, setHeading] = useState('');
const [subheading, setSubheading] = useState('');
const [showLower, setShowLower] = useState(false);
// Set window title
useEffect(() => {
document.title = 'ontime - Lower Thirds';
}, []);
// reload if data changes
useEffect(() => {
// clear titles if necessary
// will trigger an animation out in the component
let timeout: NodeJS.Timeout;
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
// we have new titles
if (haveTitlesChanged && !areTitlesEmpty) {
// show lower
setHeading(eventNow?.title ?? '');
setSubheading(eventNow?.presenter ?? '');
setShowLower(true);
// schedule transition out
const transitionTime = 5000;
timeout = setTimeout(() => {
setShowLower(false);
}, transitionTime);
}
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line -- we do this to keep animations
}, [eventNow?.title, eventNow?.presenter]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const options: LowerOptions = {
size: 1,
transitionIn: 3,
textColour: '#fffffa',
bgColour: '#00000033',
fadeOut: 3,
};
// size: multiplier
// Should be a number 0.0-n
const _size = searchParams.get('size');
if (_size) {
const parsedValue = Number(_size);
if (!Number.isNaN(parsedValue)) {
options.size = parsedValue;
}
}
// transitionIn: seconds
// Should be a number 0-n
const _transitionIn = searchParams.get('transition');
if (_transitionIn) {
const parsedValue = Number(_transitionIn);
if (!Number.isNaN(parsedValue)) {
options.transitionIn = parsedValue;
}
}
// textColour: string
// Should be a hex string '#ffffff'
const _textColour = searchParams.get('text');
if (_textColour) options.textColour = `#${_textColour}`;
// bgColour: string
// Should be a hex string '#ffffff'
const _bgColour = searchParams.get('bg');
if (_bgColour) options.bgColour = `#${_bgColour}`;
// key: string
// Should be a hex string '#00FF00' with key colour
const _key = searchParams.get('key');
if (_key) options.keyColour = `#${_key}`;
// fadeOut: seconds
// Should be a number 0-n
const _fadeOut = searchParams.get('fadeout');
if (_fadeOut) {
const parsedValue = Number(_transitionIn);
if (!Number.isNaN(parsedValue)) {
options.fadeOut = parsedValue;
}
}
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
};
export default memo(Lower, areEqual);
@@ -1,43 +1,40 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
import './MinimalTimer.scss';
interface MinimalTimerProps {
isMirrored: boolean;
pres: TimerMessage;
time: TimeManagerType;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function MinimalTimer(props: MinimalTimerProps) {
const { isMirrored, pres, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Minimal Timer';
}, []);
useWindowTitle('Minimal Timer');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// get config from url: key, text, font, size, hideovertime
// eg. http://localhost:3000/minimal?key=f00&text=fff
// Check for user options
// TODO: this should be tied to the params
// USER OPTIONS
const userOptions: OverridableOptions = {
size: 1,
};
@@ -126,30 +123,34 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const hideEndMessage = searchParams.get('hideendmessage');
userOptions.hideEndMessage = isStringBoolean(hideEndMessage);
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
const isNegative = (time.current ?? 0) < 0 && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
const showProgress = time.playback !== Playback.Stop;
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
const showBlinking = pres.timerBlink;
const showBlackout = pres.timerBlackout;
const showWarning = (time.current ?? 1) < (time.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (time.timeDanger ?? 0);
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
const stageTimer = getTimerByType(time);
let display = formatTimerDisplay(stageTimer);
if (isNegative) {
display = `-${display}`;
}
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: true,
});
const stageTimerCharacters = display.replace('/:/g', '').length;
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
@@ -169,7 +170,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
}}
data-testid='minimal-timer'
>
<NavigationMenu />
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
{!hideMessagesOverlay && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { overrideStylesURL } from '../../../common/api/constants';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
@@ -12,25 +11,23 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import './Public.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
interface BackstageProps {
customFields: CustomFields;
isMirrored: boolean;
publ: Message;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: TimeManagerType;
time: ViewExtendedTimer;
events: OntimeEvent[];
publicSelectedId: string | null;
general: ProjectData;
@@ -40,6 +37,7 @@ interface BackstageProps {
export default function Public(props: BackstageProps) {
const {
customFields,
isMirrored,
publ,
publicEventNow,
@@ -51,12 +49,12 @@ export default function Public(props: BackstageProps) {
viewSettings,
settings,
} = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Public Screen';
}, []);
useWindowTitle('Public Schedule');
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -64,14 +62,18 @@ export default function Public(props: BackstageProps) {
}
const showPublicMessage = publ.text && publ.visible;
const clock = formatTime(time.clock, formatOptions);
const clock = formatTime(time.clock);
const qrSize = Math.max(window.innerWidth / 15, 128);
const publicOptions = getPublicOptions(settings?.timeFormat ?? '24');
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const publicOptions = getPublicOptions(defaultFormat, customFields);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNext = getPropertyValue(publicEventNext, secondarySource);
const secondaryTextNow = getPropertyValue(publicEventNow, secondarySource);
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={publicOptions} />
<div className='project-header'>
{general.title}
@@ -92,12 +94,7 @@ export default function Public(props: BackstageProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={publicEventNow.title}
subtitle={publicEventNow.subtitle}
presenter={publicEventNow.presenter}
/>
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} />
</motion.div>
)}
</AnimatePresence>
@@ -112,12 +109,7 @@ export default function Public(props: BackstageProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={publicEventNext.title}
subtitle={publicEventNext.subtitle}
presenter={publicEventNext.presenter}
/>
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} />
</motion.div>
)}
</AnimatePresence>
@@ -1,183 +1,208 @@
@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: #300000;
$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;
grid-area: clock;
display: grid;
place-content: center;
justify-items: center;
padding-top: 3em;
gap: 0.5em;
width: 100%;
height: 100%;
position: relative;
margin: 4vh auto;
font-family: digital-clock, monospace;
text-transform: uppercase;
font-family: seven-seg;
}
.clock-indicators {
.clock-indicators {
position: absolute;
top: 0;
width: 100%;
height: 100%;
.min,
.hours {
border-radius: 50%;
position: absolute;
top: 0;
width: 100%;
height: 100%;
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});
&--active {
background: var(--studio-active, $red-active);
}
}
.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));
}
.min {
min-height: $size-min;
width: $size-min;
top: calc(50% - #{$half_min});
left: calc(50% - #{$half_min});
}
.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: var(--studio-active, $red-active);
filter: brightness(0.9);
}
}
.next-countdown::before {
content: '-';
.hours {
min-height: $size-hours;
width: $size-hours;
top: calc(50% - #{$half_hours});
left: calc(50% - #{$half_hours});
}
}
/* ============= SCHEDULE STUFF =================*/
.studio-timer {
font-size: calc(var(--clock-size) / 5);
line-height: 1em;
.schedule-container {
grid-area: schd;
margin: 4vh 0;
font-family: digital-clock, monospace;
text-transform: uppercase;
color: var(--studio-active, $red-active);
.onAir {
padding-bottom: 2vh;
font-size: 15vh;
line-height: 0.9em;
color: var(--studio-active, $red-active);
&--idle {
color: var(--studio-idle, $red-idle);
}
&--with-seconds {
font-size: calc(var(--clock-size) / 6.5);
}
.schedule {
ul {
color: var(--studio-idle-label, $cyan-idle);
font-size: 3.75vh;
line-height: 1em;
list-style: none;
}
&::before {
content: '88:88';
mix-blend-mode: hard-light;
opacity: 0.1;
position: absolute;
}
}
li {
margin-bottom: 1.5vh;
padding-left: 0.25em;
display: flex;
align-items: center;
}
.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);
}
.now {
color: var(--studio-active-label, $cyan-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;
}
.next {
color: var(--studio-active, $red-active);
}
.next-countdown {
color: var(--studio-active, $red-active);
font-size: calc(var(--clock-size) / 10);
line-height: 1em;
.user-colour {
width: 0.35em;
height: 0.35em;
aspect-ratio: 1;
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) {
.studio-clock {
display: grid;
grid-template-areas: "clck";
grid-template-areas: 'clock';
grid-template-columns: 100%;
place-content: center;
.schedule-container {
display: none;
@@ -1,32 +1,27 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { overrideStylesURL } from '../../../common/api/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
import { secondsInMillis, trimRundown } from './studioClock.utils';
import './StudioClock.scss';
const formatOptions = {
showSeconds: false,
format: 'hh:mm',
};
interface StudioClockProps {
isMirrored: boolean;
eventNext: OntimeEvent | null;
time: TimeManagerType;
time: ViewExtendedTimer;
backstageEvents: OntimeRundown;
selectedId: string | null;
nextId: string | null;
@@ -38,61 +33,65 @@ interface StudioClockProps {
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText)
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');
formatOptions.showSeconds = Boolean(showSeconds);
formatOptions.format = `hh:mm${formatOptions.showSeconds ? ':ss' : ''}`;
useEffect(() => {
document.title = 'ontime - Studio Clock';
}, []);
useWindowTitle('Studio Clock');
// Prepare event list
useEffect(() => {
if (!backstageEvents) {
return;
}
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 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 defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioClockOptions = getStudioClockOptions(defaultFormat);
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
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' : ''}`} data-testid='studio-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={studioClockOptions} />
<div className='clock-container'>
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? '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' : ''}
`}
>
{timer}
</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), { 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>
);
})}
</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 { secondsInMillis, 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,33 @@ 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);
});
});
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
@@ -1,76 +1,34 @@
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;
};
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/**
* @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}]
* @description Returns amount of seconds in a date given in milliseconds. For studio clock second indicator
* @param {MaybeNumber} millis time to format
* @returns amount of elapsed seconds
*/
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,
};
});
};
export function secondsInMillis(millis: MaybeNumber): number {
if (!millis) {
return 0;
}
return Math.floor((millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
}
@@ -30,6 +30,21 @@
transition: $viewer-transition-time;
}
.blackout {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000;
z-index: 2;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
opacity: 1;
}
}
/* =================== CLOCK ===================*/
.clock-container {
@@ -1,29 +1,31 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, Playback, Settings, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import {
CustomFields,
Message,
OntimeEvent,
Playback,
Settings,
TimerMessage,
TimerType,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
import './Timer.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
// motion
const titleVariants = {
hidden: {
@@ -41,25 +43,25 @@ const titleVariants = {
};
interface TimerProps {
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
external: Message;
isMirrored: boolean;
pres: TimerMessage;
external: Message;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Timer';
}, []);
useWindowTitle('Timer');
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -72,6 +74,8 @@ export default function Timer(props: TimerProps) {
hideCards: false,
hideProgress: false,
hideMessage: false,
hideTimerSeconds: false,
hideClockSeconds: false,
};
const hideClock = searchParams.get('hideClock');
@@ -86,23 +90,32 @@ export default function Timer(props: TimerProps) {
const hideMessage = searchParams.get('hideMessage');
userOptions.hideMessage = isStringBoolean(hideMessage);
const clock = formatTime(time.clock, formatOptions);
const hideClockSeconds = searchParams.get('hideClockSeconds');
userOptions.hideClockSeconds = isStringBoolean(hideClockSeconds);
const clock = formatTime(time.clock);
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const isNegative = (time.current ?? 0) < 0 && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
const showProgress = time.playback !== Playback.Stop;
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
const showBlinking = pres.timerBlink;
const showBlackout = pres.timerBlackout;
const showWarning = (time.current ?? 1) < (eventNow?.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (eventNow?.timeDanger ?? 0);
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock;
const showExternal = external.visible && external.text;
@@ -110,14 +123,15 @@ export default function Timer(props: TimerProps) {
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
const stageTimer = getTimerByType(time);
let display = formatTimerDisplay(stageTimer);
const stageTimerCharacters = display.replace('/:/g', '').length;
if (isNegative) {
display = `-${display}`;
}
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: true,
});
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there
if (showExternal) {
@@ -127,12 +141,13 @@ export default function Timer(props: TimerProps) {
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const timerOptions = getTimerOptions(settings?.timeFormat ?? '24');
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields);
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
@@ -174,9 +189,9 @@ export default function Timer(props: TimerProps) {
now={timerIsTimeOfDay ? null : time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
@@ -185,7 +200,7 @@ export default function Timer(props: TimerProps) {
{!userOptions.hideCards && (
<>
<AnimatePresence>
{eventNow && !finished && (
{eventNow?.title && (
<motion.div
className='event now'
key='now'
@@ -194,18 +209,13 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
/>
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext && (
{eventNext?.title && (
<motion.div
className='event next'
key='next'
@@ -214,12 +224,7 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
</motion.div>
)}
</AnimatePresence>