Compare commits

..

3 Commits

Author SHA1 Message Date
Carlos Valente 0bb09dd039 fix: sanitise cue import (#519)
* fix: sanitise cue import
2023-09-11 21:06:32 +02:00
Carlos Valente 93fb48ea1c feat: operator view (#440)
* feat: operator view

* chore: smoke test operator

* refactor: small improvements from deepsource

---------

Co-authored-by: arihanv <arihanvaranasi@gmail.com>
Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
2023-09-11 21:03:11 +02:00
Carlos Valente 1de3e01216 style: localise labels to user device (#509) 2023-09-03 09:13:56 +02:00
57 changed files with 1139 additions and 167 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.7.2",
"version": "2.8.2",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+4
View File
@@ -6,6 +6,7 @@ import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -58,6 +59,9 @@ export default function AppRouter() {
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
@@ -0,0 +1,9 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
@@ -0,0 +1,40 @@
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
return null;
}
@@ -58,7 +58,7 @@ function NavigationMenu() {
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
@@ -116,6 +116,10 @@ function NavigationMenu() {
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon />
<Icon className={className} />
</Tooltip>
);
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
background-color: $gray-1250;
}
.drawerHeader {
@@ -1,3 +1,5 @@
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
@@ -201,3 +203,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
type: 'boolean',
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
];
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -0,0 +1,63 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
+8 -1
View File
@@ -4,10 +4,17 @@ import { deepCompare, useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
+12
View File
@@ -0,0 +1,12 @@
export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback();
}, wait);
};
}
@@ -0,0 +1,6 @@
export function isMacOS() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
+3 -3
View File
@@ -3,7 +3,7 @@ import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
};
/**
* @description Selects text colour to maintain accessible contrast
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
console.log(`Unable to parse colour: ${bgColour}`);
}
}
return { backgroundColor: '#000', color: "#fffffa" };
return { backgroundColor: '#000', color: '#fffffa' };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
+2 -1
View File
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
@@ -113,14 +113,6 @@ $table-header-font-size: calc(1rem - 3px);
}
}
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
+5 -31
View File
@@ -1,4 +1,4 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
@@ -14,6 +14,7 @@ import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordin
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
@@ -46,6 +47,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({
data,
@@ -63,7 +66,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const tableContainerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -83,34 +85,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}),
);
// when selection moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLTableRowElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (!followSelected) {
return;
}
if (selectedRef.current && tableContainerRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedRef as MutableRefObject<HTMLTableRowElement>,
tableContainerRef as MutableRefObject<HTMLDivElement>,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
}, [selectedRef.current, tableContainerRef.current, followSelected]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -243,7 +217,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
rowBgColour = '#D20300'; // $red-700
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
}
return (
<tr
@@ -26,7 +26,7 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter', undefined)).toBe('');
expect(parseField('presenter')).toBe('');
});
describe('simply returns any other value in any other field', () => {
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
.actionIcon {
cursor: pointer;
&:hover {
color: $active-colour;
}
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
}
@@ -6,13 +6,13 @@ import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import PlaybackIcon from '../tableElements/PlaybackIcon';
import style from './CuesheetTableHeader.module.scss';
@@ -1,14 +1,10 @@
import { useCallback } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { tooltipDelayFast } from '../../ontimeConfig';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function DelayIndicator(props: { delayValue: number }) {
const { delayValue } = props;
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
return null;
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const cellValue = (getValue() as number | null) ?? 0;
@@ -144,7 +144,7 @@ $playback-width: 26rem;
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
box-shadow: $large-bottom-drawer-shadow;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
@@ -164,7 +164,7 @@ $playback-width: 26rem;
}
.header {
background-color: $gray-1200;
background-color: $gray-1250;
padding: 0.5rem;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
@@ -0,0 +1,24 @@
@use '../../../src/theme/v2Styles' as *;
@use '../../../src/theme/ontimeColours' as *;
.operatorContainer {
width: 100vw;
height: 100vh;
color: $ui-white;
}
.operatorEvents {
width: 100vw;
height: 100vh;
padding-top: 0.25rem;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.spacer {
min-height: 95vh;
}
@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useEventData from '../../common/hooks-query/useEventData';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import { isStringBoolean } from '../../common/utils/viewUtils';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar';
import style from './Operator.module.scss';
const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useEventData();
const featureData = useOperator();
const [searchParams] = useSearchParams();
const isAutomatedScroll = useRef(false);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef: scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
setScrollFlag: () => (isAutomatedScroll.current = true),
});
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
// reset scroll if nothing is selected
useEffect(() => {
if (!featureData?.selectedEventId) {
if (!lockAutoScroll) {
scrollRef.current?.scrollTo(0, 0);
}
}
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
const handleOffset = () => {
if (featureData.selectedEventId) {
scrollToComponent();
}
setLockAutoScroll(false);
};
const handleScroll = () => {
// prevent considering automated scrolls as user scrolls
if (isAutomatedScroll.current) {
isAutomatedScroll.current = false;
return;
}
if (selectedRef?.current && scrollRef?.current) {
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
};
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
if (missingData || isLoading) {
return <Empty text='Loading...' />;
}
// get fields which the user subscribed to
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
const firstEvent = getFirstEvent(data);
const lastEvent = getLastEvent(data);
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
<StatusBar
projectTitle={projectData.title}
playback={featureData.playback}
selectedEventId={featureData.selectedEventId}
firstStart={firstEvent?.timeStart}
firstId={firstEvent?.id}
lastEnd={lastEvent?.timeEnd}
lastId={lastEvent?.id}
/>
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
{data.map((entry) => {
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
isPast = false;
}
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const mainField = main ? entry?.[main] || entry.title : entry.title;
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
return (
<OperatorEvent
key={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
timeEnd={entry.timeEnd}
duration={entry.duration}
delay={entry.delay}
isSelected={isSelected}
subscribed={subscribedData}
subscribedAlias={subscribedAlias}
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
/>
);
}
if (entry.type === SupportedEvent.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />;
}
return null;
})}
<div className={style.spacer} />
</div>
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
}
@@ -0,0 +1,38 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 12rem;
margin: 0 auto;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
background-color: $blue-700;
color: #fff;
font-size: 1rem;
border-radius: 99px;
transition: bottom 1s;
&:active {
transition: background-color $transition-time-action;
background-color: $blue-900;
}
}
.hidden {
transition: bottom 1s;
bottom: -50px;
}
// tablet
@media (min-width: $min-tablet) {
.followButton {
font-size: 1.25rem;
gap: 1rem;
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,23 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { cx } from '../../../common/utils/styleUtils';
import style from './FollowButton.module.scss';
interface FollowButtonProps {
isVisible: boolean;
onClickHandler: () => void;
}
export default function FollowButton(props: FollowButtonProps) {
const { isVisible, onClickHandler } = props;
const classes = cx([style.followButton, !isVisible && style.hidden]);
return (
<button className={classes} onClick={onClickHandler} type='button'>
<IoLocate />
Follow
</button>
);
}
@@ -0,0 +1,16 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.block {
width: 100%;
padding: 0.25rem 0.5rem;
background-color: $gray-1350;
font-size: 1.25rem;
}
// tablet
@media (min-width: $min-tablet) {
.block {
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,13 @@
import { memo } from 'react';
import style from './OperatorBlock.module.scss';
interface OperatorBlockProps {
title: string;
}
function OperatorBlock({ title }: OperatorBlockProps) {
return <div className={style.block}>{title}</div>;
}
export default memo(OperatorBlock);
@@ -0,0 +1,111 @@
@use '../../../../src/theme/v2Styles' as *;
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
@mixin clock-size {
font-size: calc(1rem - 2px);
@media (min-width: $min-tablet) {
font-size: 1rem;
}
}
.event {
opacity: 1;
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $gray-1300;
display: grid;
align-items: center;
grid-template-columns: 1.25rem 1fr auto;
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
grid-template-areas:
"binder main schedule"
"binder secondary running"
"binder fields fields";
&.subscribed {
background-color: $gray-1250;
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
}
}
.binder {
grid-area: binder;
color: $section-white;
height: 100%;
display: grid;
place-content: center;
position: relative;
background-color: $gray-1050; // to override inline
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
width: 6em;
rotate: -90deg;
letter-spacing: 0.5px;
}
}
.mainField {
grid-area: main;
font-size: 1.5rem;
letter-spacing: 0.5px;
color: $ui-white;
}
.secondaryField {
grid-area: secondary;
font-size: 1.25rem;
letter-spacing: 0.5px;
}
.schedule {
@include clock-size;
grid-area: schedule;
justify-self: end;
}
.running {
@include clock-size;
grid-area: running;
justify-self: end;
}
.fields {
grid-area: fields;
font-size: 1.25rem;
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
.field {
font-weight: 600;
padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600);
margin-right: 0.5rem;
}
.value {
color: $orange-500
}
}
.fields::after {
content: '\200b';
}
@@ -0,0 +1,92 @@
import { memo, RefObject } from 'react';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import style from './OperatorEvent.module.scss';
interface OperatorEventProps {
colour: string;
cue: string;
main: string;
secondary: string;
timeStart: number;
timeEnd: number;
duration: number;
delay?: number;
isSelected: boolean;
subscribed?: string;
subscribedAlias: string;
showSeconds: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
}
// extract this to contain re-renders
function RollingTime() {
const timer = useTimer();
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
}
function OperatorEvent(props: OperatorEventProps) {
const {
colour,
cue,
main,
secondary,
timeStart,
timeEnd,
duration,
delay,
isSelected,
subscribed,
subscribedAlias,
showSeconds,
isPast,
selectedRef,
} = props;
const start = formatTime(timeStart, { showSeconds });
const end = formatTime(timeEnd, { showSeconds });
const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([
style.event,
isSelected ? style.running : null,
subscribed ? style.subscribed : null,
isPast ? style.past : null,
]);
return (
<div className={operatorClasses} ref={selectedRef}>
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
<span className={style.mainField}>{main}</span>
<span className={style.schedule}>
{start} - {end}
</span>
<span className={style.secondaryField}>{secondary}</span>
<span className={style.running}>
<DelayIndicator delayValue={delay} />
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
</span>
<div className={style.fields}>
{subscribed && (
<>
<span className={style.field}>{subscribedAlias}</span>
<span className={style.value}>{subscribed}</span>
</>
)}
</div>
</div>
);
}
export default memo(OperatorEvent);
@@ -0,0 +1,105 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
@mixin column {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.statusBar {
position: sticky;
top: 0;
width: 100%;
background-color: $gray-1350;
z-index: 2;
padding: 0.5rem 1rem;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
display: grid;
grid-template-areas:
"playback timer1B timer2B timer3B";
grid-template-columns: 1fr auto auto auto;
column-gap: 1.5rem;
align-items: center;
}
.playbackIcon {
grid-area: playback;
font-size: 2rem;
color: $gray-700;
&.active {
color: $ui-white;
}
}
.timeNow {
grid-area: timer1B;
@include column;
}
.elapsedTime {
grid-area: timer2B;
@include column;
}
.runningTime {
grid-area: timer3B;
@include column;
}
.title {
grid-area: title;
font-size: 1.25rem;
padding-left: 0.25rem;
display: none;
line-height: 1.25em;
}
.startTime {
grid-area: timer2A;
@include column;
display: none;
}
.endTime {
grid-area: timer3A;
@include column;
display: none;
}
.label {
font-size: 0.75rem;
color: $gray-700;
}
.timer {
letter-spacing: 0.5px;
}
// tablet
@media (min-width: $min-tablet) {
.statusBar {
grid-template-areas:
"playback timer1B timer2A timer3A"
"title title timer2B timer3B";
row-gap: 0.25rem;
column-gap: 2rem;
}
.title {
display: block;
}
.startTime {
display: flex;
}
.endTime {
display: flex;
}
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import styles from './StatusBar.module.scss';
interface StatusBarProps {
projectTitle: string;
playback: Playback;
selectedEventId: string | null;
firstStart?: number;
firstId?: string;
lastEnd?: number;
lastId?: string;
}
export default function StatusBar(props: StatusBarProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
return (
<div className={styles.statusBar}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
</div>
);
}
+3 -23
View File
@@ -1,10 +1,11 @@
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
@@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) {
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
};
}, [handleKeyPress]);
// when cursor moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLDivElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (cursorRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [cursorRef?.current, scrollRef]);
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
$block-clearance: 0.5rem;
$block-border-radius: 0.5rem;
$block-text-color: $gray-50;
$block-bg: $gray-1200;
$block-bg: $gray-1250;
$block-bg2: $gray-1050; // for delay and blocks
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 2.5rem;
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<span>
<IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-isPublic={isPublic}
data-ispublic={isPublic}
/>
</span>
</Tooltip>
@@ -5,6 +5,7 @@ import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss';
@@ -82,7 +83,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-event'
>
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
@@ -94,7 +95,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-delay'
>
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
@@ -106,7 +107,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-block'
>
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
</Button>
</Tooltip>
</div>
+3 -2
View File
@@ -8,6 +8,7 @@ $white-10: rgba(255, 255, 255, 0.10);
$white-13: rgba(255, 255, 255, 0.13);
$white-20: rgba(255, 255, 255, 0.20);
$white-60: rgba(255, 255, 255, 0.60);
$white-90: rgba(255, 255, 255, 0.90);
$black-10: rgba(0, 0, 0, 0.10);
@@ -24,8 +25,8 @@ $gray-900: #4c4c4c;
$gray-1000: #404040;
$gray-1050: #303030;
$gray-1100: #2d2d2d;
$gray-1250: #262626;
$gray-1200: #202020;
$gray-1200: #262626;
$gray-1250: #202020;
$gray-1300: #1a1a1a;
$gray-1350: #101010;
$pure-white: #fff;
+6 -1
View File
@@ -36,6 +36,8 @@ $bg-container-onlight: $gray-100;
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
$modal-note-color: $gray-700;
@@ -54,6 +56,9 @@ $section-white: $ui-white;
$inner-section-text-size: calc(1rem - 2px);
$text-body-size: calc(1rem - 1px);
// media queries
$min-tablet: 500px;
.blink {
animation: blink $blinking-time linear infinite;
}
@@ -62,4 +67,4 @@ $text-body-size: calc(1rem - 1px);
50% {
opacity: 20%;
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ export const ontimeButtonOutlined = {
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-12000
borderColor: '#202020', // $gray-1250
},
};
@@ -43,7 +43,7 @@ export const ontimeButtonSubtle = {
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-12000
borderColor: '#202020', // $gray-1250
},
};
+3 -3
View File
@@ -1,9 +1,9 @@
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1250
backgroundColor: '#262626', // $gray-1250
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1250
borderColor: '#262626', // $gray-1200
color: '#3182ce', // $action-blue
backgroundColor: '#3182ce', // $action-blue
},
+1 -1
View File
@@ -1,6 +1,6 @@
const commonStyles = {
fontWeight: '400',
backgroundColor: '#262626', // $gray-1250
backgroundColor: '#262626', // $gray-1200
color: '#e2e2e2', // $gray-200
border: '1px solid transparent',
_hover: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.7.2",
"version": "2.8.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.7.2",
"version": "2.8.2",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
@@ -209,7 +209,7 @@ export const postSettings = async (req, res) => {
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
if (isDocker && req.body?.serverPort) {
return res.status(403).json({ message: `Can't change port when running inside docker` });
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
const serverPort = parseInt(req.body?.serverPort ?? settings.serverPort, 10);
+18 -13
View File
@@ -1,18 +1,23 @@
:root {
--background-color-override: #ececec;
--color-override: #101010;
--secondary-color-override: #404040;
--accent-color-override: #fa5656;
--label-color-override: #6c6c6c;
--timer-color-override: #202020;
--card-background-color-override: #fff;
--card-background-color-blink-override: #339e4e;
--font-family-override: "Open Sans";
--font-family-bold-override: "Arial Black";
--timer-progress-bg-override: #fff;
--timer-progress-override: #202020;
--background-color-override: #ececec;
--color-override: #101010;
--secondary-color-override: #404040;
--accent-color-override: #fa5656;
--label-color-override: #6c6c6c;
--timer-color-override: #202020;
--card-background-color-override: #fff;
--card-background-color-blink-override: #339e4e;
--font-family-override: "Open Sans";
--font-family-bold-override: "Arial Black";
--timer-progress-bg-override: #fff;
--timer-progress-override: #202020;
--cuesheet-running-bg-override: #D20300;
--operator-running-bg-override: #D20300;
--operator-highlight-override: #FFAB33;
}
.timer {
color: black !important;
color: black !important;
}
@@ -206,7 +206,7 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
if (eventData.type === SupportedEvent.Event && eventData?.cue === '') {
throw new Error(`Cue value invalid`);
throw new Error('Cue value invalid');
}
const newEvent = await cachedEdit(eventData.id, eventData);
@@ -269,6 +269,24 @@ describe('test json parser with valid def', () => {
});
describe('test parser edge cases', () => {
it('stringifies necessary values', async () => {
const testData = {
rundown: [
{
cue: 101,
type: 'event',
},
{
cue: 101.1,
type: 'event',
},
],
};
const parseResponse = await parseJson(testData);
expect(typeof (parseResponse.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (parseResponse.rundown[1] as OntimeEvent).cue).toBe('string');
});
it('generates missing ids', async () => {
const testData = {
rundown: [
@@ -579,6 +597,7 @@ describe('test parseExcel function', () => {
'user8:test8',
'user9:test9',
'Colour',
'cue',
],
[
'1899-12-30T07:00:00.000Z',
@@ -602,6 +621,7 @@ describe('test parseExcel function', () => {
'a8',
'a9',
'red',
101,
],
[
'1899-12-30T08:00:00.000Z',
@@ -625,6 +645,7 @@ describe('test parseExcel function', () => {
'',
'',
'#F00',
102,
],
[],
];
@@ -662,6 +683,7 @@ describe('test parseExcel function', () => {
user9: 'a9',
colour: 'red',
type: 'event',
cue: '101',
},
{
//timeStart: 32400000,
@@ -678,6 +700,7 @@ describe('test parseExcel function', () => {
user5: 'b5',
colour: '#F00',
type: 'event',
cue: '102',
},
];
+6 -7
View File
@@ -100,19 +100,19 @@ export const parseExcel = async (excelData) => {
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = column;
event.title = makeString(column, '');
} else if (j === cueIndex) {
event.cue = column;
event.cue = makeString(column, '');
} else if (j === presenterIndex) {
event.presenter = column;
event.presenter = makeString(column, '');
} else if (j === subtitleIndex) {
event.subtitle = column;
event.subtitle = makeString(column, '');
} else if (j === isPublicIndex) {
event.isPublic = Boolean(column);
} else if (j === skipIndex) {
event.skip = Boolean(column);
} else if (j === notesIndex) {
event.note = column;
event.note = makeString(column, '');
} else if (j === endActionIndex) {
if (column === '') {
event.endAction = EndAction.None;
@@ -326,7 +326,6 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string) => {
// ensure id is defined and unique
const id = eventArgs.id || generateId();
const cue = eventArgs.cue || cueFallback;
let event = null;
@@ -365,7 +364,7 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
user9: makeString(e.user9, d.user9),
colour: makeString(e.colour, d.colour),
id,
cue,
cue: makeString(e.cue, cueFallback),
type: 'event',
};
}
+28 -14
View File
@@ -25,7 +25,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "32d31"
"id": "32d31",
"cue": "SF1.01"
},
{
"title": "Latvia",
@@ -52,7 +53,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "21cd2"
"id": "21cd2",
"cue": "SF1.02"
},
{
"title": "Lithuania",
@@ -79,7 +81,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "0b371"
"id": "0b371",
"cue": "SF1.03"
},
{
"title": "Switzerland",
@@ -106,7 +109,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "3cd28"
"id": "3cd28",
"cue": "SF1.04"
},
{
"title": "Slovenia",
@@ -133,7 +137,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "e457f"
"id": "e457f",
"cue": "SF1.05"
},
{
"title": "Lunch break",
@@ -165,7 +170,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "1c420"
"id": "1c420",
"cue": "SF1.06"
},
{
"title": "Bulgaria",
@@ -192,7 +198,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "b7737"
"id": "b7737",
"cue": "SF1.07"
},
{
"title": "Netherlands",
@@ -219,7 +226,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "d3a80"
"id": "d3a80",
"cue": "SF1.08"
},
{
"title": "Moldova",
@@ -246,7 +254,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "8276c"
"id": "8276c",
"cue": "SF1.09"
},
{
"title": "Portugal",
@@ -273,7 +282,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "2340b"
"id": "2340b",
"cue": "SF1.10"
},
{
"title": "Afternoon break",
@@ -305,7 +315,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "503c4"
"id": "503c4",
"cue": "SF1.11"
},
{
"title": "Denmark",
@@ -332,7 +343,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "5e965"
"id": "5e965",
"cue": "SF1.12"
},
{
"title": "Austria",
@@ -359,7 +371,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "bab4a"
"id": "bab4a",
"cue": "SF1.13"
},
{
"title": "Greece",
@@ -386,7 +399,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "d3eb1"
"id": "d3eb1",
"cue": "SF1.14"
}
],
"eventData": {
+1 -1
View File
@@ -28,5 +28,5 @@ test('CRUD operations on the rundown', async ({ page }) => {
await page.locator('label').filter({ hasText: 'Event is public' }).click();
await page.getByTestId('quick-add-event').click();
await expect(await page.getByTestId('entry-4').getByRole('img').nth(3)).toHaveAttribute('data-isPublic', 'true');
await expect(await page.getByTestId('entry-4').getByRole('img').nth(3)).toHaveAttribute('data-ispublic', 'true');
});
+56
View File
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
test('smoke test operator', async ({ page }) => {
// make some boilerplate
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Run mode' }).click();
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('time-input-timeStart').fill('1m');
await page.getByTestId('time-input-timeStart').press('Enter');
await page.getByTestId('time-input-durationOverride').fill('1m');
await page.getByTestId('time-input-durationOverride').press('Enter');
await page.getByText('Start time is last end').click();
await page.getByTestId('quick-add-event').click();
await page.getByTestId('entry-2').getByTestId('time-input-durationOverride').fill('1m');
await page.getByTestId('entry-2').getByTestId('time-input-durationOverride').press('Enter');
await page.getByText('Start time is last end').click();
await page.getByTestId('quick-add-event').click();
await page.getByTestId('entry-3').getByTestId('time-input-durationOverride').fill('1m');
await page.getByTestId('entry-3').getByTestId('time-input-durationOverride').press('Enter');
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Add block at start' }).click();
await page.getByTestId('quick-add-block').click();
await page.getByTestId('entry-1').getByRole('button', { name: 'Event options' }).first().click();
await page.getByLabel('Title', { exact: true }).click();
await page.getByLabel('Title', { exact: true }).fill('title 1');
await page.getByLabel('Title', { exact: true }).press('Enter');
await page.getByTestId('entry-2').getByRole('button', { name: 'Event options' }).first().click();
await page.getByLabel('Title', { exact: true }).click();
await page.getByLabel('Title', { exact: true }).fill('title 2');
await page.getByLabel('Title', { exact: true }).press('Enter');
await page.getByTestId('entry-3').getByRole('button', { name: 'Event options' }).first().click();
await page.getByLabel('Title', { exact: true }).click();
await page.getByLabel('Title', { exact: true }).fill('title 3');
await page.getByLabel('Title', { exact: true }).press('Enter');
// start an event
await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click();
await page.goto('http://localhost:4001/op');
await expect(page.getByText('title 1')).toBeInViewport();
await expect(page.getByText('title 2')).toBeInViewport();
await expect(page.getByText('title 3')).toBeInViewport();
await expect(page.getByText('00:01 - 00:02')).toBeInViewport();
await expect(page.getByText('00:02 - 00:03')).toBeInViewport();
await expect(page.getByText('00:03 - 00:04')).toBeInViewport();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.7.2",
"version": "2.8.2",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
+1 -1
View File
@@ -1,5 +1,5 @@
// runtime utils
export { getFirst, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { validatePlayback } from './src/validate-action/validatePlayback.js';
// rundown utils
+6 -2
View File
@@ -3,14 +3,18 @@ import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'o
import { getFirstEvent, getNextEvent, getPreviousEvent } from '../rundown-utils/rundownUtils.js';
import { isNumeric } from '../types/types.js';
// Zero or more non-digit characters at the beginning ((\D*)).
// One or more digits ((\d+)).
// Optionally, a decimal part starting with a dot ((\.\d+)?).
const regex = /^(\D*)(\d+)(\.\d+)?$/;
/**
* Finds if last characters in input are a number and increments
* @param input {string}
*/
export function getIncrement(input: string): string {
// Check if the input string contains a number at the end
const match = input?.match(/^(\D*)(\d+)(\.\d+)?$/);
const match = regex.exec(input);
if (match) {
// If a number is found, extract the non-numeric prefix, integer part, and decimal part
let [, prefix, integerPart, decimalPart] = match;
@@ -26,6 +26,21 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return null;
}
export function getLastEvent(rundown: OntimeRundown): OntimeEvent | null {
if (rundown.length < 1) {
return null;
}
for (let i = rundown.length - 1; i > 0; i--) {
const event = rundown.at(i);
if (isOntimeEvent(event)) {
return event;
}
}
return null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
+1 -1
View File
@@ -8500,4 +8500,4 @@ packages:
dependencies:
react: 18.2.0
use-sync-external-store: 1.2.0_react@18.2.0
dev: false
dev: false