diff --git a/apps/client/package.json b/apps/client/package.json
index 8c213bbb8..439489ae7 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
- "version": "2.7.2",
+ "version": "2.8.2",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx
index ca631d359..4c6f5726d 100644
--- a/apps/client/src/AppRouter.tsx
+++ b/apps/client/src/AppRouter.tsx
@@ -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 *!/*/}
} />
+ } />
+ } />
+
{/*/!* Protected Routes *!/*/}
} />
} />
diff --git a/apps/client/src/common/components/delay-indicator/DelayIndicator.module.scss b/apps/client/src/common/components/delay-indicator/DelayIndicator.module.scss
new file mode 100644
index 000000000..7f7ce3cb2
--- /dev/null
+++ b/apps/client/src/common/components/delay-indicator/DelayIndicator.module.scss
@@ -0,0 +1,9 @@
+@use '../../../theme/v2Styles' as *;
+
+.delaySymbol {
+ svg {
+ font-size: 1.5rem;
+ color: $ontime-delay;
+ margin: 0 auto;
+ }
+}
diff --git a/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx b/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx
new file mode 100644
index 000000000..14b01a55d
--- /dev/null
+++ b/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
+ if (delayValue > 0) {
+ return (
+
+
+
+
+
+ );
+ }
+ }
+
+ return null;
+}
diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
index 23dfbcf79..7924291ba 100644
--- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
+++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
@@ -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
+
+ Operator
+
+
{navigatorConstants.map((route) => (
;
+ }
+
return (
-
+
);
}
diff --git a/apps/client/src/common/components/state/Empty.tsx b/apps/client/src/common/components/state/Empty.tsx
index 776f92b4a..db43ae49d 100644
--- a/apps/client/src/common/components/state/Empty.tsx
+++ b/apps/client/src/common/components/state/Empty.tsx
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
+
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss
index 38c18e674..7bf970ecf 100644
--- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss
+++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
- background-color: $gray-1200;
+ background-color: $gray-1250;
}
.drawerHeader {
diff --git a/apps/client/src/common/components/view-params-editor/constants.ts b/apps/client/src/common/components/view-params-editor/constants.ts
index 682f4ab62..a3cebc54e 100644
--- a/apps/client/src/common/components/view-params-editor/constants.ts
+++ b/apps/client/src/common/components/view-params-editor/constants.ts
@@ -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',
+ },
+ },
+ ];
+};
diff --git a/apps/client/src/common/context/useMediaQuery.ts b/apps/client/src/common/context/useMediaQuery.ts
new file mode 100644
index 000000000..e2d282529
--- /dev/null
+++ b/apps/client/src/common/context/useMediaQuery.ts
@@ -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(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;
+}
diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts
new file mode 100644
index 000000000..f26ef07c0
--- /dev/null
+++ b/apps/client/src/common/hooks/useFollowComponent.ts
@@ -0,0 +1,63 @@
+import { MutableRefObject, useCallback, useEffect } from 'react';
+
+function scrollToComponent(
+ componentRef: MutableRefObject,
+ scrollRef: MutableRefObject,
+ 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;
+ scrollRef: MutableRefObject;
+ 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,
+ scrollRef as MutableRefObject,
+ 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;
+}
diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts
index 95be26d8f..6f0f3e520 100644
--- a/apps/client/src/common/hooks/useSocket.ts
+++ b/apps/client/src/common/hooks/useSocket.ts
@@ -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);
diff --git a/apps/client/src/common/utils/debounce.ts b/apps/client/src/common/utils/debounce.ts
new file mode 100644
index 000000000..4b15d83d4
--- /dev/null
+++ b/apps/client/src/common/utils/debounce.ts
@@ -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);
+ };
+}
diff --git a/apps/client/src/common/utils/styleUtils.ts b/apps/client/src/common/utils/styleUtils.ts
index 2be4ff166..f0e2a05e6 100644
--- a/apps/client/src/common/utils/styleUtils.ts
+++ b/apps/client/src/common/utils/styleUtils.ts
@@ -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(' ');
diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts
index 269c07a12..dc0945f7b 100644
--- a/apps/client/src/common/utils/time.ts
+++ b/apps/client/src/common/utils/time.ts
@@ -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);
};
diff --git a/apps/client/src/features/cuesheet/Cuesheet.module.scss b/apps/client/src/features/cuesheet/Cuesheet.module.scss
index de94398b9..04c982bcf 100644
--- a/apps/client/src/features/cuesheet/Cuesheet.module.scss
+++ b/apps/client/src/features/cuesheet/Cuesheet.module.scss
@@ -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);
diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx
index f8806e65f..6d377a826 100644
--- a/apps/client/src/features/cuesheet/Cuesheet.tsx
+++ b/apps/client/src/features/cuesheet/Cuesheet.tsx
@@ -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(null);
+ const tableContainerRef = useRef(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(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,
- scrollRef: MutableRefObject,
- ) {
- 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,
- tableContainerRef as MutableRefObject,
- );
- });
- }
- // 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 (
{
});
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', () => {
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
index f5b988dee..6cf3dc4ea 100644
--- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
+++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
.actionIcon {
cursor: pointer;
- &:hover {
- color: $active-colour;
- }
-
&.enabled {
color: $active-indicator;
}
+
+ &:hover {
+ color: $active-colour;
+ }
}
}
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
index 52e3cda2b..b5445f93f 100644
--- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
+++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
@@ -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';
diff --git a/apps/client/src/features/cuesheet/cuesheetCols.tsx b/apps/client/src/features/cuesheet/cuesheetCols.tsx
index caca2c628..13446f305 100644
--- a/apps/client/src/features/cuesheet/cuesheetCols.tsx
+++ b/apps/client/src/features/cuesheet/cuesheetCols.tsx
@@ -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) {
return cellValue ? : '';
}
-function DelayIndicator(props: { delayValue: number }) {
- const { delayValue } = props;
- if (delayValue < 0) {
- return (
-
-
-
-
-
- );
- }
-
- if (delayValue > 0) {
- return (
-
-
-
-
-
- );
- }
- return null;
-}
-
function MakeTimer({ getValue, row: { original } }: CellContext) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const cellValue = (getValue() as number | null) ?? 0;
diff --git a/apps/client/src/features/editors/Editor.module.scss b/apps/client/src/features/editors/Editor.module.scss
index acdd490c0..3d569eb5a 100644
--- a/apps/client/src/features/editors/Editor.module.scss
+++ b/apps/client/src/features/editors/Editor.module.scss
@@ -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;
diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss
new file mode 100644
index 000000000..0a72d0d8b
--- /dev/null
+++ b/apps/client/src/features/operator/Operator.module.scss
@@ -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;
+}
diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx
new file mode 100644
index 000000000..edee9d37b
--- /dev/null
+++ b/apps/client/src/features/operator/Operator.tsx
@@ -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;
+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(null);
+ const scrollRef = useRef(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 ;
+ }
+
+ // 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 (
+
+
+
+
+
+
+
+ {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 (
+
+ );
+ }
+
+ if (entry.type === SupportedEvent.Block) {
+ return
;
+ }
+ return null;
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/operator/follow-button/FollowButton.module.scss b/apps/client/src/features/operator/follow-button/FollowButton.module.scss
new file mode 100644
index 000000000..af4a9db25
--- /dev/null
+++ b/apps/client/src/features/operator/follow-button/FollowButton.module.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/apps/client/src/features/operator/follow-button/FollowButton.tsx b/apps/client/src/features/operator/follow-button/FollowButton.tsx
new file mode 100644
index 000000000..8a4875e6b
--- /dev/null
+++ b/apps/client/src/features/operator/follow-button/FollowButton.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/client/src/features/operator/operator-block/OperatorBlock.module.scss b/apps/client/src/features/operator/operator-block/OperatorBlock.module.scss
new file mode 100644
index 000000000..8bdb86ce4
--- /dev/null
+++ b/apps/client/src/features/operator/operator-block/OperatorBlock.module.scss
@@ -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;
+ }
+}
diff --git a/apps/client/src/features/operator/operator-block/OperatorBlock.tsx b/apps/client/src/features/operator/operator-block/OperatorBlock.tsx
new file mode 100644
index 000000000..fcadbba8f
--- /dev/null
+++ b/apps/client/src/features/operator/operator-block/OperatorBlock.tsx
@@ -0,0 +1,13 @@
+import { memo } from 'react';
+
+import style from './OperatorBlock.module.scss';
+
+interface OperatorBlockProps {
+ title: string;
+}
+
+function OperatorBlock({ title }: OperatorBlockProps) {
+ return {title}
;
+}
+
+export default memo(OperatorBlock);
diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss
new file mode 100644
index 000000000..ce696aee8
--- /dev/null
+++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss
@@ -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';
+}
diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx
new file mode 100644
index 000000000..2fd06d21e
--- /dev/null
+++ b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx
@@ -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;
+}
+
+// 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 (
+
+
+ {cue}
+
+
+
{main}
+
+ {start} - {end}
+
+
+
{secondary}
+
+
+ {isSelected ? : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
+
+
+
+ {subscribed && (
+ <>
+ {subscribedAlias}
+ {subscribed}
+ >
+ )}
+
+
+ );
+}
+
+export default memo(OperatorEvent);
diff --git a/apps/client/src/features/operator/status-bar/StatusBar.module.scss b/apps/client/src/features/operator/status-bar/StatusBar.module.scss
new file mode 100644
index 000000000..30582f2f3
--- /dev/null
+++ b/apps/client/src/features/operator/status-bar/StatusBar.module.scss
@@ -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;
+ }
+}
diff --git a/apps/client/src/features/operator/status-bar/StatusBar.tsx b/apps/client/src/features/operator/status-bar/StatusBar.tsx
new file mode 100644
index 000000000..cf76d886f
--- /dev/null
+++ b/apps/client/src/features/operator/status-bar/StatusBar.tsx
@@ -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 ;
+ }, [playback]);
+
+ return (
+
+ {PlaybackIconComponent}
+
+ Time now
+ {timeNow}
+
+
+ Elapsed time
+ {elapsedTime}
+
+
+ Running timer
+ {runningTime}
+
+
+
{projectTitle}
+
+ Scheduled start
+ {getTimeStart()}
+
+
+ Scheduled end
+ {getTimeEnd()}
+
+
+ );
+}
diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx
index c18c30425..e4601d981 100644
--- a/apps/client/src/features/rundown/Rundown.tsx
+++ b/apps/client/src/features/rundown/Rundown.tsx
@@ -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(null);
const scrollRef = useRef(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,
- scrollRef: MutableRefObject,
- ) {
- 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, scrollRef as MutableRefObject);
- });
- }
-
- // eslint-disable-next-line -- the prompt seems incorrect
- }, [cursorRef?.current, scrollRef]);
-
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
diff --git a/apps/client/src/features/rundown/_blockMixins.scss b/apps/client/src/features/rundown/_blockMixins.scss
index 7e7ab0bfc..73d712581 100644
--- a/apps/client/src/features/rundown/_blockMixins.scss
+++ b/apps/client/src/features/rundown/_blockMixins.scss
@@ -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;
diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
index d15abe82f..251fe3423 100644
--- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
+++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
diff --git a/apps/client/src/theme/_ontimeColours.scss b/apps/client/src/theme/_ontimeColours.scss
index a9db62269..6ba850466 100644
--- a/apps/client/src/theme/_ontimeColours.scss
+++ b/apps/client/src/theme/_ontimeColours.scss
@@ -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;
diff --git a/apps/client/src/theme/_v2Styles.scss b/apps/client/src/theme/_v2Styles.scss
index c299fb8e8..adc90326b 100644
--- a/apps/client/src/theme/_v2Styles.scss
+++ b/apps/client/src/theme/_v2Styles.scss
@@ -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%;
}
-}
\ No newline at end of file
+}
diff --git a/apps/client/src/theme/ontimeButton.ts b/apps/client/src/theme/ontimeButton.ts
index ab50a341b..50442c6a8 100644
--- a/apps/client/src/theme/ontimeButton.ts
+++ b/apps/client/src/theme/ontimeButton.ts
@@ -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
},
};
diff --git a/apps/client/src/theme/ontimeRadio.ts b/apps/client/src/theme/ontimeRadio.ts
index 9a56d8d3b..bad8ba9c6 100644
--- a/apps/client/src/theme/ontimeRadio.ts
+++ b/apps/client/src/theme/ontimeRadio.ts
@@ -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
},
diff --git a/apps/client/src/theme/ontimeTextInputs.ts b/apps/client/src/theme/ontimeTextInputs.ts
index c135d589e..e64381c52 100644
--- a/apps/client/src/theme/ontimeTextInputs.ts
+++ b/apps/client/src/theme/ontimeTextInputs.ts
@@ -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: {
diff --git a/apps/electron/package.json b/apps/electron/package.json
index 6ea2ac7dd..2ce6bc507 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -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",
diff --git a/apps/server/package.json b/apps/server/package.json
index c43f35e5e..d9c0b25c0 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -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",
diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts
index 18f42952d..e23390c15 100644
--- a/apps/server/src/controllers/ontimeController.ts
+++ b/apps/server/src/controllers/ontimeController.ts
@@ -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);
diff --git a/apps/server/src/external/styles/override.css b/apps/server/src/external/styles/override.css
index 958388070..e4ab0deec 100644
--- a/apps/server/src/external/styles/override.css
+++ b/apps/server/src/external/styles/override.css
@@ -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;
}
diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts
index 09e9f39cc..56ee64473 100644
--- a/apps/server/src/services/rundown-service/RundownService.ts
+++ b/apps/server/src/services/rundown-service/RundownService.ts
@@ -206,7 +206,7 @@ export async function addEvent(eventData: Partial | Partial | Partial | Partial) {
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);
diff --git a/demo-db/db.json b/demo-db/db.json
index c67d51682..e27276933 100644
--- a/demo-db/db.json
+++ b/demo-db/db.json
@@ -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": {
diff --git a/e2e/tests/features/204-editor-crud.spec.ts b/e2e/tests/features/204-editor-crud.spec.ts
index df4ce03b3..dce7073c5 100644
--- a/e2e/tests/features/204-editor-crud.spec.ts
+++ b/e2e/tests/features/204-editor-crud.spec.ts
@@ -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');
});
diff --git a/e2e/tests/features/205-operator.spec.ts b/e2e/tests/features/205-operator.spec.ts
new file mode 100644
index 000000000..a58a0dbbe
--- /dev/null
+++ b/e2e/tests/features/205-operator.spec.ts
@@ -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();
+});
diff --git a/package.json b/package.json
index 28fb90ca2..210136c8c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "2.7.2",
+ "version": "2.8.2",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index 02a936e71..d86894c2f 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -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
diff --git a/packages/utils/src/cue-utils/cueUtils.ts b/packages/utils/src/cue-utils/cueUtils.ts
index e700b77b4..aecd9b702 100644
--- a/packages/utils/src/cue-utils/cueUtils.ts
+++ b/packages/utils/src/cue-utils/cueUtils.ts
@@ -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;
diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts
index c1efeb095..685a8ddf9 100644
--- a/packages/utils/src/rundown-utils/rundownUtils.ts
+++ b/packages/utils/src/rundown-utils/rundownUtils.ts
@@ -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
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fff533d3c..26c69ec68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
\ No newline at end of file