Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente 7d9c2a9cfd refactor: create mock data utilities 2025-01-12 09:05:23 +01:00
70 changed files with 1108 additions and 1374 deletions
-1
View File
@@ -24,7 +24,6 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
-5
View File
@@ -2,7 +2,6 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -13,11 +12,7 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.10.7",
"version": "3.10.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1
View File
@@ -15,6 +15,7 @@
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.10.7",
"version": "3.10.3",
"private": true,
"type": "module",
"dependencies": {
@@ -8,7 +8,6 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
@@ -63,9 +62,13 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
@@ -76,7 +79,8 @@
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
+1 -1
View File
@@ -22,7 +22,7 @@ const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
-7
View File
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 1.5rem;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
.multiprogress-bar {
@@ -53,6 +53,3 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,7 +1,6 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import './MultiPartProgressBar.scss';
@@ -14,7 +13,6 @@ interface MultiPartProgressBar {
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
@@ -29,7 +27,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger,
dangerColor,
hidden,
hideOvertime,
ignoreCssOverride,
className = '',
} = props;
@@ -37,17 +34,12 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
const showOvertime = isOvertime && !hideOvertime;
return (
<div
className={cx([
'multiprogress-bar',
hidden && 'multiprogress-bar--hidden',
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
>
{now !== null && (
<>
@@ -58,8 +50,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
@@ -1,4 +1,4 @@
import { memo, PropsWithChildren } from 'react';
import { memo, PropsWithChildren, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
@@ -19,6 +19,7 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
@@ -47,8 +48,12 @@ function NavigationMenu(props: NavigationMenuProps) {
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal'>
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
@@ -1,12 +1,10 @@
@use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title-card__title,
@@ -18,13 +16,13 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card__title {
font-weight: 600;
font-size: $title-primary-size;
font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color);
line-height: 1.2em;
}
.title-card__secondary {
font-size: $title-secondary-size;
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -33,7 +31,7 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: $timer-label-size;
font-size: clamp(1rem, 1.5vw, 1.5rem);
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss';
interface TitleCardProps {
title?: string;
title: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,25 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
+1 -8
View File
@@ -28,13 +28,6 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
return { isValid: true, message: 'ok' };
};
/**
* Utility removes trailing slash from a string
*/
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
/**
* Gets the URL to send a preset to
* @param location
@@ -45,7 +38,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
}
-3
View File
@@ -12,9 +12,6 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
@@ -1,12 +1,5 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import {
buyMeACoffeeUrl,
discordUrl,
documentationUrl,
githubSponsorUrl,
githubUrl,
websiteUrl,
} from '../../../../externals';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -16,29 +9,22 @@ export default function AboutPanel() {
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
And trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
</Panel.Section>
</>
);
}
@@ -24,10 +24,6 @@
display: flex;
gap: 1rem;
justify-content: end;
:first-child {
margin-right: auto;
}
}
.tableContainer {
@@ -42,20 +38,14 @@
tbody {
background-color: $gray-1300;
tr {
&:hover:not(.current) {
&:has(:hover) {
background-color: $gray-1350;
}
.current {
&:hover {
background-color: $blue-900;
}
}
}
}
tr {
height: 2rem;
cursor: pointer;
}
th,
@@ -72,8 +62,4 @@
.current {
background-color: $blue-700;
&:hover {
background-color: $blue-900;
}
}
@@ -1,12 +1,10 @@
import { useNavigate } from 'react-router-dom';
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import * as Editor from '../editor-utils/EditorUtils';
import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList';
@@ -70,37 +68,30 @@ export default function Welcome(props: WelcomeProps) {
</div>
<div className={style.column}>
<div className={style.header}>Welcome to Ontime</div>
<Editor.Title>Select project</Editor.Title>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div>
</div>
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
<Checkbox
size='sm'
variant='ontime-ondark'
defaultChecked
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
>
Show this modal on next startup
</Checkbox>
</ModalBody>
</ModalContent>
</Modal>
@@ -1,3 +1,5 @@
import { Button } from '@chakra-ui/react';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss';
@@ -16,16 +18,26 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
{data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) {
return (
<tr className={style.current} key={project.filename} onClick={onClose}>
<tr className={style.current} key={project.filename}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr>
);
}
return (
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
<tr key={project.filename}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr>
);
})}
@@ -105,6 +105,5 @@
}
.progressOverride {
height: 1rem;
--progress-bar-br: 0;
}
@@ -2,7 +2,3 @@ sup.period {
top: -1em;
font-size: 0.4em;
}
.subscript {
font-size: 0.75em;
}
@@ -9,32 +9,17 @@ interface SuperscriptTimeProps {
}
/**
* When the timer includes seconds, we want to split it from the rest
*/
function getTimerParts(time: string) {
if (time.length !== 8) {
return [time, ''];
}
return [time.slice(0, 5), time.slice(5)];
}
/**
* Receives a time string and formats it with a subscript or superscript
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> the seconds become a subscript
* @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;
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
const [mainTime, subscript] = getTimerParts(timeString);
return (
<div className={className} style={style}>
{mainTime}
{subscript && <span className='subscript'>{subscript}</span>}
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
@@ -7,17 +7,11 @@ import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
export function getTimerByType(
freezeEnd: boolean,
timerObject?: TimerTypeParams,
timerTypeOverride?: TimerType,
): number | null {
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
if (!timerObject) {
return null;
}
const viewTimerType = timerTypeOverride ?? timerObject.timerType;
if (timerObject.countToEnd) {
if (timerObject.current === null) {
return null;
@@ -25,7 +19,7 @@ export function getTimerByType(
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (viewTimerType) {
switch (timerObject.timerType) {
case TimerType.CountDown:
if (timerObject.current === null) {
return null;
@@ -37,8 +31,10 @@ export function getTimerByType(
return timerObject.clock;
case TimerType.None:
return null;
default:
return null;
default: {
const exhaustiveCheck: never = timerObject.timerType;
return exhaustiveCheck;
}
}
}
@@ -1,4 +1,4 @@
@use '../../theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
.stage-timer {
margin: 0;
@@ -11,14 +11,21 @@
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto 1fr auto auto auto;
grid-template-areas:
'clock'
'timer'
'progress'
'now'
'next';
&--finished {
outline: clamp(4px, 1vw, 16px) solid var(--timer-overtime-color-override, $timer-finished-color);
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
@@ -41,18 +48,19 @@
/* =================== CLOCK ===================*/
.clock-container {
grid-area: clock;
margin-left: auto;
font-weight: 600;
transition: opacity $viewer-transition-time;
.label {
font-size: $timer-label-size;
font-size: clamp(16px, 1.5vw, 24px);
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.clock {
font-size: $timer-value-size;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -67,8 +75,8 @@
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
padding: 16px 24px;
border-radius: 8px;
&.now {
grid-area: now;
@@ -82,8 +90,7 @@
/* =================== MAIN ===================*/
.timer-container {
flex: 1;
align-content: center;
grid-area: timer;
justify-self: center;
align-self: center;
@@ -95,7 +102,7 @@
font-size: 11.5vw;
line-height: 0.9em;
font-weight: 600;
color: var(--timer-overtime-color-override, $timer-finished-color);
color: $timer-finished-color;
padding: 0;
}
@@ -116,16 +123,8 @@
transition: $viewer-transition-time;
}
// use a class instead of a phase, to allow suppressing overtime style
&--finished {
color: var(--timer-overtime-color-override, $timer-finished-color);
}
&[data-phase="warning"] {
color: var(--timer-warning-color-override, var(--phase-color));
}
&[data-phase="danger"] {
color: var(--timer-danger-color-override, var(--phase-color));
color: $timer-finished-color;
}
}
}
@@ -135,14 +134,14 @@
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.125em;
padding-block: 0.125em;
margin-top: 0.25em;
padding-block: 0.25em;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 1em;
line-height: 0.9em;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
@@ -154,6 +153,7 @@
}
.progress-container {
grid-area: progress;
width: 100%;
margin: 0 auto;
opacity: 1;
@@ -195,11 +195,10 @@
font-weight: 600;
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
top: min(2vh, 16px);
left: min(2vw, 16px);
top: 2vw;
left: 2vw;
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
@@ -0,0 +1,267 @@
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import {
CustomFields,
MessageState,
OntimeEvent,
Playback,
ProjectData,
Settings,
SimpleTimerState,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
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 { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { getTimerOptions } from './timer.options';
import './Timer.scss';
// motion
const titleVariants = {
hidden: {
x: -2500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -2500,
},
};
export const MotionTitleCard = motion(TitleCard);
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
props;
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useWindowTitle('Timer');
// USER OPTIONS
const userOptions = {
hideClock: false,
hideCards: false,
hideProgress: false,
hideMessage: false,
hideTimerSeconds: false,
hideClockSeconds: false,
removeLeadingZeros: true,
};
const hideClock = searchParams.get('hideClock');
userOptions.hideClock = isStringBoolean(hideClock);
const hideCards = searchParams.get('hideCards');
userOptions.hideCards = isStringBoolean(hideCards);
const hideProgress = searchParams.get('hideProgress');
userOptions.hideProgress = isStringBoolean(hideProgress);
const hideMessage = searchParams.get('hideMessage');
userOptions.hideMessage = isStringBoolean(hideMessage);
const hideClockSeconds = searchParams.get('hideClockSeconds');
userOptions.hideClockSeconds = isStringBoolean(hideClockSeconds);
const clock = formatTime(time.clock);
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const showLeadingZeros = searchParams.get('showLeadingZeros');
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const main = searchParams.get('main');
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = message.timer.text !== '' && message.timer.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const finished = time.phase === TimerPhase.Overtime;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.countToEnd;
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
const showProgress =
eventNow !== null &&
time.timerType !== TimerType.None &&
time.timerType !== TimerType.Clock &&
time.playback !== Playback.Stop;
const showFinished = shouldShowModifiers && finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showClock = time.timerType !== TimerType.Clock;
const secondaryContent = ((): string | undefined => {
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
})();
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
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 (secondaryContent) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields);
const disableProgress = timerIsTimeOfDay || time.timerType === TimerType.None;
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
{message.timer.text}
</FitText>
</div>
)}
{!userOptions.hideClock && (
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
)}
<div className={timerContainerClasses}>
{showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div>
) : (
<div
className={timerClasses}
style={{
fontSize: `${timerFontSize}vw`,
'--phase-color': timerColor,
}}
>
{display}
</div>
)}
<div
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
</div>
</div>
{!userOptions.hideProgress && (
<MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={disableProgress ? null : time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
)}
{!userOptions.hideCards && (
<>
<AnimatePresence>
{eventNow?.title && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={mainFieldNow}
secondary={secondaryTextNow}
/>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext?.title && (
<MotionTitleCard
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={mainFieldNext}
secondary={secondaryTextNext}
className='event next'
/>
)}
</AnimatePresence>
</>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
+3
View File
@@ -0,0 +1,3 @@
import matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
+1 -6
View File
@@ -21,11 +21,6 @@ $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$element-border-radius: 8px;
// Generic element sizes
$view-element-gap: min(2vh, 16px);
$view-outer-padding: min(2vh, 16px) clamp(16px, 2vw, 24px);
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
// Properties related to timer
$timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
@@ -35,4 +30,4 @@ $external-color: rgba(white, 85%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
$timer-value-size: clamp(32px, 3.5vw, 50px);
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
@@ -76,7 +76,7 @@ type CuesheetOptions = {
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with cuesheetOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
// we manually make an object that matches the key above
return {
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
-227
View File
@@ -1,227 +0,0 @@
import { AnimatePresence } from 'framer-motion';
import {
CustomFields,
MessageState,
OntimeEvent,
ProjectData,
Settings,
SimpleTimerState,
ViewSettings,
} from 'ontime-types';
import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
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 { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { MotionTitleCard, titleVariants } from './timer.animations';
import { getTimerOptions, useTimerOptions } from './timer.options';
import {
getCardData,
getEstimatedFontSize,
getIsPlaying,
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTimerColour,
getTotalTime,
} from './timer.utils';
import './Timer.scss';
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
props;
const {
hideClock,
hideCards,
hideProgress,
hideMessage,
hideExternal,
hideTimerSeconds,
removeLeadingZeros,
mainSource,
secondarySource,
timerType,
} = useTimerOptions();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
useWindowTitle('Timer');
// gather modifiers
const viewTimerType = timerType ?? time.timerType;
const showOverlay = getShowMessage(message.timer);
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
time.timerType,
time.countToEnd,
time.phase,
viewSettings,
);
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
mainSource,
secondarySource,
time.playback,
time.phase,
);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const clock = formatTime(time.clock);
const stageTimer = getTimerByType(viewSettings.freezeEnd, time, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
});
const secondaryContent = getSecondaryDisplay(
message,
auxTimer.current,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
hideExternal,
);
// gather presentation styles
const timerColour = getTimerColour(viewSettings, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields);
return (
<div
className={cx(['stage-timer', isMirrored && 'mirror', showFinished && 'stage-timer--finished'])}
data-testid='timer-view'
>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={32} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
)}
{showClock && (
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
)}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'>
{viewSettings.endMessage}
</FitText>
) : (
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{
fontSize: `${timerFontSize}vw`,
'--phase-color': timerColour,
}}
data-phase={time.phase}
>
{display}
</div>
)}
<div
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
</div>
</div>
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
)}
{!hideCards && (
<>
<AnimatePresence>
{showNow && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={nowMain}
secondary={nowSecondary}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showNext && (
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={nextMain}
secondary={nextSecondary}
/>
)}
</AnimatePresence>
</>
)}
</div>
);
}
@@ -1,20 +0,0 @@
import { motion } from 'framer-motion';
import TitleCard from '../../common/components/title-card/TitleCard';
export const titleVariants = {
hidden: {
x: -2500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -2500,
},
};
export const MotionTitleCard = motion(TitleCard);
@@ -1,141 +0,0 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, TimerType } from 'ontime-types';
import { validateTimerType } from 'ontime-utils';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions = {
'no-overrides': 'No Overrides',
'count-up': 'Count up',
'count-down': 'Count down',
clock: 'Clock',
};
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{
id: 'timerType',
title: 'Timer type',
description: 'Override the timer type',
type: 'option',
values: timerDisplayOptions,
defaultValue: 'no-overrides',
},
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Timer Message',
description: 'Prevents displaying fullscreen messages in the timer',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide Auxiliary timer / External message',
description: 'Prevents the screen from displaying the secondary timer field',
type: 'boolean',
defaultValue: false,
},
];
};
type TimerOptions = {
hideClock: boolean;
hideCards: boolean;
hideProgress: boolean;
hideMessage: boolean;
hideExternal: boolean;
hideTimerSeconds: boolean;
removeLeadingZeros: boolean;
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
timerType?: TimerType;
};
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions {
const timerType = validateTimerType(searchParams.get('timerType'), TimerType.None);
// we manually make an object that matches the key above
return {
hideClock: isStringBoolean(searchParams.get('hideClock')),
hideCards: isStringBoolean(searchParams.get('hideCards')),
hideProgress: isStringBoolean(searchParams.get('hideProgress')),
hideMessage: isStringBoolean(searchParams.get('hideMessage')),
hideExternal: isStringBoolean(searchParams.get('hideExternal')),
hideTimerSeconds: isStringBoolean(searchParams.get('hideTimerSeconds')),
removeLeadingZeros: !isStringBoolean(searchParams.get('showLeadingZeros')),
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
// none doesnt make sense as a configuration of the view
timerType: timerType === TimerType.None ? undefined : timerType,
};
}
/**
* Hook exposes the timer view options
*/
export function useTimerOptions(): TimerOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
-183
View File
@@ -1,183 +0,0 @@
import {
MaybeNumber,
MessageState,
OntimeEvent,
Playback,
TimerMessage,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
* Whether a message should be shown
*/
export function getShowMessage(message: TimerMessage): boolean {
return message.text !== '' && message.visible;
}
/**
* Whether the playback is playing
*/
export function getIsPlaying(playback: Playback): boolean {
return playback === Playback.Play || playback === Playback.Roll;
}
/**
* Gets the total time from the duration and added time of an event
*/
export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): number {
return (duration ?? 0) + (addedTime ?? 0);
}
/**
* Whether the progress bar should be shown for this timer type
*/
export function getShowProgressBar(timerType: TimerType) {
return timerType !== TimerType.None && timerType !== TimerType.Clock;
}
/**
* Whether the clock should be shown with this timer type
*/
export function getShowClock(timerType: TimerType) {
return timerType !== TimerType.Clock;
}
const fontSizeMap: { [key: number]: number } = {
4: 28, // 9:01
5: 28, // -9:01, 10:01, 9 min
6: 25, // -10:01, 10 min
8: 20, // 23:01:01
9: 20, // -23:01:01
};
/**
* Finds a font size that fits the timer in the screen
* Unfortunately hand tweaked
*/
export function getEstimatedFontSize(stageTimer: string, secondaryContent?: string) {
const stageTimerCharacters = stageTimer.length;
let timerFontSize = (100 / (stageTimerCharacters - 1)) * 1.25;
if (fontSizeMap[stageTimerCharacters]) {
timerFontSize = fontSizeMap[stageTimerCharacters];
}
let externalFontSize = timerFontSize * 0.2;
if (secondaryContent) {
// we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen
timerFontSize *= 0.6;
if (secondaryContent.length > 25) {
externalFontSize = (100 / (secondaryContent.length - 1)) * 1.8;
}
}
return {
timerFontSize,
externalFontSize,
};
}
/**
* which, if any, modifier should be shown at any time
*/
export function getShowModifiers(
timerType: TimerType,
countToEnd: boolean,
phase: TimerPhase,
viewSettings: ViewSettings,
) {
const showModifiers = timerType === TimerType.CountDown || countToEnd;
const finished = phase === TimerPhase.Overtime;
return {
showEndMessage: showModifiers && finished && viewSettings.endMessage,
showFinished: showModifiers && finished, // ????
showWarning: showModifiers && phase === TimerPhase.Warning,
showDanger: showModifiers && phase === TimerPhase.Danger,
};
}
/**
* Which colour should the timer have at a given moment
*/
export function getTimerColour(viewSettings: ViewSettings, showWarning: boolean, showDanger: boolean) {
if (showWarning) return viewSettings.warningColor;
if (showDanger) return viewSettings.dangerColor;
return viewSettings.normalColor;
}
/**
* What, if anything, should be displayed in the secondary field
*/
export function getSecondaryDisplay(
message: MessageState,
currentAux: MaybeNumber,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
hideExternal: boolean,
): string | undefined {
if (hideExternal) {
return;
}
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
removeSeconds,
removeLeadingZero,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
}
/**
* What should we be showing in the cards?
*/
export function getCardData(
eventNow: OntimeEvent | null,
eventNext: OntimeEvent | null,
mainSource: keyof OntimeEvent | null,
secondarySource: keyof OntimeEvent | null,
playback: Playback,
phase: TimerPhase,
) {
if (playback === Playback.Stop) {
return {
showNow: false,
nowMain: undefined,
nowSecondary: undefined,
showNext: false,
nextMain: undefined,
nextSecondary: undefined,
};
}
// pending roll timers would be classified as active
const hasActiveTimer = isPlaybackActive(playback) && phase !== TimerPhase.Pending;
// if we are loaded, we show the upcoming event as next
const nowMain = hasActiveTimer ? getPropertyValue(eventNow, mainSource ?? 'title') : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource) : undefined;
const nextMain = hasActiveTimer
? getPropertyValue(eventNext, mainSource ?? 'title')
: getPropertyValue(eventNow, mainSource ?? 'title');
const nextSecondary = hasActiveTimer
? getPropertyValue(eventNext, secondarySource)
: getPropertyValue(eventNow, secondarySource);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
nextMain,
nextSecondary,
};
}
+1
View File
@@ -42,6 +42,7 @@ export default defineConfig({
},
test: {
globals: true,
setupFiles: './src/setupTests.js',
environment: 'jsdom',
},
build: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.10.7",
"version": "3.10.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -3
View File
@@ -2,12 +2,10 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.10.7",
"version": "3.10.3",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.1",
+5 -12
View File
@@ -25,7 +25,6 @@ import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils';
import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null;
@@ -35,7 +34,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null;
private shouldShowWelcome = true;
private isFirstEditor = true;
constructor() {
if (instance) {
@@ -48,16 +47,10 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server, showWelcome: boolean, prefix?: string) {
this.shouldShowWelcome = showWelcome;
init(server: Server, prefix?: string) {
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws, req) => {
authenticateSocket(ws, req, (error) => {
if (error) {
ws.close(1008, 'Unauthorized');
}
});
this.wss.on('connection', (ws) => {
const clientId = generateId();
this.clients.set(clientId, {
@@ -143,8 +136,8 @@ export class SocketServer implements IAdapter {
previousData.path = payload;
this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.shouldShowWelcome) {
this.shouldShowWelcome = false;
if (payload.includes('editor') && this.isFirstEditor) {
this.isFirstEditor = false;
ws.send(
JSON.stringify({
type: 'dialog',
+2 -3
View File
@@ -15,7 +15,6 @@ import {
handleUploaded,
} from '../../services/project-service/projectServiceUtils.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try {
@@ -42,7 +41,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
const newFileName = await projectService.createProject(req.body.filename, {
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
@@ -68,7 +67,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const filename = await projectService.createProject(req.body.project.title || 'untitled', req.body);
const filename = await projectService.createProject(req.body.project.title, req.body);
res.status(200).send({
filename,
});
@@ -7,7 +7,6 @@ import sanitize from 'sanitize-filename';
* @description Validates request for a new project.
*/
export const validateNewProject = [
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -7,7 +7,6 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
const startedAt = new Date();
@@ -25,7 +24,7 @@ export async function getSessionStats(): Promise<SessionStats> {
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName,
playback,
timezone: getTimezoneLabel(startedAt),
timezone: startedAt.getTimezoneOffset(),
};
}
@@ -6,7 +6,6 @@ import type { Request, Response } from 'express';
import { isDocker } from '../../externals.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { extractPin } from './settings.utils.js';
@@ -66,8 +65,3 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
res.status(400).send({ message });
}
}
export async function postWelcomeDialog(req: Request, res: Response) {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).send({ show });
}
@@ -1,10 +1,8 @@
import express from 'express';
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -1,18 +1,6 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings/welcomedialog
*/
export const validateWelcomeDialog = [
body('show').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
+19 -22
View File
@@ -5,18 +5,16 @@ import express from 'express';
import http, { Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import cookieParser from 'cookie-parser';
// import utils
import { publicDir, srcDir } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import middleware configuration
import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js';
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js';
// import utils
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -46,7 +44,6 @@ import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './config/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -75,18 +72,19 @@ if (!isProduction) {
}
app.disable('x-powered-by');
// Implement middleware
app.use(cors()); // setup cors for all routes
app.options('*', cors()); // enable pre-flight cors
// setup cors for all routes
app.use(cors());
// enable pre-flight cors
app.options('*', cors());
// Implement middleware
app.use(bodyParser);
app.use(cookieParser());
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
app.use(prefix, compressedStatic);
// Implement route endpoints
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
// serve static external files
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
@@ -96,9 +94,9 @@ app.use(`${prefix}/external`, (req, res) => {
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
// Base route for static files
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Implement catch all
app.use((_error, response) => {
@@ -168,9 +166,8 @@ export const startServer = async (
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog();
socket.init(expressServer, showWelcome, prefix);
socket.init(expressServer, prefix);
/**
* Module initialises the services and provides initial payload for the store
-2
View File
@@ -15,8 +15,6 @@ export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD;
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
-82
View File
@@ -1,82 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta name="description" content="Login page for Ontime application" />
<title>Ontime Login</title>
<style>
body,
html {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
sans-serif;
background: #101010;
text-align: center;
box-sizing: border-box;
}
h1 {
padding-top: 30vh;
font-size: 3rem;
color: #ff7597;
font-weight: 400;
}
input {
all: unset;
text-align: start;
padding-left: 0.5rem;
background-color: #fffffa;
color: black;
height: 2rem;
border-radius: 2px;
margin-right: 0.5rem;
}
input:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
color: #262626;
}
button {
all: unset;
color: #779be7;
background: #2d2d2d;
height: 2rem;
padding-inline: 2rem;
border-radius: 2px;
cursor: pointer;
}
button:hover {
background: #404040;
}
button:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
}
</style>
</head>
<body>
<form method="post" class="form" autocomplete="off">
<h1>Welcome to Ontime</h1>
<input type="hidden" name="redirect" value="" id="redirect" />
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button type="submit">Login</button>
</form>
</body>
<script>
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect');
if (redirect) {
// Validate redirect URL to prevent open redirect attacks
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
if (isValidRedirect) {
document.getElementById('redirect').value = redirect;
}
}
</script>
</html>
-131
View File
@@ -1,131 +0,0 @@
import { LogOrigin } from 'ontime-types';
import express, { type Request, type Response, type NextFunction } from 'express';
import type { IncomingMessage } from 'node:http';
import type { WebSocket } from 'ws';
import { parse as parseCookie } from 'cookie';
import { hashPassword } from '../utils/hash.js';
import { srcFiles } from '../setup/index.js';
import { logger } from '../classes/Logger.js';
import { password } from '../externals.js';
import { noopMiddleware } from './noop.js';
export const hasPassword = Boolean(password);
const hashedPassword = hasPassword ? hashPassword(password) : '';
/**
* List of public assets that can be accessed without authentication
* should match the files in client/public
*/
const publicAssets = new Set([
'/favicon.ico',
'/manifest.json',
'/ontime-logo.png',
'/robots.txt',
'/site.webmanifest',
]);
export const loginRouter = express.Router();
// serve static files at root
loginRouter.use('/', express.static(srcFiles.login));
// verify password and set cookies + redirect appropriately
loginRouter.post('/', (req, res) => {
res.clearCookie('token');
const { password: reqPassword, redirect } = req.body;
if (hashPassword(reqPassword) === hashedPassword) {
setSessionCookie(res, hashedPassword);
res.redirect(redirect || '/');
return;
}
res.status(401).send('Unauthorized');
});
/**
* Express middleware to authenticate requests
* @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud
*/
export function makeAuthenticateMiddleware(prefix: string) {
// we dont need to initialise the authenticate middleware if there is no password
if (!hasPassword) {
return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware };
}
function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.query.token || req.cookies?.token;
if (token && token === hashedPassword) {
return next();
}
res.status(401).send('Unauthorized');
}
function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) {
// Allow access to specific public assets without authentication
if (publicAssets.has(req.originalUrl)) {
return next();
}
// we shouldnt be here in the login route
if (req.originalUrl.startsWith('/login')) {
return next();
}
// we expect the token to be in the cookies
if (req.cookies?.token === hashedPassword) {
return next();
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
setSessionCookie(res, hashedPassword);
return next();
}
res.redirect(`${prefix}/login?redirect=${req.originalUrl}`);
}
return { authenticate, authenticateAndRedirect };
}
/**
* Middleware to authenticate a WebSocket connection with a token in the cookie
*/
export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) {
if (!hasPassword) {
return next();
}
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token === hashedPassword) {
return next();
}
}
// check if token is in the params
const url = new URL(req.url || '', `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt');
return next(new Error('Unauthorized'));
}
function setSessionCookie(res: Response, token: string) {
res.cookie('token', token, {
httpOnly: false, // allow websocket to access cookie
secure: true,
path: '/', // allow cookie to be accessed from any path
sameSite: 'strict',
});
}
-5
View File
@@ -1,5 +0,0 @@
import type { Request, Response, NextFunction } from 'express';
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
next();
}
@@ -1,4 +1,4 @@
import { dayInMs, millisToString } from 'ontime-utils';
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
@@ -6,6 +6,7 @@ import {
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
} from '../timerUtils.js';
@@ -1046,6 +1047,40 @@ describe('getRuntimeOffset()', () => {
});
});
describe('getTotalDuration()', () => {
it('calculates the duration of events in a single day', () => {
const start = MILLIS_PER_HOUR * 9;
const end = MILLIS_PER_HOUR * 17;
const daySpan = 0;
const duration = getTotalDuration(start, end, daySpan);
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9));
});
it('calculates the duration of events across days', () => {
const start = MILLIS_PER_HOUR * 9;
const end = MILLIS_PER_HOUR * 17;
const daySpan = 1;
const duration = getTotalDuration(start, end, daySpan);
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9) + dayInMs);
});
it('calculates the duration of events across days (2)', () => {
const start = new Date(0).setHours(12);
const end = new Date(0).setHours(8);
const daySpan = 1;
const duration = getTotalDuration(start, end, daySpan);
expect(millisToString(duration)).toBe('20:00:00');
});
it('calculates the duration of events across days (3)', () => {
const start = new Date(0).setHours(9);
const end = new Date(0).setHours(23);
const daySpan = 2;
const duration = getTotalDuration(start, end, daySpan);
expect(millisToString(duration)).toBe('62:00:00');
});
});
describe('getTimerPhase()', () => {
it('should be None if the timer is not running', () => {
const state = {
@@ -8,7 +8,6 @@ import { shouldCrashDev } from '../../utils/development.js';
interface AppState {
lastLoadedProject?: string;
showWelcomeDialog?: boolean;
}
const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -35,19 +34,3 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
config.data.lastLoadedProject = filename;
await config.write();
}
export async function getShowWelcomeDialog(): Promise<boolean> {
// in test environment, we do not want the dialog
if (isTest) return false;
await config.read();
return config.data.showWelcomeDialog ?? true; // default to true
}
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
if (isTest) return;
config.data.showWelcomeDialog = show;
await config.write();
return show;
}
@@ -9,7 +9,6 @@ import {
appendToName,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
@@ -21,7 +20,6 @@ import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
@@ -41,6 +39,7 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
// init dependencies
init();
@@ -268,8 +267,7 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data = safeMerge(dbModel, initialData);
const fileNameWithExtension = ensureJsonExtension(filename);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
@@ -3,7 +3,6 @@ import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
revision: 1,
};
/**
+20
View File
@@ -156,6 +156,26 @@ export function getRuntimeOffset(state: RuntimeState): number {
return startOffset - addedTime - pausedTime + overtime;
}
/**
* Calculates total duration of a time span
* @param firstStart
* @param lastEnd
* @param daySpan
* @returns
*/
export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
if (!lastEnd) {
return 0;
}
let correctDay = 0;
if (lastEnd < firstStart) {
correctDay = dayInMs;
daySpan -= 1;
}
// eslint-disable-next-line prettier/prettier -- we like the clarity
return lastEnd + correctDay + daySpan * dayInMs - firstStart;
}
/**
* Calculates the expected end of the rundown
*/
-2
View File
@@ -87,8 +87,6 @@ export const srcFiles = {
userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
};
/**
+47 -49
View File
@@ -5,62 +5,60 @@
*/
:root {
/** Background colour for the views */
--background-color-override: #ececec;
/** Background colour for the views */
--background-color-override: #ececec;
/** Main text colour for the views */
--color-override: #101010;
/** Text colour for the views */
--secondary-color-override: #404040;
/** Main text colour for the views */
--color-override: #101010;
/** Accent text colour, used on active elements */
--accent-color-override: #fa5656;
/** Text colour for the views */
--secondary-color-override: #404040;
/** Label text colour, used on active elements */
--label-color-override: #6c6c6c;
/** Accent text colour, used on active elements */
--accent-color-override: #fa5656;
/** Timer text colour */
--timer-color-override: #202020;
/** Label text colour, used on active elements */
--label-color-override: #6c6c6c;
/** Background for card elements on background */
--card-background-color-override: #fff;
/** Timer text colour */
--timer-color-override: #202020;
--timer-warning-color-override: #ffbc56;
--timer-danger-color-override: #e69000;
--timer-overtime-color-override: #fa5656;
/** Background highlight for blink behaviour, used only in /backstage */
--card-background-color-blink-override: #339e4e;
/** Font used for all text in views */
--font-family-override: "Open Sans";
/** Background for card elements on background */
--card-background-color-override: #fff;
/** Font used for clock in /minimal and /clock views */
--font-family-bold-override: "Arial Black";
/** Font used for all text in views */
--font-family-override: 'Open Sans';
/** Colour used for progress bar background */
--timer-progress-bg-override: #fff;
/** Font used for clock in /minimal and /clock views */
--font-family-bold-override: 'Arial Black';
/** Colour used for external message and aux timer in /timer */
--external-color-override: #161616;
/** View specific features: /backstage */
/** ---- Background highlight for blink behaviour */
--card-background-color-blink-override: #339e4e;
/** ---- Colour used for progress bar background */
--timer-progress-bg-override: #fff;
/** ---- Colour used for progress bar progress */
--timer-progress-override: #202020;
/** View specific features: /studio */
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
/** View specific features: /lower */
--lowerThird-font-family-override: 'Courier New';
--lowerThird-top-font-weight-override: bold;
--lowerThird-bottom-font-weight-override: bold;
--lowerThird-top-font-style-override: normal;
--lowerThird-bottom-font-style-override: italic;
--lowerThird-line-height-override: 1vh;
--lowerThird-text-align-override: end;
/** Colour used for progress bar progress */
--timer-progress-override: #202020;
/** Colour used for external message and aux timer in /timer */
--external-color-override: #161616;
/** View specific features: /studio */
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
/** View specific features: /lower */
--lowerThird-font-family-override: 'Courier New';
--lowerThird-top-font-weight-override: bold;
--lowerThird-bottom-font-weight-override: bold;
--lowerThird-top-font-style-override: normal;
--lowerThird-bottom-font-style-override: italic;
--lowerThird-line-height-override: 1vh;
--lowerThird-text-align-override: end;
}
/**
@@ -68,5 +66,5 @@
* In the below example, we change the colour of the overlay message in the stage-timer view.
*/
.stage-timer > .message-overlay--active > div {
color: red;
color: red;
}
-9
View File
@@ -1,9 +0,0 @@
import { createHash } from 'node:crypto';
/**
* Creates a hash of the password that is URL safe
* @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs
*/
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('base64url');
}
+2 -16
View File
@@ -1,4 +1,5 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
@@ -45,18 +46,3 @@ export function parseExcelDate(excelDate: unknown): number {
return 0;
}
export function getTimezoneLabel(date: Date): string {
const tz = date.getTimezoneOffset();
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
// timezone offset is inverted
const sign = tz < 0 ? '+' : '-';
const abs = Math.abs(tz);
// convert minutes to hours
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
}
-57
View File
@@ -1,57 +0,0 @@
# Automations
The automation feature's purpose is to integrate ontime into users' workflows.
Ontime has a large amount of production information, which users need considerable effort to maintain. We want to allow tools so that:
- allow distribution of Ontime's and other production data
- allow surfacing Ontime events
- allow synchronizing with other tools
## Previous
Previous iterations imposed limitations on the number of integrations and target devices to evaluate performance concerns.
The feature was not as used as we had hoped, and users often escalated the integration to tools like Companion.
I believe this to be in part from a lack of clarity on the feature and the limitations imposed
- Lack of explicit filtering logic made the process hard to reason
- Users met limitations on target devices earlier than expected. We would want users to meet these limitations only when the project grew over a size where a show controller would be needed
- Building the template strings was complex and poorly documented
## Overview
- The user should be able to create as many automations as they want
- Automations are triggered by lifecycle events
- Each automation should go through a user-defined filtering process
- Each automation should be able to target multiple devices and multiple protocols
- We leverage HTTP / OSC as the main protocols ~~and add support for triggering Companion button presses~~
- To allow easier debugging and "learn" workflows, users should be able to test a message before saving the automation
- We should have inline documentation for the template strings
> ⚠️ **Companion module** \
> We have opted for removing the requirement for companion module integration. \
> We did not see as much friction that could be solved with a custom integration as initially thought. Triggering a button press in companion is done trivially over either OSC or HTTP and companion users are familiar with the process.
## Implementation details
- To simplify the usage of template strings, we will generate a list of template strings at runtime from the user project file
- To allow easier implementation and extensions, we want to keep the automations separate from triggers
```ts
type Automation = {
id: AutomationId;
name: string;
filter: Filter[];
output: Output[];
};
type Trigger = {
id: string;
event: TimerLifecycle;
automations: AutomationId[];
};
```
### Extension
- Users have expressed a desire to have automation triggered by the lifecycle of a specific event. By keeping the trigger separate from the automation, we will allow this to be implemented in the future.
+6
View File
@@ -8,6 +8,9 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
@@ -38,6 +41,9 @@ test('project file upload', async ({ page }) => {
test('project file download', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'toggle settings' }).click();
await page.getByRole('button', { name: 'Project', exact: true }).click();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.10.7",
"version": "3.10.3",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -38,7 +38,7 @@
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.42.1",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
@@ -15,7 +15,7 @@ export interface SessionStats {
lastRequest: MaybeString;
projectName: string;
playback: Playback;
timezone: string;
timezone: number;
}
export interface GetInfo {
+2 -5
View File
@@ -44,11 +44,10 @@ export {
millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js';
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export {
formatFromMillis,
millisToString,
pad,
removeLeadingZero,
removeSeconds,
removeTrailingZero,
@@ -64,9 +63,7 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
export { deepmerge } from './src/externals/deepmerge.js';
// array utils
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils
export { getPropertyFromPath } from './src/common/objectUtils.js';
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js';
// generic utilities
export { getErrorMessage } from './src/generic/generic.js';
-18
View File
@@ -1,18 +0,0 @@
/**
* Extracts a value from a nested object using a dot-separated path
*/
export function getPropertyFromPath<T extends object>(path: string, obj: T): any | undefined {
const keys = path.split('.');
let result: any = obj;
// iterate through variable parts, and look for the property in the given object
for (const key of keys) {
if (result && typeof result === 'object' && key in result) {
result = result[key];
} else {
return undefined;
}
}
return result;
}
@@ -1,17 +0,0 @@
import { getPropertyFromPath } from './objectUtils';
describe('getNestedFromTemplate', () => {
it('should return the value of a nested property', () => {
expect(getPropertyFromPath('a', { a: 1 })).toBe(1);
expect(getPropertyFromPath('a.b', { a: { b: 1 } })).toBe(1);
expect(getPropertyFromPath('a.b.c', { a: { b: { c: 1 } } })).toBe(1);
});
it('should guard against values that do not exist', () => {
expect(getPropertyFromPath('c', {})).toBe(undefined);
expect(getPropertyFromPath('c', { a: 1 })).toBe(undefined);
expect(getPropertyFromPath('a.c', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('c.a', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('a.b.b', { a: { b: { c: 1 } } })).toBeUndefined();
});
});
@@ -2,7 +2,7 @@ import type { MaybeNumber } from 'ontime-types';
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
export function pad(val: number): string {
function pad(val: number): string {
return String(val).padStart(2, '0');
}
@@ -27,8 +27,4 @@ describe('validateTimerType()', () => {
expect(emptyType).toBe(TimerType.Clock);
expect(invalidType).toBe(TimerType.CountDown);
});
it('handles a null value from params', () => {
const nullType = validateTimerType(null, TimerType.Clock);
expect(nullType).toBe(TimerType.Clock);
});
});
+436 -127
View File
File diff suppressed because it is too large Load Diff