mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 02:43:50 +00:00
feat: operator view (#440)
* feat: operator view * chore: smoke test operator * refactor: small improvements from deepsource --------- Co-authored-by: arihanv <arihanvaranasi@gmail.com> Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -14,6 +14,7 @@ import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordin
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
||||
@@ -46,6 +47,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -63,7 +66,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -83,34 +85,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
}),
|
||||
);
|
||||
|
||||
// when selection moves, view should follow
|
||||
useEffect(() => {
|
||||
function scrollToComponent(
|
||||
componentRef: MutableRefObject<HTMLTableRowElement>,
|
||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
||||
) {
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (!followSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRef.current && tableContainerRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(
|
||||
selectedRef as MutableRefObject<HTMLTableRowElement>,
|
||||
tableContainerRef as MutableRefObject<HTMLDivElement>,
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
|
||||
}, [selectedRef.current, tableContainerRef.current, followSelected]);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
@@ -243,7 +217,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (row.original.id === selectedId) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('parseField()', () => {
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
expect(parseField('presenter', undefined)).toBe('');
|
||||
expect(parseField('presenter')).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
|
||||
+4
-4
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
|
||||
.actionIcon {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $active-colour;
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
color: $active-indicator;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $active-colour;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'
|
||||
import { EventData, Playback } from 'ontime-types';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import useEventData from '../../../common/hooks-query/useEventData';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
import PlaybackIcon from '../tableElements/PlaybackIcon';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import EditableCell from './tableElements/EditableCell';
|
||||
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
||||
}
|
||||
|
||||
function DelayIndicator(props: { delayValue: number }) {
|
||||
const { delayValue } = props;
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
interface PlaybackIconProps {
|
||||
state: Playback;
|
||||
}
|
||||
|
||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
const { state } = props;
|
||||
|
||||
// if timer is Pause or Armed
|
||||
let label = 'Timer Paused';
|
||||
let Icon = IoPause;
|
||||
|
||||
if (state === Playback.Roll) {
|
||||
label = 'Timer Rolling';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Play) {
|
||||
label = 'Timer Playing';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Stop) {
|
||||
label = 'Timer Stopped';
|
||||
Icon = IoStop;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||
<Icon />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
border-top: 1px solid $white-20;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: $gray-1200;
|
||||
background-color: $gray-1250;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
@use '../../../src/theme/v2Styles' as *;
|
||||
@use '../../../src/theme/ontimeColours' as *;
|
||||
|
||||
.operatorContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.operatorEvents {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding-top: 0.25rem;
|
||||
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 95vh;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
||||
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
import useEventData from '../../common/hooks-query/useEventData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
|
||||
const selectedOffset = 50;
|
||||
|
||||
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||
const { data: projectData, status: projectDataStatus } = useEventData();
|
||||
|
||||
const featureData = useOperator();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isAutomatedScroll = useRef(false);
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef: scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: selectedOffset,
|
||||
setScrollFlag: () => (isAutomatedScroll.current = true),
|
||||
});
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Operator';
|
||||
}, []);
|
||||
|
||||
// reset scroll if nothing is selected
|
||||
useEffect(() => {
|
||||
if (!featureData?.selectedEventId) {
|
||||
if (!lockAutoScroll) {
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
|
||||
|
||||
const handleOffset = () => {
|
||||
if (featureData.selectedEventId) {
|
||||
scrollToComponent();
|
||||
}
|
||||
setLockAutoScroll(false);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
// prevent considering automated scrolls as user scrolls
|
||||
if (isAutomatedScroll.current) {
|
||||
isAutomatedScroll.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRef?.current && scrollRef?.current) {
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
if (selectedRect && scrollerRect) {
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const missingData = !data || !userFields || !projectData;
|
||||
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
|
||||
|
||||
if (missingData || isLoading) {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
|
||||
const subscribedAlias = subscribe ? userFields[subscribe] : '';
|
||||
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||
|
||||
const operatorOptions = getOperatorOptions(userFields);
|
||||
let isPast = Boolean(featureData.selectedEventId);
|
||||
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
||||
|
||||
const firstEvent = getFirstEvent(data);
|
||||
const lastEvent = getLastEvent(data);
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
|
||||
<StatusBar
|
||||
projectTitle={projectData.title}
|
||||
playback={featureData.playback}
|
||||
selectedEventId={featureData.selectedEventId}
|
||||
firstStart={firstEvent?.timeStart}
|
||||
firstId={firstEvent?.id}
|
||||
lastEnd={lastEvent?.timeEnd}
|
||||
lastId={lastEvent?.id}
|
||||
/>
|
||||
|
||||
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
|
||||
{data.map((entry) => {
|
||||
if (isOntimeEvent(entry)) {
|
||||
const isSelected = featureData.selectedEventId === entry.id;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
|
||||
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={entry.id}
|
||||
colour={entry.colour}
|
||||
cue={entry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={entry.timeStart}
|
||||
timeEnd={entry.timeEnd}
|
||||
duration={entry.duration}
|
||||
delay={entry.delay}
|
||||
isSelected={isSelected}
|
||||
subscribed={subscribedData}
|
||||
subscribedAlias={subscribedAlias}
|
||||
showSeconds={showSeconds}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === SupportedEvent.Block) {
|
||||
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.followButton {
|
||||
position: relative;
|
||||
bottom: 12rem;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
background-color: $blue-700;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
border-radius: 99px;
|
||||
transition: bottom 1s;
|
||||
|
||||
&:active {
|
||||
transition: background-color $transition-time-action;
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
transition: bottom 1s;
|
||||
bottom: -50px;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.followButton {
|
||||
font-size: 1.25rem;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './FollowButton.module.scss';
|
||||
|
||||
interface FollowButtonProps {
|
||||
isVisible: boolean;
|
||||
onClickHandler: () => void;
|
||||
}
|
||||
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const { isVisible, onClickHandler } = props;
|
||||
|
||||
const classes = cx([style.followButton, !isVisible && style.hidden]);
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler} type='button'>
|
||||
<IoLocate />
|
||||
Follow
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: $gray-1350;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.block {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import style from './OperatorBlock.module.scss';
|
||||
|
||||
interface OperatorBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function OperatorBlock({ title }: OperatorBlockProps) {
|
||||
return <div className={style.block}>{title}</div>;
|
||||
}
|
||||
|
||||
export default memo(OperatorBlock);
|
||||
@@ -0,0 +1,111 @@
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
@mixin clock-size {
|
||||
font-size: calc(1rem - 2px);
|
||||
@media (min-width: $min-tablet) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
opacity: 1;
|
||||
border-top: 1px solid $white-1;
|
||||
padding-right: 0.5rem;
|
||||
color: $white-90;
|
||||
background-color: $gray-1300;
|
||||
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 1.25rem 1fr auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
"binder main schedule"
|
||||
"binder secondary running"
|
||||
"binder fields fields";
|
||||
|
||||
&.subscribed {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $red-700);
|
||||
}
|
||||
|
||||
&.past {
|
||||
border-top: 1px solid transparent;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
color: $section-white;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
position: relative;
|
||||
background-color: $gray-1050; // to override inline
|
||||
|
||||
.cue {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
width: 6em;
|
||||
|
||||
rotate: -90deg;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mainField {
|
||||
grid-area: main;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.secondaryField {
|
||||
grid-area: secondary;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
@include clock-size;
|
||||
grid-area: schedule;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.running {
|
||||
@include clock-size;
|
||||
grid-area: running;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.fields {
|
||||
grid-area: fields;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
color: $ui-black;
|
||||
margin: 0.25rem 0;
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--operator-highlight-override, $orange-600);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $orange-500
|
||||
}
|
||||
}
|
||||
|
||||
.fields::after {
|
||||
content: '\200b';
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { memo, RefObject } from 'react';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
interface OperatorEventProps {
|
||||
colour: string;
|
||||
cue: string;
|
||||
main: string;
|
||||
secondary: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay?: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: string;
|
||||
subscribedAlias: string;
|
||||
showSeconds: boolean;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
function OperatorEvent(props: OperatorEventProps) {
|
||||
const {
|
||||
colour,
|
||||
cue,
|
||||
main,
|
||||
secondary,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
delay,
|
||||
isSelected,
|
||||
subscribed,
|
||||
subscribedAlias,
|
||||
showSeconds,
|
||||
isPast,
|
||||
selectedRef,
|
||||
} = props;
|
||||
|
||||
const start = formatTime(timeStart, { showSeconds });
|
||||
const end = formatTime(timeEnd, { showSeconds });
|
||||
|
||||
const cueColours = colour && getAccessibleColour(colour);
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
isSelected ? style.running : null,
|
||||
subscribed ? style.subscribed : null,
|
||||
isPast ? style.past : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses} ref={selectedRef}>
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
|
||||
<span className={style.mainField}>{main}</span>
|
||||
<span className={style.schedule}>
|
||||
{start} - {end}
|
||||
</span>
|
||||
|
||||
<span className={style.secondaryField}>{secondary}</span>
|
||||
<span className={style.running}>
|
||||
<DelayIndicator delayValue={delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed && (
|
||||
<>
|
||||
<span className={style.field}>{subscribedAlias}</span>
|
||||
<span className={style.value}>{subscribed}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(OperatorEvent);
|
||||
@@ -0,0 +1,105 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
@mixin column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
box-shadow: $large-top-drawer-shadow;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2B timer3B";
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
column-gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playbackIcon {
|
||||
grid-area: playback;
|
||||
font-size: 2rem;
|
||||
color: $gray-700;
|
||||
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.timeNow {
|
||||
grid-area: timer1B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.elapsedTime {
|
||||
grid-area: timer2B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
grid-area: timer3B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 1.25rem;
|
||||
padding-left: 0.25rem;
|
||||
display: none;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
grid-area: timer2A;
|
||||
@include column;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
grid-area: timer3A;
|
||||
@include column;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.timer {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.statusBar {
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2A timer3A"
|
||||
"title title timer2B timer3B";
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarProps {
|
||||
projectTitle: string;
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
firstStart?: number;
|
||||
firstId?: string;
|
||||
lastEnd?: number;
|
||||
lastId?: string;
|
||||
}
|
||||
|
||||
export default function StatusBar(props: StatusBarProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
const timer = useTimer();
|
||||
|
||||
const getTimeStart = () => {
|
||||
if (firstStart === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (firstId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(firstStart);
|
||||
};
|
||||
|
||||
const getTimeEnd = () => {
|
||||
if (lastEnd === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (lastId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(lastEnd);
|
||||
};
|
||||
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
const PlaybackIconComponent = useMemo(() => {
|
||||
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||
}, [playback]);
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
{PlaybackIconComponent}
|
||||
<div className={styles.timeNow}>
|
||||
<span className={styles.label}>Time now</span>
|
||||
<span className={styles.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={styles.elapsedTime}>
|
||||
<span className={styles.label}>Elapsed time</span>
|
||||
<span className={styles.timer}>{elapsedTime}</span>
|
||||
</div>
|
||||
<div className={styles.runningTime}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<span className={styles.timer}>{runningTime}</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.title}>{projectTitle}</span>
|
||||
<div className={styles.startTime}>
|
||||
<span className={styles.label}>Scheduled start</span>
|
||||
<span className={styles.timer}>{getTimeStart()}</span>
|
||||
</div>
|
||||
<div className={styles.endTime}>
|
||||
<span className={styles.label}>Scheduled end</span>
|
||||
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
||||
import { getFirst, getNext, getPrevious } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||
@@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) {
|
||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
function scrollToComponent(
|
||||
componentRef: MutableRefObject<HTMLDivElement>,
|
||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
||||
) {
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (cursorRef.current && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line -- the prompt seems incorrect
|
||||
}, [cursorRef?.current, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
|
||||
$block-clearance: 0.5rem;
|
||||
$block-border-radius: 0.5rem;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1200;
|
||||
$block-bg: $gray-1250;
|
||||
$block-bg2: $gray-1050; // for delay and blocks
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 2.5rem;
|
||||
|
||||
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
<span>
|
||||
<IoPeople
|
||||
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
|
||||
data-isPublic={isPublic}
|
||||
data-ispublic={isPublic}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
Reference in New Issue
Block a user