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