feat: edit groups

This commit is contained in:
Carlos Valente
2025-06-25 06:10:12 +02:00
committed by Carlos Valente
parent 4c08482258
commit 473f50b493
75 changed files with 916 additions and 511 deletions
@@ -0,0 +1,226 @@
@use '../blockMixins' as *;
$skip-opacity: 0.2;
.rundownEvent {
@include block-styling;
background-color: $block-bg;
margin-block: 0.25rem;
display: grid;
grid-template-areas:
'binder ... ... ...'
'binder pb-actions times chip'
'binder pb-actions title title'
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem 1fr auto;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center;
padding-right: $block-clearance;
gap: 2px;
row-gap: 0.25rem;
transition-property: background-color;
transition-duration: $transition-time-feedback;
@mixin declare-overrides() {
--status-color-override: #{$gray-200};
--status-color-active-override: #{$green-400};
}
&.loaded {
background-color: $gray-1325;
}
&.play {
background-color: $active-green;
@include declare-overrides;
}
&.roll {
background-color: $blue-700;
@include declare-overrides;
}
&.pause {
background-color: rgba($ontime-paused, 0.6);
@include declare-overrides;
}
&.selected {
outline: 1px solid $block-selected-color;
}
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.past:not(.skip) {
.timerNote,
.statusElements,
.eventTitle,
.eventNote,
.eventTimers,
.eventStatus,
.playbackActions,
.binder {
opacity: 0.4;
}
}
&.skip {
border: 1px solid $white-3;
.timerNote,
.eventTitle,
.eventNote,
.binder,
.eventTimers,
.eventStatus {
opacity: $skip-opacity;
}
}
}
.binder {
grid-area: binder;
height: 100%;
display: grid;
place-content: center;
position: relative;
cursor: pointer;
border-radius: $block-border-radius 0 0 $block-border-radius;
background-color: $gray-1050; // to override inline
color: $section-white;
font-size: 1rem;
.drag {
@include drag-style;
position: absolute;
margin-top: 0.25rem;
}
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 5.5rem;
text-align: center;
font-weight: 600;
letter-spacing: 0.5px;
rotate: -90deg;
}
}
.playbackActions {
grid-area: pb-actions;
align-self: flex-start;
display: flex;
flex-direction: column;
margin: 0 0.5rem;
gap: 0.25rem;
}
.eventTimers {
grid-area: times;
display: flex;
align-items: center;
gap: $block-clearance;
height: 100%;
}
.chipSection {
grid-area: chip;
}
.titleSection {
grid-area: title;
display: flex;
align-items: center;
justify-content: space-between;
.nextTag {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
}
.eventTitle {
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
flex: 1;
}
}
.progressBg {
grid-area: progb;
border-radius: 1px;
background-color: $gray-1100;
opacity: 1;
height: 100%;
overflow: hidden; /* clip foreground border radius*/
}
.progressBg.hidden {
opacity: 0;
}
.flip {
transform: rotateY(180deg);
}
.statusElements {
grid-area: estatus;
display: grid;
grid-template-areas:
'notes status'
'progb progb';
gap: 2px;
grid-template-rows: auto 0.25rem;
align-items: center;
height: 100%;
padding: 2px 0;
}
.eventNote {
grid-area: notes;
display: block;
font-size: calc(1rem - 3px);
line-height: 1em;
color: $block-text-color;
max-width: 35rem;
// allow multi-line text but trim before
white-space: pre-line;
}
.eventStatus {
grid-area: status;
align-self: flex-end;
display: flex;
justify-content: flex-end;
align-items: flex-end;
gap: 0.5rem;
color: var(--status-color-override, $gray-500);
.statusIcon {
width: 1rem;
height: 1rem;
}
.statusIcon.active {
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.disabled {
color: $gray-1000;
}
}
@@ -0,0 +1,298 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
IoAdd,
IoDuplicateOutline,
IoFolder,
IoLink,
IoReorderTwo,
IoSwapVertical,
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import RundownEventInner from './RundownEventInner';
import RundownIndicators from './RundownIndicators';
import style from './RundownEvent.module.scss';
interface RundownEventProps {
eventId: EntryId;
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
colour: string;
isPast: boolean;
isNext: boolean;
skip: boolean;
parent: EntryId | null;
loaded: boolean;
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
gap: number;
isNextDay: boolean;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
hasTriggers: boolean;
}
export default function RundownEvent({
eventId,
cue,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
eventIndex,
endAction,
timerType,
title,
note,
delay,
colour,
isPast,
isNext,
skip = false,
parent,
loaded,
hasCursor,
playback,
isRolling,
gap,
isNextDay,
dayOffset,
totalGap,
isLinkedToLoaded,
actionHandler,
hasTriggers,
}: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [onContextMenu] = useContextMenu<HTMLDivElement>(
selectedEvents.size > 1
? [
{
label: 'Link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: 'true',
}),
},
{
label: 'Unlink from previous',
icon: IoUnlink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: null,
}),
},
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
label: 'Toggle link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: linkStart,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
{ withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
],
);
const {
isDragging,
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: eventId,
data: {
type: 'event',
parent,
},
animateLayoutChanges: () => false,
});
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
const binderColours = colour && getAccessibleColour(colour);
// move focus to element if necessary
useEffect(() => {
if (!hasCursor || handleRef?.current == null) {
return;
}
const elementInFocus = document.activeElement;
// we know the block is the grandparent of our binder
const blockElement = handleRef.current.closest('#event-block');
// we only move focus if the block doesnt already contain focus
if (blockElement && !blockElement.contains(elementInFocus)) {
handleRef.current.focus();
}
}, [hasCursor]);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 1,
},
);
const handleRefCurrent = handleRef.current;
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [handleRef]);
const isSelected = selectedEvents.has(eventId);
const blockClasses = cx([
style.rundownEvent,
skip ? style.skip : null,
isPast ? style.past : null,
loaded ? style.loaded : null,
playback ? style[playback] : null,
isSelected ? style.selected : null,
hasCursor ? style.hasCursor : null,
]);
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
// event.button === 2 is a right-click
// disable selection if the user selected events and right clicks
// so the context menu shows up
if (selectedEvents.size > 1 && event.button === 2) {
return;
}
// UI indexes are 1 based
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode });
};
return (
<div
className={blockClasses}
ref={setNodeRef}
style={dragStyle}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
id='event-block'
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<span className={style.cue}>{cue}</span>
</div>
{isVisible && (
<RundownEventInner
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
linkStart={linkStart}
countToEnd={countToEnd}
timeStrategy={timeStrategy}
eventId={eventId}
eventIndex={eventIndex}
endAction={endAction}
timerType={timerType}
title={title}
note={note}
delay={delay}
isNext={isNext}
skip={skip}
loaded={loaded}
playback={playback}
isRolling={isRolling}
dayOffset={dayOffset}
isPast={isPast}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
hasTriggers={hasTriggers}
/>
)}
</div>
);
}
@@ -0,0 +1,191 @@
import { memo, useEffect, useState } from 'react';
import {
IoArrowDown,
IoArrowUp,
IoBan,
IoFlag,
IoFlash,
IoPlay,
IoPlayForward,
IoPlaySkipForward,
IoTime,
} from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import RundownEventChip from './composite/RundownEventChip';
import EventBlockPlayback from './composite/RundownEventPlayback';
import EventBlockProgressBar from './composite/RundownEventProgressBar';
import style from './RundownEvent.module.scss';
interface RundownEventInnerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
isNext: boolean;
skip: boolean;
loaded: boolean;
playback?: Playback;
isRolling: boolean;
dayOffset: number;
isPast: boolean;
totalGap: number;
isLinkedToLoaded: boolean;
hasTriggers: boolean;
}
export default memo(RundownEventInner);
function RundownEventInner({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
endAction,
timerType,
title,
note,
delay,
isNext,
skip = false,
loaded,
playback,
isRolling,
dayOffset,
isPast,
totalGap,
isLinkedToLoaded,
hasTriggers,
}: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false);
useEffect(() => {
setRenderInner(true);
}, []);
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
const playBtnStyles = { _hover: {} };
if (!skip && eventIsPlaying) {
playBtnStyles._hover = { bg: '#c05621' }; // $ontime-paused
} else if (!skip && !eventIsPlaying) {
playBtnStyles._hover = {};
}
return !renderInner ? null : (
<>
<div className={style.eventTimers}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
countToEnd={countToEnd}
/>
</div>
<div className={style.titleSection}>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>}
</div>
<EventBlockPlayback
eventId={eventId}
skip={skip}
isPlaying={eventIsPlaying}
isPaused={eventIsPaused}
loaded={loaded}
disablePlayback={skip || isRolling}
/>
{!skip && (
<RundownEventChip
className={style.chipSection}
id={eventId}
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='block-status' data-timertype={timerType}>
<span className={style.eventNote}>{note}</span>
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{loaded && <EventBlockProgressBar />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
<span>
<TimerIcon type={timerType} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
<span>
<EndActionIcon action={endAction} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`${countToEnd ? 'Count to End' : 'Count duration'}`} openDelay={tooltipDelayMid}>
<span>
<IoFlag className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
</span>
</Tooltip>
<Tooltip label='Event has Triggers' openDelay={tooltipDelayMid}>
<span>
<IoFlash className={`${style.statusIcon} ${hasTriggers ? style.active : style.disabled}`} />
</span>
</Tooltip>
</div>
</div>
</>
);
}
function EndActionIcon(props: { action: EndAction; className: string }) {
const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
if (action === EndAction.LoadNext) {
return <IoPlaySkipForward className={maybeActiveClasses} />;
}
if (action === EndAction.PlayNext) {
return <IoPlayForward className={maybeActiveClasses} />;
}
return <IoPlay className={className} />;
}
function TimerIcon(props: { type: TimerType; className: string }) {
const { type, className } = props;
if (type === TimerType.CountUp) {
return <IoArrowUp className={className} />;
}
if (type === TimerType.Clock) {
return <IoTime className={className} />;
}
if (type === TimerType.None) {
return <IoBan className={className} />;
}
return <IoArrowDown className={className} />;
}
@@ -0,0 +1,29 @@
/** binder + gap + pb-actions */
$gap-left: calc(2rem + 0.25rem + 3rem);
.indicators {
font-size: calc(1rem - 5px);
position: absolute;
top: -1em;
z-index: $zindex-floating;;
margin-left: $gap-left;
display: flex;
gap: 0.5rem;
font-weight: 600;
}
@mixin indicator($bg-colour) {
padding: 0 0.5rem;
border-radius: 2px;
background-color: $bg-colour;
color: $ui-white;
}
.delay {
@include indicator($ontime-delay);
}
.gap {
@include indicator($blue-500);
}
@@ -0,0 +1,22 @@
import { formatDelay, formatGap } from './rundownEvent.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
isNextDay: boolean;
delay: number;
gap: number;
}
export default function RundownIndicators({ timeStart, delay, gap, isNextDay }: RundownIndicatorProps) {
const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay);
return (
<div className={style.indicators}>
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
{hasGap && <div className={style.gap}>{hasGap}</div>}
</div>
);
}
@@ -0,0 +1,10 @@
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02');
});
});
@@ -0,0 +1,21 @@
.chip {
background-color: $gray-1100;
white-space: nowrap;
font-size: calc(1rem - 3px);
color: $label-gray;
padding: 0.125rem 0.5rem;
border-radius: 2px;
&.over {
color: $ontime-delay-text;
}
&.under {
color: $playback-ahead;
}
&.due {
color: $warning-orange;
}
}
@@ -0,0 +1,140 @@
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { usePlayback } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './RundownEventChip.module.scss';
interface RundownEventChipProps {
id: string;
timeStart: number;
delay: number;
dayOffset: number;
isPast: boolean;
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
export default function RundownEventChip({
timeStart,
delay,
dayOffset,
isPast,
isLoaded,
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const { playback } = usePlayback();
if (isLoaded) {
return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates
}
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
}
if (playbackActive) {
// we extracted the component to avoid unnecessary calculations and re-renders
return (
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
<div className={className}>
<EventUntil
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
</div>
</Tooltip>
);
}
return null;
}
interface EventUntilProps {
timeStart: number;
delay: number;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
}
function EventUntil(props: EventUntilProps) {
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
}
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'ontime', 'Event finished ontime'];
}
const isOver = difference > 0;
const fullTimeValue = formatTime(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
if (!value) {
return null;
}
return (
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
<div className={cx([style.chip, style[overUnderStyle], className])}>
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</div>
</Tooltip>
);
}
@@ -0,0 +1,133 @@
import { memo, MouseEvent } from 'react';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import style from '../RundownEvent.module.scss';
const blockBtnStyle = {
size: 'sm',
};
type StyleVariant = {
'aria-label': string;
tooltip: string;
backgroundColor: string;
_hover: { backgroundColor?: string };
};
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface RundownEventPlaybackProps {
eventId: string;
skip: boolean;
isPlaying: boolean;
isPaused: boolean;
loaded: boolean;
disablePlayback: boolean;
}
export default memo(RundownEventPlayback);
function RundownEventPlayback({
eventId,
skip,
isPlaying,
isPaused,
loaded,
disablePlayback,
}: RundownEventPlaybackProps) {
const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => {
event.stopPropagation();
updateEntry({ id: eventId, skip: !skip });
};
const actionHandler = (event: MouseEvent) => {
event.stopPropagation();
// is playing -> pause
// is paused -> continue
// otherwise -> start
if (isPlaying) {
setEventPlayback.pause();
} else if (isPaused) {
setEventPlayback.start();
} else {
setEventPlayback.startEvent(eventId);
}
};
const load = (event: MouseEvent) => {
event.stopPropagation();
setEventPlayback.loadEvent(eventId);
};
const buttonVariant: Partial<StyleVariant> = {};
if (isPaused) {
// continue
buttonVariant['aria-label'] = 'Continue event';
buttonVariant.tooltip = 'Continue event';
buttonVariant.backgroundColor = '#339E4E';
buttonVariant._hover = { backgroundColor: '#339E4Eee' };
} else if (isPlaying) {
// pause
buttonVariant['aria-label'] = 'Pause event';
buttonVariant.tooltip = 'Pause event';
buttonVariant.backgroundColor = '#c05621';
buttonVariant._hover = { backgroundColor: '#c05621ee' };
} else {
// start
buttonVariant['aria-label'] = 'Start event';
buttonVariant.tooltip = 'Start event';
if (!disablePlayback) {
buttonVariant._hover = { backgroundColor: '#339E4E' };
}
}
return (
<div className={style.playbackActions}>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Skip event'
tooltip='Skip event'
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
backgroundColor={skip ? '#B20000' : undefined}
_hover={{ backgroundColor: '#FF7878' }}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={toggleSkip}
tabIndex={-1}
isDisabled={loaded}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Load event'
tooltip='Load event'
icon={<IoReload className={style.flip} />}
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={load}
tabIndex={-1}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Start event'
tooltip='Start event'
icon={!isPlaying ? <IoPlay /> : <IoPause />}
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
{...buttonVariant}
clickHandler={actionHandler}
tabIndex={-1}
/>
</div>
);
}
@@ -0,0 +1,9 @@
.progressBar {
height: 100%;
width: 0;
border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200;
}
@@ -0,0 +1,12 @@
import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() {
const timer = useTimer();
const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
}
@@ -0,0 +1,24 @@
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
// We show a next day warning even if there is no gap
return '(next day)';
}
return;
}
const gapString = formatDuration(Math.abs(gap), false);
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
}