Compare commits

..

21 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen 35347e8d63 Feat: Time until group and flag (#1708)
* refactor: split event data and state data

* refactor: calculate the start time insted of until

* cleanup

* send next flag expected start from server

* refactor: render

* fix test

* use same icon

* refactor: group duration

* Optimize (#1711)

* refactor: consolidate block, flag and end loading and expected times

* work on test

* cal end value

* lint

* fix test

* remove todo
2025-08-06 21:55:28 +02:00
Carlos Valente e37d2ce50f refactor: tweaks to timeline styles 2025-08-06 21:40:19 +02:00
Carlos Valente 49e923026e feat: expose planned start in operator 2025-08-06 21:40:19 +02:00
Carlos Valente f1a8db9649 feat: timer overview shows waiting timer 2025-08-06 21:40:19 +02:00
Carlos Valente dddeefd544 chore: rename projected > expected 2025-08-06 21:40:19 +02:00
Carlos Valente b4aaa8966f fix: insert into group 2025-08-06 21:40:19 +02:00
Carlos Valente ad9db4edd3 chore: add demo project 2025-08-06 21:40:19 +02:00
Carlos Valente fef9ed4fcc chore: update icon assets 2025-08-06 21:40:19 +02:00
Carlos Valente 9ec47eda6e feat: expose block target duration in header 2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen 32edf3404c Fix tag ws api (#1709)
* fix: ws api should also reten tag and not type

* fix: when starting by cue try finding the next first then check before
2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen bcf80f9d77 chore: rename offset to offsetAbs and offsetRel (#1706) 2025-08-06 21:40:19 +02:00
Carlos Valente df137402cf bump version to 4.0.0-alpha.2 2025-08-06 21:40:19 +02:00
Carlos Valente daddb7f7b7 fix: open group when selecting from finder 2025-08-06 21:40:19 +02:00
Carlos Valente 82e8b9d66a fix: cleanup behaviour for rundown add buttons 2025-08-06 21:40:19 +02:00
Carlos Valente 765d5d97cf refactor: readonly inputs in cuesheet 2025-08-06 21:40:19 +02:00
Carlos Valente ed4d3ba265 refactor: remove toggle link context menu action 2025-08-06 21:40:19 +02:00
Carlos Valente a358ec0f17 refactor: small style tweaks
refactor: consistent font size on inputs

refactor: style tweaks for block spacing

refactor: improve skipped styles

fix: improve responsiveness in extracted rundown

refactor: style tweaks to overview

refactor: simplify group target duration
2025-08-06 21:40:19 +02:00
Carlos Valente 524721002a fix: prevent nesting groups 2025-08-06 21:40:19 +02:00
Carlos Valente 9ab54edbf5 refactor: small code cleanups
- remove compiler directives
2025-08-06 21:40:19 +02:00
Carlos Valente 8a7f8c8ebc refactor: small style tweaks
- cursor styles on dnd
2025-08-06 21:40:19 +02:00
Carlos Valente 3ce539af2a fix: rendering if countdown in safari 2025-08-06 21:40:19 +02:00
114 changed files with 1695 additions and 1651 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.1",
"version": "4.0.0-alpha.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.1",
"version": "4.0.0-alpha.2",
"private": true,
"type": "module",
"dependencies": {
@@ -10,7 +10,12 @@
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
background-color: $gray-1100;
}
@@ -25,9 +30,8 @@
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
&:read-only::placeholder {
opacity: 0;
}
}
@@ -9,9 +9,13 @@
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) {
background-color: $gray-1100;
}
@@ -26,21 +30,21 @@
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
&:read-only::placeholder {
opacity: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-top: 0.5em;
padding-inline: 0.5em;
padding-top: 0.25em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.5em;
padding-top: 0.25em;
}
.fluid {
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
submitHandler: (field: T, value: number) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
return false;
}
submitHandler(name, newValue);
submitHandler(name, valueInMillis);
return true;
},
[name, submitHandler, time],
@@ -5,12 +5,17 @@
}
.interfaces {
padding: 0.5rem 1rem;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -30,8 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
{nif.address} <IoArrowUp className={style.goIcon} />
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
);
})}
@@ -14,7 +14,6 @@
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
@@ -57,7 +56,7 @@
}
.popup {
font-size: calc(1rem - 2px);
font-size: 1rem;
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
@@ -22,6 +22,11 @@
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&[data-disabled] {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
.medium {
@@ -50,4 +55,5 @@
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -56,6 +56,7 @@ export function useFlatRundown() {
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
@@ -64,7 +65,7 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRundown, status };
return { data: flatRundown, rundownId: data.id, status };
}
/**
@@ -693,6 +693,7 @@ export const useEntryActions = () => {
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
}
},
[reorderEntryMutation],
+8 -8
View File
@@ -151,7 +151,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({
nextFlag: state.nextFlag,
id: state.nextFlag?.id ?? null,
expectedStart: state.nextFlag?.expectedStart ?? null,
}));
/** Used by the progress bar components */
@@ -175,24 +176,23 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
blockStartedAt: state.blockNow?.startedAt ?? null,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
offset: state.runtime.offsetAbs,
}));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
clock: state.clock,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
@@ -200,7 +200,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
offset: state.runtime.offsetAbs,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
+2 -2
View File
@@ -19,7 +19,7 @@ export function getOffsetText(offset: MaybeNumber): string {
}
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
if (offset === null) return null;
if (offset === 0) return 'muted';
if (offset === null) return 'muted';
if (offset === 0) return null;
return offset < 0 ? 'over' : 'under';
}
+14 -9
View File
@@ -1,7 +1,7 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import {
calculateTimeUntilStart,
formatFromMillis,
getExpectedStart,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
@@ -9,7 +9,7 @@ import {
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket';
import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
/**
@@ -105,8 +105,7 @@ export const formatTime = (
/**
* Handles case for formatting a duration time
* @param duration
* @returns
* eg: "1h 0m 0s" or "0h 30m"
*/
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
@@ -134,18 +133,24 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
/**
*
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns
*/
export function useTimeUntilStart(
export function useTimeUntilExpectedStart(
// typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> | null,
state: {
totalGap: number;
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
const { offset, currentDay, offsetMode, actualStart, plannedStart, clock } = useExpectedStartData();
if (event === null) return 0;
const expectedStart = getExpectedStart(
{ ...event },
{ ...state, currentDay, offset, offsetMode, actualStart, plannedStart },
);
return expectedStart - clock;
}
@@ -31,7 +31,6 @@ export default function TriggerForm({
onCancel,
postSubmit,
}: TriggerFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const {
handleSubmit,
register,
@@ -1,5 +1,3 @@
'use no memo'; // RHF and react-compiler don't seem to get along
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
@@ -34,7 +32,6 @@ interface GenerateLinkFormOptions {
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
@@ -32,7 +32,6 @@ export default function CustomFieldForm({
initialLabel,
initialKey,
}: CustomFieldsFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data } = useCustomFields();
// we use this to force an update
@@ -31,7 +31,6 @@ export default function ImportMapForm({
onSubmitExport,
onSubmitImport,
}: ImportMapFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
@@ -23,7 +23,6 @@ type ProjectCreateFormValues = {
};
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [error, setError] = useState<string | null>(null);
const {
@@ -20,7 +20,6 @@ interface ProjectFormProps {
}
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const {
handleSubmit,
register,
@@ -28,7 +28,6 @@ type ProjectMergeFormValues = {
};
export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFromProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -16,7 +16,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput';
export default function GeneralSettings() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, status, refetch } = useSettings();
const {
handleSubmit,
@@ -18,7 +18,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import style from './SettingsPanel.module.scss';
export default function ProjectData() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, status } = useProjectData();
const { updateProjectData } = useUpdateProjectData();
@@ -18,7 +18,6 @@ import CodeEditorModal from './composite/StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettings() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, isPending, mutateAsync } = useViewSettings();
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure();
@@ -20,7 +20,6 @@ interface QuickStartProps {
}
export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
const {
@@ -1,5 +1,3 @@
import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import AddTime from './add-time/AddTime';
@@ -14,7 +12,7 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback}>
<PlaybackTimer>
<AddTime playback={data.playback} />
</PlaybackTimer>
<PlaybackButtons
@@ -11,10 +11,6 @@ import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
interface PlaybackTimerProps {
playback: Playback;
}
function resolveAddedTimeLabel(addedTime: number) {
if (addedTime > 0) {
return `Added ${formatDuration(addedTime, false)}`;
@@ -27,11 +23,10 @@ function resolveAddedTimeLabel(addedTime: number) {
return '';
}
export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProps>) {
const { playback, children } = props;
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
const isRolling = playback === Playback.Roll;
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
@@ -52,7 +47,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
)}
</div>
{children}
@@ -65,9 +60,7 @@ interface RunningStatusProps {
expectedFinish: MaybeNumber;
playback: Playback;
}
function RunningStatus(props: RunningStatusProps) {
const { startedAt, expectedFinish, playback } = props;
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
@@ -35,7 +35,7 @@ export default function Operator() {
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast } = useOperatorOptions();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -160,6 +160,7 @@ export default function Operator() {
isSelected={isSelected}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -208,6 +209,7 @@ export default function Operator() {
isSelected={isSelected}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,3 +1,4 @@
@use '@/theme/viewerDefs' as *;
@import '../Operator.module.scss';
.event {
@@ -5,13 +6,14 @@
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $gray-1300;
background-color: $viewer-card-bg-color;
display: grid;
align-items: center;
grid-template-columns: 1.25rem 1fr auto;
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
row-gap: 0.5rem;
grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto;
grid-template-areas:
'binder main schedule'
'binder secondary running'
@@ -69,14 +71,23 @@
white-space: pre-line;
}
.plannedStart, .timeUntil {
line-height: 1em;
background-color: $gray-1000;
padding: 0.25rem 0.5rem;
border-radius: 1px;
}
.plannedStart {
font-size: 1.5rem;
margin-right: 0.5rem;
}
.timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule;
justify-self: end;
background-color: $gray-1000;
padding: 0.25rem 0.5rem;
border-radius: 1px;
}
.runningTime {
@@ -1,10 +1,10 @@
import { memo, RefObject, SyntheticEvent } from 'react';
import { useLongPress } from '@mantine/hooks';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilStart } from '../../../common/utils/time';
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import type { EditEvent, Subscribed } from '../operator.types';
@@ -24,6 +24,7 @@ interface OperatorEventProps {
isSelected: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean;
subscribed: Subscribed;
totalGap: number;
onLongPress: (event: EditEvent) => void;
@@ -44,6 +45,7 @@ function OperatorEvent({
isSelected,
isPast,
selectedRef,
showStart,
subscribed,
totalGap,
onLongPress,
@@ -64,9 +66,8 @@ function OperatorEvent({
const operatorClasses = cx([
style.event,
isSelected ? style.running : null,
subscribed ? style.subscribed : null,
isPast ? style.past : null,
isSelected && style.running,
isPast && style.past,
]);
return (
@@ -75,9 +76,11 @@ function OperatorEvent({
<span className={style.cue}>{cue}</span>
</div>
<span className={style.mainField}>{main}</span>
<span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>}
{main}
</span>
<span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule
timeStart={timeStart}
isPast={isPast}
@@ -159,7 +162,7 @@ interface TimeUntilProps {
}
function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: TimeUntilProps) {
// we isolate this to avoid unnecessary re-renders
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
@@ -67,6 +67,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'boolean',
defaultValue: false,
},
{
id: 'showStart',
title: 'Show planned start',
description: 'Whether to prepend the planned start to the items',
type: 'boolean',
defaultValue: false,
},
],
},
];
@@ -78,6 +85,7 @@ type OperatorOptions = {
subscribe: string[];
shouldEdit: boolean;
hidePast: boolean;
showStart: boolean;
};
/**
@@ -92,6 +100,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
subscribe: searchParams.getAll('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')),
};
}
@@ -3,15 +3,12 @@
top: 0;
width: 100%;
z-index: $zindex-floating;
background-color: $gray-1350;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
}
.timers {
padding-block: 0.75rem 0.5rem;
padding-inline: 1rem;
padding-inline: 1rem 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -20,35 +17,14 @@
.runningTimer {
grid-area: timers;
display: flex;
flex-direction: column;
justify-self: center;
color: $muted-gray;
}
.timeNow {
grid-area: clock;
display: flex;
flex-direction: column;
justify-self: right;
}
.label {
font-size: calc(1rem - 2px);
color: $gray-700;
line-height: 0.9em;
}
.timer {
font-size: 1.5rem;
letter-spacing: 0.5px;
line-height: 1.5;
}
.active {
color: $ui-white;
}
.progressOverride {
grid-area: bar;
height: 1rem;
@@ -1,33 +1,12 @@
import { isPlaybackActive } from 'ontime-utils';
import { ClockOverview, TimerOverview } from '../../overview/composite/TimeElements';
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import styles from './StatusBar.module.scss';
import style from './StatusBar.module.scss';
export default function StatusBarTimers() {
const timer = useTimer();
const { clock } = useClock();
const playbackActive = isPlaybackActive(timer.playback);
return (
<div className={styles.timers}>
<div className={styles.runningTimer}>
<span className={styles.label}>Running timer</span>
<RunningTime
className={cx([styles.timer, playbackActive && styles.active])}
value={timer.current}
hideLeadingZero
/>
</div>
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<ClockTime className={styles.timer} value={clock} />
</div>
<div className={style.timers}>
<TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} />
</div>
);
}
@@ -66,4 +66,4 @@
color: $active-indicator;
font-size: calc(1rem - 2px);
text-align: right;
}
}
@@ -1,6 +1,14 @@
import { useMemo } from 'react';
import { TbCalendar, TbCalendarClock, TbCalendarDown, TbCalendarStar, TbFlagDown, TbFlagStar } from 'react-icons/tb';
import { isOntimeBlock, OntimeBlock, OntimeEvent, TimerType } from 'ontime-types';
import {
TbCalendarClock,
TbCalendarPin,
TbCalendarStar,
TbFlagPin,
TbFlagStar,
TbFolderPin,
TbFolderStar,
} from 'react-icons/tb';
import { OntimeBlock, OntimeEvent, TimerPhase, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
@@ -15,7 +23,7 @@ import {
import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime, useTimeUntilStart } from '../../../common/utils/time';
import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout';
@@ -36,7 +44,7 @@ export function StartTimes() {
<div className={style.row}>
<span className={style.label}>Start</span>
<div className={style.labelledElement}>
<Tooltip text='Planned start time' render={<TbCalendar className={style.icon} />} />
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
</div>
<div className={style.labelledElement}>
@@ -47,7 +55,7 @@ export function StartTimes() {
<div className={style.row}>
<span className={style.label}>End</span>
<div className={style.labelledElement}>
<Tooltip text='Planned end time' render={<TbCalendar className={style.icon} />} />
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
@@ -88,50 +96,31 @@ export function MetadataTimes() {
);
}
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
function GroupTimes() {
const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { currentBlockId } = useCurrentBlockId();
const entry = useEntry(currentBlockId);
const group = useEntry(currentBlockId) as OntimeBlock | null;
if (!currentBlockId) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Group</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
// the group end time dose not encode any day offsets
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
const remainingBlockDuration = (() => {
if (blockStartedAt === null || !entry) return timerPlaceholder;
if (!isOntimeBlock(entry)) return timerPlaceholder;
return formattedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown);
})();
const expectedGroupEnd = blockExpectedEnd !== null ? blockExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
const timeUntilBlockEnd = (() => {
if (blockExpectedEnd === null) return timerPlaceholder;
return formattedTime(blockExpectedEnd - clock, 3, TimerType.CountDown);
})();
const groupTitle = (entry as OntimeBlock | null)?.title || 'Group';
const groupTitle = group?.title ?? null;
return (
<div className={style.metadataRow}>
<span className={style.labelTitle}>{groupTitle}</span>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, blockStartedAt === null && style.muted])}>{remainingBlockDuration}</span>
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{timeUntilBlockEnd}</span>
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
</div>
</div>
);
@@ -139,50 +128,27 @@ function GroupTimes() {
function FlagTimes() {
const { clock } = useClock();
const { nextFlag } = useNextFlag();
const entry = useEntry(nextFlag?.id ?? null);
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
// TODO(v4): can we make a good approximation of time until next flag?
const timeUntil = useTimeUntilStart({
timeStart: nextFlag?.start ?? 0,
delay: 0,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: true,
});
const plannedFlagStart = entry ? entry.timeStart - clock : null;
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
if (!nextFlag) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Flag</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown);
const muted = nextFlag === null;
const flagTitle = (entry as OntimeEvent | null)?.title || 'Flag';
const timeToNextFlag = nextFlag.start - clock;
const display = millisToString(timeToNextFlag, { fallback: timerPlaceholder });
const timeUntilDisplay = millisToString(timeUntil, { fallback: timerPlaceholder });
const title = entry?.title ?? null;
return (
<div className={style.metadataRow}>
<span className={cx([style.labelTitle])}>{flagTitle}</span>
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time])}>{display}</span>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span className={cx([style.time, !entry && style.muted])}>{plannedTimeUntilDisplay}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, muted && style.muted])}>{timeUntilDisplay}</span>
<span className={cx([style.time, expectedTimeUntil === null && style.muted])}>{expectedTimeUntilDisplay}</span>
</div>
</div>
);
@@ -194,7 +160,7 @@ export function ProgressOverview() {
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
return <TimeColumn label='Progress' value={progressText} muted={selectedEventIndex === null} />;
return <TimeColumn label='Progress' value={progressText} state={selectedEventIndex === null ? 'muted' : 'active'} />;
}
export function OffsetOverview() {
@@ -202,22 +168,29 @@ export function OffsetOverview() {
const isPlaying = isPlaybackActive(playback);
const correctedOffset = offset * -1;
const offsetState = getOffsetState(offset);
const offsetState = getOffsetState(isPlaying ? offset : null);
const offsetText = getOffsetText(isPlaying ? correctedOffset : null);
return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
}
export function ClockOverview() {
export function ClockOverview({ className }: { className?: string }) {
const { clock } = useClock();
return <TimeColumn label='Time now' value={formattedTime(clock)} />;
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
}
export function TimerOverview() {
const { current } = useTimer();
export function TimerOverview({ className }: { className?: string }) {
const timer = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
const isWaiting = timer.phase === TimerPhase.Pending;
const title = isWaiting ? 'Count to start' : 'Running timer';
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
const timerState = (() => {
if (isWaiting) return 'waiting';
if (timer.current === null) return 'muted';
return 'active';
})();
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
return <TimeColumn label={title} value={display} state={timerState} className={className} />;
}
@@ -9,14 +9,11 @@
letter-spacing: 0.5px;
min-width: 5em;
font-variant-numeric: tabular-nums;
color: $ui-white;
&::after {
content: '\200b';
}
&.muted {
color: $muted-gray;
}
}
.column {
@@ -32,7 +29,12 @@
gap: 0.25rem;
}
&[data-state="over"] {
&[data-state='waiting'] {
.label {
color: $ontime-roll;
}
}
&[data-state='over'] {
.label .over {
color: $playback-over;
}
@@ -41,7 +43,7 @@
}
}
&[data-state="under"] {
&[data-state='under'] {
.label .under {
color: $playback-under;
}
@@ -50,7 +52,7 @@
}
}
&[data-state="muted"] {
&[data-state='muted'] {
.clock {
color: $muted-gray;
}
@@ -5,17 +5,17 @@ import style from './TimeLayout.module.scss';
interface TimeLayoutProps {
label: string;
value: string;
muted?: boolean;
state?: 'muted' | 'waiting' | 'active';
daySpan?: number;
className?: string;
testId?: string;
}
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
export function TimeColumn({ label, value, state = 'active', className, testId }: TimeLayoutProps) {
return (
<div className={style.column}>
<div className={cx([style.column, className])} data-state={state}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
<span className={style.clock} data-testid={testId}>
{value}
</span>
</div>
+64 -31
View File
@@ -18,7 +18,6 @@ import {
type Rundown,
isOntimeBlock,
isOntimeEvent,
OntimeBlock,
OntimeEntry,
Playback,
SupportedEntry,
@@ -42,9 +41,9 @@ import { AppMode, sessionKeys } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import BlockEnd from './rundown-block/BlockEnd';
import RundownBlock from './rundown-block/RundownBlock';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownBlockEnd from './rundown-block/RundownBlockEnd';
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -335,14 +334,14 @@ export default function Rundown({ data }: RundownProps) {
return;
}
// prevent dropping a group inside another
if (active.data.current?.type === 'block' && !canDrop(over.data.current?.type, over.data.current?.parent)) {
return;
}
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
@@ -368,7 +367,16 @@ export default function Rundown({ data }: RundownProps) {
}
}
}
reorderEntry(active.id as EntryId, destinationId, order);
// keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData);
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => {
setSortableData(currentEntries);
});
};
/**
@@ -425,9 +433,6 @@ export default function Rundown({ data }: RundownProps) {
<div className={style.list}>
{isEditMode && <QuickAddButtons previousEventId={null} parentBlock={null} />}
{sortableData.map((entryId, index) => {
const isFirst = index === 0;
const isLast = index === sortableData.length - 1;
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
@@ -435,20 +440,23 @@ export default function Rundown({ data }: RundownProps) {
if (isBlockCollapsed) {
return null;
} else {
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddInline
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && (
<QuickAddButtons previousEventId={null} parentBlock={parentId} backgroundColor={parentColour} />
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
</Fragment>
);
}
// if the previous element is selected, it will have its own QuickAddInline
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentBlock={parentId}
backgroundColor={rundownMetadata.groupColour}
/>
)}
<RundownBlockEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
</Fragment>
);
}
// we iterate through a stateful copy of order to make the dnd operations smoother
@@ -456,6 +464,7 @@ export default function Rundown({ data }: RundownProps) {
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
@@ -478,13 +487,30 @@ export default function Rundown({ data }: RundownProps) {
*/
const blockColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === order.at(-1);
/**
* We need to provide the parent ID for the QuickAdd components
* This should be different depending on whether we are adding before or after an element
* - when adding before, we need to avoid a group referencing itself as the parent
* - when adding after, we can use the group ID directly to insert at the top of the group
*/
const parentIdForBefore =
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null;
const parentIdForAfter = rundownMetadata.groupId;
return (
<Fragment key={entry.id}>
{/**
* Before the entry
* - edit mode only
* - if there is a cursor
* - if it is not the first entry (the buttons would be there)
*/}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline
previousEventId={rundownMetadata.previousEntryId}
parentBlock={rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null}
/>
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentBlock={parentIdForBefore} />
)}
{isOntimeBlock(entry) ? (
<RundownBlock
@@ -525,8 +551,15 @@ export default function Rundown({ data }: RundownProps) {
</div>
</div>
)}
{isEditMode && hasCursor && rundownMetadata.groupEntries !== 0 && !isLast && (
<QuickAddInline previousEventId={entry.id} parentBlock={rundownMetadata.groupId} />
{/**
* After the entry
* - edit mode only
* - if there is a cursor
* - if it is not the last entry (the buttons would be there)
* - if the entry is not the block header
*/}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline previousEventId={entry.id} parentBlock={parentIdForAfter} />
)}
</Fragment>
);
@@ -37,7 +37,7 @@ function RundownExport() {
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.content}>
<div className={style.rundown}>
<ErrorBoundary>
<RundownContextMenu>
<RundownWrapper isSmallDevice />
@@ -1,25 +1,25 @@
import { useCallback } from 'react';
import { OntimeBlock } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import { MaybeNumber, OntimeBlock } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import NullableTimeInput from '../../../common/components/input/time-input/NullableTimeInput';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea';
import EntryEditorTextInput from './composite/EventTextInput';
import TargetDurationInput from './composite/TargetDurationInput';
import style from './EntryEditor.module.scss';
// title + colour + custom field labels
export type BlockEditorUpdateTextFields = 'targetDuration' | 'title' | 'colour' | string;
export type BlockEditorUpdateTextFields = 'title' | 'colour' | string;
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
interface BlockEditorProps {
@@ -31,7 +31,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields, value: string | boolean) => {
(field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
@@ -40,11 +40,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
}
if (field === 'targetDuration') {
if (value === '') {
return updateEntry({ id: block.id, targetDuration: null });
}
return updateEntry({ id: block.id, targetDuration: parseUserTime(value as string) });
return updateEntry({ id: block.id, targetDuration: value as MaybeNumber });
}
// all other strings are text fields
@@ -85,22 +81,22 @@ export default function BlockEditor({ block }: BlockEditorProps) {
</div>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<NullableTimeInput
name='targetDuration'
time={block.targetDuration}
submitHandler={handleSubmit}
emptyDisplay={enDash}
/>
</div>
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
<TextLikeInput offset={planOffsetLabel} className={style.textLikeInput} tabIndex={-1}>
<TextLikeInput
offset={planOffsetLabel}
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
tabIndex={-1}
>
{planOffset !== null && planOffset > 0 ? '+' : ''}
{millisToString(planOffset, { fallback: enDash })}
</TextLikeInput>
</div>
<TargetDurationInput
duration={block.duration}
targetDuration={block.targetDuration}
submitHandler={handleSubmit}
/>
</div>
</div>
@@ -94,16 +94,21 @@
/* approximating the style of a disabled input */
.textLikeInput {
background-color: rgba($gray-1200, 0.4);
font-weight: 400;
color: $gray-200;
border: 1px solid transparent;
justify-content: center;
background: transparent;
justify-content: start;
width: 7.5em;
border: 1px solid transparent;
border-bottom: 1px solid $gray-1100;
&:hover {
background-color: rgba($gray-1200, 0.4);
background: transparent;
}
}
.inactive {
color: $muted-gray;
}
.active {
color: $active-indicator;
}
@@ -0,0 +1,45 @@
import { IoLockClosed, IoLockOpenOutline } from 'react-icons/io5';
import { MaybeNumber } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import NullableTimeInput from '../../../../common/components/input/time-input/NullableTimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { cx, enDash } from '../../../../common/utils/styleUtils';
import TimeInputGroup from '../../time-input-flow/TimeInputGroup';
import style from '../EntryEditor.module.scss';
interface TargetDurationInputProps {
duration: MaybeNumber;
targetDuration: MaybeNumber;
submitHandler: (field: 'targetDuration', value: MaybeNumber) => void;
}
export default function TargetDurationInput({ duration, targetDuration, submitHandler }: TargetDurationInputProps) {
const isBlocked = targetDuration !== null;
return (
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<TimeInputGroup hasDelay={isBlocked && targetDuration !== duration}>
<NullableTimeInput
name='targetDuration'
time={targetDuration}
submitHandler={submitHandler}
emptyDisplay={enDash}
className={isBlocked ? '' : style.inactive}
/>
<Tooltip
text='Lock to target duration'
className={cx([style.timeAction, isBlocked && style.active])}
onClick={() => submitHandler('targetDuration', isBlocked ? null : duration)}
data-testid='lock__duration'
render={<IconButton variant='subtle-white' className={isBlocked ? style.active : style.inactive} />}
>
{isBlocked ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</TimeInputGroup>
</div>
);
}
@@ -3,7 +3,7 @@
.block {
@include block-styling;
margin-block: 1rem 0.25rem;
margin-block: 0.5rem;
display: grid;
grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
@@ -14,7 +14,7 @@
}
&.expanded {
margin-block: 1rem 0;
margin-block: 0.5rem 0;
border-radius: $block-border-radius $block-border-radius 0 0;
border-bottom: 0.25rem solid color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%);
}
@@ -32,7 +32,7 @@
&:focus {
outline: 1px solid $blue-500;
outline-offset: -1px;
outline-offset: -1px;
}
}
@@ -68,6 +68,16 @@
}
}
.strike {
text-decoration: line-through;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.drag {
@include drag-style;
position: absolute;
@@ -14,6 +14,7 @@ import { EntryId, OntimeBlock } from 'ontime-types';
import IconButton from '../../../common/components/buttons/IconButton';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
@@ -29,6 +30,7 @@ interface RundownBlockProps {
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
}
//TODO: the block should maybe include a multiple day indicator
export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) {
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
@@ -91,11 +93,24 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const [planOffset, planOffsetLabel] = (() => {
if (data.targetDuration === null) {
return [null, null];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 'under'];
}
return [offset < 0 ? `-${formatDuration(offset * -1)}` : `+${formatDuration(offset)}`, getOffsetState(offset * -1)];
})();
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'grab',
};
return (
@@ -139,7 +154,16 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
</div>
<div className={style.metaEntry}>
<div>Duration</div>
<div>{formatDuration(data.duration)}</div>
{planOffset === null ? (
<div className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}>
{formatDuration(data.duration)}
</div>
) : (
<div>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<span className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}>{planOffset}</span>
</div>
)}
</div>
<div className={style.metaEntry}>
<div>Entries</div>
@@ -6,5 +6,5 @@
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
@@ -1,14 +1,14 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import style from './BlockEnd.module.scss';
import style from './RundownBlockEnd.module.scss';
interface BlockEndProps {
id: string;
colour?: string;
}
export default function BlockEnd({ id, colour }: BlockEndProps) {
export default function RundownBlockEnd({ id, colour }: BlockEndProps) {
const {
attributes: dragAttributes,
listeners: dragListeners,
@@ -17,6 +17,9 @@ export default function BlockEnd({ id, colour }: BlockEndProps) {
transition,
} = useSortable({
id,
data: {
type: 'end-block',
},
animateLayoutChanges: () => false,
disabled: true, // we do not want to drag end blocks
});
@@ -37,6 +37,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -135,16 +135,6 @@ export default function RundownEvent({
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
type: 'item',
label: 'Toggle link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: linkStart,
}),
},
{
type: 'item',
label: flag ? 'Remove flag' : 'Add flag',
@@ -201,6 +191,7 @@ export default function RundownEvent({
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -6,7 +6,7 @@ import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { usePlayback } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilStart } from '../../../../common/utils/time';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
import style from './RundownEventChip.module.scss';
@@ -76,7 +76,7 @@ interface EventUntilProps {
function EventUntil(props: EventUntilProps) {
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
@@ -37,10 +37,10 @@ function RundownHeader() {
return (
<Toolbar.Root className={style.header}>
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.button}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.button}>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
@@ -48,10 +48,10 @@ function RundownHeader() {
<Editor.Separator className={style.separator} />
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.button}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.button}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
</ToggleGroup>
@@ -70,6 +70,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -158,15 +158,21 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
/**
* Checks whether a drop operation is valid
* Currently only used for validating dropping blocks
* Currently only used for validating dropping groups
*/
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
if (targetType === 'event' || targetType === 'delay') {
return targetParent === null;
export function canDrop(targetType?: SupportedEntry & 'end-block', targetParent?: EntryId | null): boolean {
// this would mean inserting a group inside another
if (targetType === 'end-block') {
return false;
}
// remaining events will be block or end-block
// we can swap places with other blocks
return targetType == 'block';
// this means swapping places with another group
if (targetType === 'block') {
return true;
}
// for all other cases, we just need to check if we are inside a group
return targetParent === null;
}
/**
@@ -1,31 +1,3 @@
.timeAction {
background: $gray-1050;
color: $gray-500;
cursor: pointer;
height: 2rem;
width: 2rem;
display: grid;
place-content: center;
aspect-ratio: 1;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
}
&:active:not(:disabled) {
background: $gray-1300;
}
&:focus {
outline: 1px solid $blue-500;
outline-offset: -1px;
}
&.active {
color: var(--status-color-active-override, $active-indicator);
}
}
.fourtyfive {
transform: rotate(-45deg);
}
@@ -36,28 +8,10 @@
font-size: 1.5em;
}
.inputGroup {
border: 1px solid transparent;
width: fit-content;
display: flex;
align-items: center;
border-radius: $component-border-radius-md;
&.delayed {
border: 1px solid $ontime-delay-text;
}
input {
max-width: 6.5em;
border-radius: $component-border-radius-md 0 0 $component-border-radius-md;
border: none;
padding-right: 0;
}
button {
width: 1.5rem;
height: 2rem;
border-radius: 0 $component-border-radius-md $component-border-radius-md 0;
border: none;
}
.inactive {
color: $muted-gray;
}
.active {
color: $active-indicator;
}
@@ -3,11 +3,13 @@ import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink
import { TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import TimeInputGroup from './TimeInputGroup';
import style from './TimeInputFlow.module.scss';
@@ -67,7 +69,7 @@ function TimeInputFlow({
<>
<div>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
@@ -78,17 +80,17 @@ function TimeInputFlow({
/>
<Tooltip
text='Link start to previous end'
className={cx([style.timeAction, linkStart && style.active])}
onClick={() => handleLink(!linkStart)}
render={<IconButton variant='subtle-white' className={linkStart ? style.active : style.inactive} />}
>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</Tooltip>
</div>
</TimeInputGroup>
</div>
<div>
{showLabels && <Editor.Label>End time</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
@@ -99,18 +101,18 @@ function TimeInputFlow({
/>
<Tooltip
text='Lock end'
className={cx([style.timeAction, isLockedEnd && style.active])}
render={<IconButton variant='subtle-white' className={isLockedEnd ? style.active : style.inactive} />}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</div>
</TimeInputGroup>
</div>
<div>
{showLabels && <Editor.Label>Duration</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='duration'
submitHandler={handleSubmit}
@@ -121,13 +123,13 @@ function TimeInputFlow({
/>
<Tooltip
text='Lock duration'
className={cx([style.timeAction, isLockedDuration && style.active])}
render={<IconButton variant='subtle-white' className={isLockedDuration ? style.active : style.inactive} />}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</div>
</TimeInputGroup>
</div>
{warnings.length > 0 && (
@@ -0,0 +1,25 @@
.inputGroup {
border: 1px solid transparent;
width: fit-content;
display: flex;
align-items: center;
border-radius: $component-border-radius-md;
&.delayed {
border: 1px solid $ontime-delay-text;
}
input {
max-width: 6.5em;
border-radius: $component-border-radius-md 0 0 $component-border-radius-md;
border: none;
padding-right: 0;
}
button {
width: 1.5rem;
height: 2rem;
border-radius: 0 $component-border-radius-md $component-border-radius-md 0;
border: none;
}
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import style from './TimeInputGroup.module.scss';
interface TimeInputGroupProps {
hasDelay?: boolean;
}
export default function TimeInputGroup({ hasDelay, children }: PropsWithChildren<TimeInputGroupProps>) {
return <div className={cx([style.inputGroup, hasDelay && style.delayed])}>{children}</div>;
}
+13
View File
@@ -114,6 +114,19 @@ table {
word-break: break-word;
}
// reset list styles
ul,
ol {
list-style: none;
margin: 0;
padding: 0;
}
li {
margin: 0;
padding: 0;
}
// remove buttons in number inputs
/* WebKit and Blink */
input::-webkit-outer-spin-button,
+1 -1
View File
@@ -28,7 +28,7 @@ $active-green: $green-700;
// playback colours
$playback-start: $green-600;
$ontime-roll: #0274B6;
$ontime-roll: #22a0e9;
$ontime-delay: #F57C13;
$ontime-delay-text: #E69056;
$ontime-paused: #c05621;
+2 -2
View File
@@ -7,8 +7,8 @@ export const langDe: TranslationObject = {
'common.next': 'Nächste',
'common.scheduled_start': 'Geplanter beginn',
'common.scheduled_end': 'Geplantes ende',
'common.projected_start': 'Erwartetes beginn',
'common.projected_end': 'Erwartetes ende',
'common.expected_start': 'Erwartetes beginn',
'common.expected_end': 'Erwartetes ende',
'common.stage_timer': 'Bühnen-Timer',
'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit',
+2 -2
View File
@@ -5,8 +5,8 @@ export const langEn = {
'common.next': 'Next',
'common.scheduled_start': 'Scheduled start',
'common.scheduled_end': 'Scheduled end',
'common.projected_start': 'Projected start',
'common.projected_end': 'Projected end',
'common.expected_start': 'Expected start',
'common.expected_end': 'Expected end',
'common.stage_timer': 'Stage Timer',
'common.started_at': 'Started At',
'common.time_now': 'Time now',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langEs: TranslationObject = {
'common.next': 'Siguiente',
'common.scheduled_start': 'Inicio programado',
'common.scheduled_end': 'Fin programado',
'common.projected_start': 'Inicio previsto',
'common.projected_end': 'Fin previsto',
'common.expected_start': 'Inicio previsto',
'common.expected_end': 'Fin previsto',
'common.stage_timer': 'Temporizador de presentador',
'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langFr: TranslationObject = {
'common.next': 'A suivre',
'common.scheduled_start': 'Début prévu',
'common.scheduled_end': 'Fin prévue',
'common.projected_start': 'Début projeté',
'common.projected_end': 'Fin projetée',
'common.expected_start': 'Début projeté',
'common.expected_end': 'Fin projetée',
'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langIt: TranslationObject = {
'common.next': 'Prossimo',
'common.scheduled_start': 'Inizio programmato',
'common.scheduled_end': 'Fine programmata',
'common.projected_start': 'Inizio previsto',
'common.projected_end': 'Fine prevista',
'common.expected_start': 'Inizio previsto',
'common.expected_end': 'Fine prevista',
'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langPt: TranslationObject = {
'common.next': 'Próximo',
'common.scheduled_start': 'Início programado',
'common.scheduled_end': 'Fim programado',
'common.projected_start': 'Início previsto',
'common.projected_end': 'Fim previsto',
'common.expected_start': 'Início previsto',
'common.expected_end': 'Fim previsto',
'common.stage_timer': 'Temporizador do presentador',
'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual',
@@ -24,11 +24,11 @@ interface ScheduleItemProps {
export default function ScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
const { showProjected } = useScheduleOptions();
const { showExpected } = useScheduleOptions();
if (showProjected) {
if (showExpected) {
return (
<ProjectedScheduleItem
<ExpectedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
@@ -95,34 +95,34 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
);
}
function ProjectedScheduleItem(props: ScheduleItemProps) {
function ExpectedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ProjectedTime time={timeStart} delay={delay} />
<ExpectedTime time={timeStart} delay={delay} />
<ProjectedTime time={timeEnd} delay={delay} />
<ExpectedTime time={timeEnd} delay={delay} />
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
interface OffsetTimeProps {
interface ExpectedTimeProps {
time: number;
delay: number;
}
function ProjectedTime(props: OffsetTimeProps) {
function ExpectedTime(props: ExpectedTimeProps) {
const { time, delay } = props;
const { offset } = useRuntimeOffset();
const projectedOffset = offset - delay;
const projectedTime = formatTime(time - offset, formatOptions);
const projectedState = getOffsetState(projectedOffset);
const expectedOffset = offset - delay;
const expectedTime = formatTime(time - offset, formatOptions);
const expectedState = getOffsetState(expectedOffset);
return <SuperscriptTime className={`entry-times--${projectedState}`} time={projectedTime} />;
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
}
@@ -24,9 +24,9 @@ export const scheduleOptions: ViewOption = {
defaultValue: 10,
},
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset.',
id: 'showExpected',
title: 'Show expected time',
description: 'Whether the times shown should account for the runtime offset.',
type: 'boolean',
defaultValue: false,
},
@@ -36,14 +36,14 @@ export const scheduleOptions: ViewOption = {
type ScheduleOptions = {
cycleInterval: number;
stopCycle: boolean;
showProjected: boolean;
showExpected: boolean;
};
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
return {
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
showProjected: isStringBoolean(searchParams.get('showProjected')),
showExpected: isStringBoolean(searchParams.get('showExpected')),
};
}
@@ -105,7 +105,6 @@ $item-height: 3.5rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem;
border-radius: $element-border-radius;
overflow: clip;
&:hover {
.sub__label {
@@ -33,7 +33,7 @@ export default function CountdownSubscriptions({
selectedId,
goToEditMode,
}: CountdownSubscriptionsProps) {
const { secondarySource, showProjected } = useCountdownOptions();
const { secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
const timeoutId = useRef<NodeJS.Timeout | null>(null);
@@ -97,8 +97,8 @@ export default function CountdownSubscriptions({
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
{showProjected ? (
<ProjectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
{showExpected ? (
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
) : (
<>
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
@@ -123,30 +123,30 @@ export default function CountdownSubscriptions({
);
}
interface ProjectedScheduleProps {
interface ExpectedScheduleProps {
timeStart: number;
timeEnd: number;
delay: number;
}
function ProjectedSchedule(props: ProjectedScheduleProps) {
function ExpectedSchedule(props: ExpectedScheduleProps) {
const { timeStart, timeEnd, delay } = props;
const { offset } = useRuntimeOffset();
// offset is negative if we are ahead
const projectedOffset = offset - delay;
const projectState = getOffsetState(projectedOffset);
const expectedOffset = offset - delay;
const expectedState = getOffsetState(expectedOffset);
return (
<>
<ClockTime
value={timeStart - projectedOffset}
className={`sub__schedule--${projectState}`}
value={timeStart - expectedOffset}
className={`sub__schedule--${expectedState}`}
preferredFormat12='h:mm'
preferredFormat24='HH:mm'
/>
<ClockTime value={timeEnd - projectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
);
}
@@ -161,7 +161,7 @@ function SubscriptionStatus({ time, event, selectedId }: SubscriptionStatusProps
const { getLocalizedString } = useTranslation();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showProjected } = useCountdownOptions();
const { showExpected } = useCountdownOptions();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
@@ -171,7 +171,7 @@ function SubscriptionStatus({ time, event, selectedId }: SubscriptionStatusProps
offset,
currentDay,
getLocalizedString('common.minutes'),
showProjected,
showExpected,
);
return (
@@ -36,9 +36,9 @@ export const getCountdownOptions = (
collapsible: true,
options: [
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset',
id: 'showExpected',
title: 'Show expected time',
description: 'Whether the times shown should account for the runtime offset.',
type: 'boolean',
defaultValue: false,
},
@@ -62,7 +62,7 @@ export const getCountdownOptions = (
type CountdownOptions = {
subscriptions: EntryId[];
secondarySource: keyof OntimeEvent | null;
showProjected: boolean;
showExpected: boolean;
};
/**
@@ -74,7 +74,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
return {
subscriptions: searchParams.getAll('sub') as EntryId[],
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
showProjected: isStringBoolean(searchParams.get('showProjected')),
showExpected: isStringBoolean(searchParams.get('showExpected')),
};
}
@@ -52,9 +52,9 @@ export function getSubscriptionDisplayData(
offset: number,
currentDay: number,
minutesString: string,
showProjected = false,
showExpected = false,
): { status: ProgressStatus; timer: string } {
const offsetAndDelay = showProjected ? offset + subscribedEvent.delay : 0;
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
if (selectedId === subscribedEvent.id) {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
@@ -139,7 +139,7 @@ export function getSubscriptionDisplayData(
};
}
// the event here has to be due, we show the countdown the projected start time
// the event here has to be due, we show the countdown the expected start time
return {
status: 'due',
timer: getFormattedTimer(
@@ -1,8 +1,8 @@
.imageInput {
&::placeholder {
&:not(:read-only)::placeholder {
opacity: 0.2;
}
&:hover::placeholder {
&:not(:read-only):hover::placeholder {
opacity: 1;
}
}
@@ -7,12 +7,13 @@ import style from './EditableImage.module.scss';
interface EditableImageProps {
initialValue: string;
readOnly?: boolean;
updateValue: (newValue: string) => void;
}
export default memo(EditableImage);
function EditableImage({ initialValue, updateValue }: EditableImageProps) {
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
const handleUpdate = (newValue: string) => {
if (newValue === initialValue) {
return;
@@ -35,6 +36,9 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
variant='ghosted'
className={style.imageInput}
fluid
readOnly={readOnly}
// we disable the field to prevent receiving focus
disabled={readOnly}
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
@@ -49,12 +53,14 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
return (
<div className={style.imageCell}>
<div className={style.overlay}>
<Button onClick={openInNewTab}>Preview</Button>
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
Delete
</Button>
</div>
{!readOnly && (
<div className={style.overlay}>
<Button onClick={openInNewTab}>Preview</Button>
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
Delete
</Button>
</div>
)}
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
</div>
);
@@ -18,6 +18,11 @@
&.skip {
position: relative;
.indexColumn {
text-decoration: line-through;
opacity: $opacity-disabled;
}
&::after {
content: '';
position: absolute;
@@ -0,0 +1,6 @@
.ghostedText {
min-height: 2rem;
padding: 0;
padding-top: 0.25em;
width: 100%;
}
@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import style from './GhostedText.module.scss';
export default function GhostedText({ children }: PropsWithChildren) {
return <div className={style.ghostedText}>{children}</div>;
}
@@ -15,13 +15,15 @@ interface SortableCellProps {
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
// build drag styles
const dragStyle = {
...injectedStyles,
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -10,6 +10,7 @@ import { AppMode } from '../../../../ontimeConfig';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
import FlagCell from './FlagCell';
import GhostedText from './GhostedText';
import MultiLineCell from './MultiLineCell';
import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
@@ -139,6 +140,11 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
return null;
}
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -155,8 +161,9 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
return null;
}
const canWrite = column.columnDef.meta?.canWrite;
const initialValue = event.custom[column.id];
return <EditableImage initialValue={initialValue} updateValue={update} />;
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
}
function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
@@ -173,6 +180,11 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
return null;
}
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -197,9 +209,15 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
return null;
}
// fields will not contain the field if there is no value set by the user
// entries will not contain the field if there is no value set by the user
// event if there is no initial value, we still render the cell
const initialValue = event.custom[column.id] ?? '';
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
@@ -4,7 +4,6 @@ import { SupportedEntry } from 'ontime-types';
import Input from '../../../common/components/input/input/Input';
import Modal from '../../../common/components/modal/Modal';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import useFinder from './useFinder';
@@ -16,10 +15,9 @@ interface FinderProps {
}
export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, results, error } = useFinder();
const { find, select, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
@@ -34,13 +32,15 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit();
}
};
const submit = () => {
const selectedEvent = results[selected];
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
select(selectedEvent);
onClose();
};
+189 -155
View File
@@ -1,7 +1,9 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId, isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
const maxResults = 12;
@@ -20,16 +22,198 @@ type FilterableEvent = {
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableBlock | FilterableEvent;
export default function useFinder() {
const { data } = useFlatRundown();
const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${rundownId}-editor-collapsed-groups`,
defaultValue: [],
});
/** Filters the rundown to a given evaluation */
const find = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeBlock(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Block,
id: event.id,
index: i,
title: event.title,
} satisfies FilterableBlock);
}
}
}
return { results, error: null };
}
},
[data],
);
const select = useCallback(
(selectedEvent: FilterableEntry) => {
// First expand the parent group if this is an event inside a group
if (selectedEvent.type === SupportedEntry.Event && selectedEvent.parent !== null) {
// Try direct state update instead of using callback
const currentGroups = [...new Set(collapsedGroups)];
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
// Force a direct update
setCollapsedGroups(newGroups);
}
// Then select the event
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
},
[collapsedGroups, setCollapsedGroups, setSelectedEvents],
);
/** clear results when source data changes */
useEffect(() => {
setResults([]);
@@ -38,157 +222,7 @@ export default function useFinder() {
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data]);
}, [data, find]);
/** Returns a single item with a matching index */
const searchByIndex = (searchString: string) => {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the cue field */
const searchByCue = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the title field*/
const searchByTitle = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeBlock(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Block,
id: event.id,
index: i,
title: event.title,
} satisfies FilterableBlock);
}
}
}
return { results, error: null };
};
/** Filters the rundown to a given evaluation */
const find = (event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
};
return { find, results, error };
return { find, select, results, error };
}
@@ -46,7 +46,7 @@ export default function StudioTimers({
time.phase === TimerPhase.Danger,
);
const offsetState = getOffsetState(runtime.offset);
const offsetState = getOffsetState(runtime.offsetAbs);
return (
<div className='studio__timers'>
@@ -63,7 +63,7 @@ export default function StudioTimers({
</div>
</div>
<div>
<div className='label right'>{getLocalizedString('common.projected_end')}</div>
<div className='label right'>{getLocalizedString('common.expected_end')}</div>
<div className='runtime-timer right'>{schedule.expectedEnd}</div>
</div>
</div>
@@ -103,7 +103,7 @@ export default function StudioTimers({
</div>
</div>
<div>
<div className='label right'>{getLocalizedString('common.projected_end')}</div>
<div className='label right'>{getLocalizedString('common.expected_end')}</div>
<div className='runtime-timer right'>{event.expectedEnd}</div>
</div>
</div>
@@ -6,7 +6,7 @@ import { formatTime } from '../../common/utils/time';
const timeFormat = { format12: 'h:mm a', format24: 'HH:mm' };
export function getFormattedScheduleTimes(runtime: Runtime) {
const correctedOffset = runtime.offset !== null ? runtime.offset * -1 : null;
const correctedOffset = runtime.offsetAbs !== null ? runtime.offsetAbs * -1 : null;
return {
actualStart: formatTime(runtime.actualStart, timeFormat),
expectedEnd: formatTime(runtime.expectedEnd, timeFormat),
@@ -84,6 +84,7 @@
.section-title__label {
text-transform: uppercase;
color: var(--label-color-override, $viewer-label-color);
}
.section-title__status {
@@ -54,7 +54,7 @@ export default function TimelinePage({ events, general, runtime, selectedId, set
let followedByStatus: string | undefined;
if (next !== null) {
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offset);
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offsetAbs);
if (timeToStart < 0) {
nextStatus = dueText;
} else {
@@ -63,7 +63,7 @@ export default function TimelinePage({ events, general, runtime, selectedId, set
}
if (followedBy !== null) {
const timeToStart = getTimeToStart(time.clock, followedBy.timeStart, followedBy?.delay ?? 0, runtime.offset);
const timeToStart = getTimeToStart(time.clock, followedBy.timeStart, followedBy?.delay ?? 0, runtime.offsetAbs);
if (timeToStart < 0) {
followedByStatus = dueText;
} else {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.0.0-alpha.1",
"version": "4.0.0-alpha.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -67,7 +67,7 @@
"runAfterFinish": false,
"installerIcon": "icon.ico",
"installerSidebar": "ontime-install.bmp",
"uninstallerSidebar": "ontime-uninstall.bmp"
"uninstallerSidebar": "ontime-install.bmp"
},
"linux": {
"target": "AppImage",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.0.0-alpha.1",
"version": "4.0.0-alpha.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+1 -1
View File
@@ -141,7 +141,7 @@ class SocketServer implements IAdapter {
if (reply) {
ws.send(
JSON.stringify({
type: tag,
tag,
payload: reply.payload,
}),
);
@@ -1,5 +1,7 @@
/* eslint-disable no-console -- we are mocking the console */
import { SupportedEntry } from 'ontime-types';
import { demoDb } from '../../../models/demoProject.js';
import { parseDatabaseModel } from '../db.parser.js';
@@ -22,9 +24,17 @@ describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
it('has 14 entries, with 6 events and 4 top level groups', () => {
expect(data.rundowns.default.order.length).toBe(4);
expect(Object.keys(data.rundowns.default.entries).length).toBe(14);
let numEntries = 0;
data.rundowns.default.flatOrder.forEach((entryId) => {
const entry = data.rundowns.default.entries[entryId];
if (entry.type === SupportedEntry.Event) {
numEntries++;
}
});
expect(numEntries).toBe(6);
});
it('is the same as the demo project since all data is valid', () => {
@@ -32,7 +42,33 @@ describe('test parseDatabaseModel() with demo project (valid)', () => {
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
// remove time-related fields from the comparison
// these are not calculated in the parser
Object.values(filteredDemoProject.rundowns.default.entries).forEach((entry: any) => {
if (entry.type === SupportedEntry.Block) {
delete entry.timeStart;
delete entry.timeEnd;
delete entry.duration;
delete entry.isFirstLinked;
}
});
Object.values(data.rundowns.default.entries).forEach((entry: any) => {
if (entry.type === SupportedEntry.Block) {
delete entry.timeStart;
delete entry.timeEnd;
delete entry.duration;
delete entry.isFirstLinked;
}
});
expect(data.automation).toMatchObject(filteredDemoProject.automation);
expect(data.customFields).toMatchObject(filteredDemoProject.customFields);
expect(data.project).toMatchObject(filteredDemoProject.project);
expect(data.rundowns).toMatchObject(filteredDemoProject.rundowns);
expect(data.settings).toMatchObject(filteredDemoProject.settings);
expect(data.urlPresets).toMatchObject(filteredDemoProject.urlPresets);
expect(data.viewSettings).toMatchObject(filteredDemoProject.viewSettings);
});
});
@@ -97,12 +97,12 @@ describe('processRundown()', () => {
it('generates metadata from given rundown', () => {
const initResult = processRundown(demoDb.rundowns.default, demoDb.customFields);
expect(initResult.order.length).toBe(12);
expect(initResult.flatEntryOrder.length).toBe(17);
expect(initResult.timedEventOrder.length).toBe(14);
expect(initResult.playableEventOrder.length).toBe(14);
expect(initResult.firstStart).toBe(36000000);
expect(initResult.lastEnd).toBe(61800000);
expect(initResult.order.length).toBe(4);
expect(initResult.flatEntryOrder.length).toBe(14);
expect(initResult.timedEventOrder.length).toBe(6);
expect(initResult.playableEventOrder.length).toBe(6);
expect(initResult.firstStart).toBe(MILLIS_PER_HOUR * 10);
expect(initResult.lastEnd).toBe(MILLIS_PER_HOUR * 14);
expect(initResult.totalDays).toBe(0);
expect(initResult.totalDelay).toBe(0);
});
@@ -723,11 +723,11 @@ export function processRundown(
// if the event is a block, we process the nested entries
// the code here is a copy of the processing of top level events
if (isOntimeBlock(processedEntry)) {
let totalBlockDuration = 0;
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
const blockEvents: EntryId[] = [];
processedEntry.duration = 0;
// check if the block contains nested entries
for (let j = 0; j < processedEntry.entries.length; j++) {
@@ -739,10 +739,7 @@ export function processRundown(
}
blockEvents.push(nestedEntry.id);
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
);
const { processedEntry: processedNestedEntry } = process(nestedEntry, processedEntry.id);
// we dont extract metadata of skipped events,
// if this is not a playable event there is nothing else to do
@@ -757,12 +754,14 @@ export function processRundown(
}
// lastEntry is the event with the latest end time
blockEndTime = processedNestedData.lastEnd;
totalBlockDuration += processedNestedEntry.duration;
blockEndTime = processedNestedEntry.timeEnd;
if (j > 0) {
processedEntry.duration += processedNestedEntry.gap;
}
processedEntry.duration = processedEntry.duration + processedNestedEntry.duration;
}
// update block metadata
processedEntry.duration = totalBlockDuration;
processedEntry.timeStart = blockStartTime;
processedEntry.timeEnd = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
@@ -118,18 +118,22 @@ export function parseRundown(
if (isOntimeEvent(nestedEvent)) {
newNestedEvent = createEvent(nestedEvent, eventIndex);
// skip if event is invalid
if (newNestedEvent == null) {
emitError?.('Skipping event without payload');
continue;
}
newNestedEvent.parent = event.id;
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
newNestedEvent.parent = event.id;
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
newNestedEvent.parent = event.id;
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
}
@@ -54,10 +54,10 @@ describe('safeMerge', () => {
expect(mergedData.project).toStrictEqual({
title: 'new title',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
description: demoDb.project.description,
url: demoDb.project.url,
info: 'new backstage info',
logo: null,
logo: demoDb.project.logo,
custom: [
{
title: 'new custom title',
+263 -483
View File
@@ -4,524 +4,311 @@ export const demoDb: DatabaseModel = {
rundowns: {
default: {
id: 'default',
title: 'Eurovision Demo',
order: [
'block',
'01e85',
'1c420',
'b7737',
'd3a80',
'8276c',
'2340b',
'cb90b',
'503c4',
'5e965',
'bab4a',
'd3eb1',
],
title: 'Demo project',
order: ['e2163f', '7eaf99', 'f60403', '6b0edb'],
flatOrder: [
'block',
'32d31',
'21cd2',
'0b371',
'3cd28',
'e457f',
'01e85',
'1c420',
'b7737',
'd3a80',
'8276c',
'2340b',
'cb90b',
'503c4',
'5e965',
'bab4a',
'd3eb1',
'e2163f',
'7eaf99',
'9bf60f',
'bf71a2',
'c2697f',
'fa593e',
'a8b0b3',
'f60403',
'0aaa7d',
'6b0edb',
'02afca',
'75ce86',
'e10ed9',
'07df89',
],
entries: {
block: {
type: SupportedEntry.Block,
entries: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'],
id: 'block',
title: 'Test Block',
note: '',
targetDuration: null,
colour: 'hotpink',
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
e2163f: {
id: 'e2163f',
type: SupportedEntry.Milestone,
cue: 'Demo',
title: 'Clear all, or Create New Project to start fresh',
note: 'Go to Settings cog wheel top left of Editor, Project > Create',
colour: '#9d9d9d',
custom: {
Song: 'Sekret',
Artist: 'Ronela Hajati',
Custom_Field:
'Create Custom Fields to store specific text information or images.\n\nGo to Settings > Project data > Custom fields',
Images: 'https://www.getontime.no/images/icons/ontime-logo.png',
},
parent: null,
revision: 0,
},
'32d31': {
type: SupportedEntry.Event,
flag: false,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
'7eaf99': {
id: '7eaf99',
type: SupportedEntry.Block,
title: 'Morning Sessions',
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
colour: '#339E4E',
custom: {},
revision: 0,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Sekret',
Artist: 'Ronela Hajati',
},
},
'21cd2': {
type: SupportedEntry.Event,
flag: false,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Eat Your Salad',
Artist: 'Citi Zeni',
},
},
'0b371': {
type: SupportedEntry.Event,
flag: false,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Sentimentai',
Artist: 'Monika Liu',
},
},
'3cd28': {
type: SupportedEntry.Event,
flag: false,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Boys Do Cry',
Artist: 'Marius Bear',
},
},
e457f: {
type: SupportedEntry.Event,
flag: false,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Disko',
Artist: 'LPS',
},
},
/// <----- BLOCK
'01e85': {
// TODO: this should be a marker type
type: SupportedEntry.Block,
id: '01e85',
title: 'Lunch break',
note: '',
colour: '',
entries: [],
targetDuration: null,
custom: {},
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
duration: 7200000,
isFirstLinked: false,
},
'1c420': {
'9bf60f': {
id: '9bf60f',
type: SupportedEntry.Event,
flag: false,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
title: 'Pre-show Countdown',
timeStart: 36000000,
timeEnd: 39600000,
duration: 3600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
note: 'Music plays, holding slide on screens',
colour: '#77C785',
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
cue: '1',
parent: '7eaf99',
revision: 0,
timeWarning: 600000,
timeDanger: 300000,
custom: {
Song: 'Stefania',
Artist: 'Kalush Orchestra',
Custom_Field: 'Put additional info here',
},
triggers: [],
},
b7737: {
bf71a2: {
id: 'bf71a2',
type: SupportedEntry.Milestone,
cue: 'Standby',
title: '10:45 - Presenters ready side stage',
note: '',
colour: '#A790F5',
revision: 0,
custom: {},
parent: '7eaf99',
},
c2697f: {
id: 'c2697f',
type: SupportedEntry.Event,
flag: false,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
title: 'Welcome',
timeStart: 39600000,
timeEnd: 40200000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: true,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
skip: false,
note: 'Emma Thompson',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
gap: 0,
cue: '1.1',
parent: '7eaf99',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
triggers: [],
},
fa593e: {
id: 'fa593e',
type: SupportedEntry.Event,
flag: true,
title: 'Session 1',
timeStart: 40200000,
timeEnd: 43200000,
duration: 3000000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: true,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
skip: false,
note: 'Liam Carter, Sophia Patel + PowerPoint',
colour: '#77C785',
delay: 0,
dayOffset: 0,
gap: 0,
cue: '1.2',
parent: '7eaf99',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
triggers: [],
},
a8b0b3: {
id: 'a8b0b3',
type: SupportedEntry.Milestone,
cue: 'House',
title: '11:30 - House staff setup lunch in lobby',
note: '',
colour: '#A790F5',
revision: 0,
custom: {},
parent: '7eaf99',
},
f60403: {
id: 'f60403',
type: SupportedEntry.Block,
title: 'Lunch',
note: '',
entries: ['0aaa7d'],
targetDuration: null,
colour: '#3E75E8',
custom: {},
revision: 0,
timeStart: 43200000,
timeEnd: 46800000,
duration: 3600000,
isFirstLinked: true,
},
'0aaa7d': {
id: '0aaa7d',
type: SupportedEntry.Event,
flag: false,
title: 'Lunch / Countdown to next session',
timeStart: 43200000,
timeEnd: 46800000,
duration: 3600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: true,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
skip: false,
note: 'Buffet in lobby\nMusic plays, holding slide on screens',
colour: '#779BE7',
delay: 0,
dayOffset: 0,
gap: 0,
cue: '2.1',
parent: 'f60403',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
triggers: [],
},
'6b0edb': {
id: '6b0edb',
type: SupportedEntry.Block,
title: 'Afternoon Sessions',
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
colour: '#339E4E',
custom: {},
revision: 0,
timeStart: 46800000,
timeEnd: 50400000,
duration: 3600000,
isFirstLinked: true,
},
'02afca': {
id: '02afca',
type: SupportedEntry.Milestone,
cue: 'Standby',
title: '12:45 - Presenters ready side stage',
note: '',
colour: '#A790F5',
revision: 0,
custom: {},
parent: '6b0edb',
},
'75ce86': {
id: '75ce86',
type: SupportedEntry.Event,
flag: false,
title: 'Session 2',
timeStart: 46800000,
timeEnd: 49800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Intention',
Artist: 'Intelligent Music Project',
},
},
d3a80: {
type: SupportedEntry.Event,
flag: false,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
duration: 3000000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: true,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
note: 'Ethan Brooks + PowerPoint + Video playback',
colour: '#77C785',
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'De Diepte',
Artist: 'S10',
},
},
'8276c': {
type: SupportedEntry.Event,
flag: false,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
cue: '3.1',
parent: '6b0edb',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Trenuletul',
Artist: 'Zdob si Zdub',
},
},
'2340b': {
type: SupportedEntry.Event,
flag: false,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Saudade Saudade',
Artist: 'Maro',
},
},
/// <----- BLOCK
cb90b: {
// TODO: This should be a marker type
type: SupportedEntry.Block,
id: 'cb90b',
title: 'Afternoon break',
note: '',
colour: '',
entries: [],
targetDuration: null,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
triggers: [],
},
'503c4': {
e10ed9: {
id: 'e10ed9',
type: SupportedEntry.Event,
flag: false,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
title: 'Wrap up',
timeStart: 49800000,
timeEnd: 50400000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
note: 'Lucas Bennett',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
cue: '3.2',
parent: '6b0edb',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
triggers: [],
custom: {
Song: 'Guilty Pleasure',
Artist: 'Mia Dimsic',
},
},
'5e965': {
type: SupportedEntry.Event,
flag: false,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
'07df89': {
id: '07df89',
type: SupportedEntry.Milestone,
cue: 'Strike',
title: '14:00 - AV & Room strike',
note: '',
colour: '#A790F5',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'The Show',
Artist: 'Reddi',
},
},
bab4a: {
type: SupportedEntry.Event,
flag: false,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Halo',
Artist: 'LUM!X & Pia Maria',
},
},
d3eb1: {
type: SupportedEntry.Event,
flag: false,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Die Together',
Artist: 'Amanda Tenfjord',
},
custom: {},
parent: '6b0edb',
},
},
revision: 0,
},
},
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
logo: null,
custom: [],
title: 'Ontime Demo Project',
description: 'Demo Project to get you started',
url: 'https://docs.getontime.no/',
info: 'Use Project info to share information to various Ontime views.\nie. Venue info, wifi, staff details, etc.',
logo: 'ontime-logo.png',
custom: [
{
title: 'Custom data',
value:
'Add additional, custom data fields to the project along with optional links to images. \nThe image will be rendered in the views',
url: '',
},
],
},
settings: {
version: '-',
@@ -537,37 +324,30 @@ export const demoDb: DatabaseModel = {
overrideStyles: false,
warningColor: '#ffa528',
},
customFields: {
Song: {
label: 'Song',
type: 'text',
colour: '#339E4E',
},
Artist: {
label: 'Artist',
type: 'text',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'clock',
target: 'timer',
search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
{
enabled: true,
alias: 'minimal',
target: 'timer',
search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&textcolour=ffffff',
},
],
customFields: {
Custom_Field: {
type: 'text',
colour: '#3E75E8',
label: 'Custom Field',
},
Images: {
type: 'image',
colour: '#ED3333',
label: 'Images',
},
},
automation: {
enabledAutomations: false,
enabledOscIn: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
@@ -741,8 +741,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-50);
});
it('added time subtracts time offset (positive offset)', () => {
@@ -765,8 +765,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-60);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-60);
});
it('considers running overtime (negative offset)', () => {
@@ -790,8 +790,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-10);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-10);
});
it('paused time is delayed time (negative offset)', () => {
@@ -816,8 +816,8 @@ describe('getRuntimeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-25);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-25);
});
it('offset doesnt exist if we havent started', () => {
@@ -834,7 +834,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 2,
offset: -77400000,
offsetAbs: -77400000,
plannedStart: 77400000,
plannedEnd: 84600000,
actualStart: null,
@@ -854,8 +854,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0);
});
it('handles loaded event', () => {
@@ -875,7 +875,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 1,
numEvents: 2,
offset: -81000000,
offsetAbs: -81000000,
plannedStart: 77400000,
plannedEnd: 84600000,
actualStart: 79443403,
@@ -895,8 +895,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(81000000 - 79521653); // clock - timestart
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(81000000 - 79521653); // clock - timestart
});
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
@@ -927,7 +927,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: 0,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
@@ -947,8 +947,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
@@ -979,7 +979,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: 0,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
@@ -999,8 +999,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null },
} as RuntimeState;
const { absoluteOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-400000); // <--- offset is always the overtime
const { offsetAbs } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-400000); // <--- offset is always the overtime
});
it('handles time-to-end started after the end time', () => {
@@ -1020,7 +1020,7 @@ describe('getRuntimeOffset()', () => {
runtime: {
selectedEventIndex: 0,
numEvents: 1,
offset: 0,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 82000000, // 22:46:40 <--- started now
@@ -1042,13 +1042,13 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent;
const { absoluteOffset } = getRuntimeOffset(state);
expect(millisToString(absoluteOffset)).toBe('-00:16:40');
expect(absoluteOffset).toBe(81000000 - 82000000); // <-- planned end - now
const { offsetAbs } = getRuntimeOffset(state);
expect(millisToString(offsetAbs)).toBe('-00:16:40');
expect(offsetAbs).toBe(81000000 - 82000000); // <-- planned end - now
});
});
describe('getRelativeOffset()', () => {
describe('getoffsetRel()', () => {
it('relative offset is 0 when starting at the planed time', () => {
const state = {
eventNow: {
@@ -1069,9 +1069,9 @@ describe('getRelativeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(0);
expect(relativeOffset).toBe(0);
const { offsetAbs, offsetRel } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0);
expect(offsetRel).toBe(0);
});
it('relative offset is 0 when starting after the planed time', () => {
const state = {
@@ -1093,9 +1093,9 @@ describe('getRelativeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(-50);
expect(relativeOffset).toBe(0);
const { offsetAbs, offsetRel } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-50);
expect(offsetRel).toBe(0);
});
it('relative offset is 0 when starting before the planed time', () => {
const state = {
@@ -1117,9 +1117,9 @@ describe('getRelativeOffset()', () => {
},
} as RuntimeState;
const { absoluteOffset, relativeOffset } = getRuntimeOffset(state);
expect(absoluteOffset).toBe(50);
expect(relativeOffset).toBe(0);
const { offsetAbs, offsetRel } = getRuntimeOffset(state);
expect(offsetAbs).toBe(50);
expect(offsetRel).toBe(0);
});
});
@@ -1224,7 +1224,7 @@ describe('getTimerPhase()', () => {
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: 0,
offsetAbs: 0,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
@@ -1263,7 +1263,7 @@ describe('getTimerPhase()', () => {
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: 0,
offsetAbs: 0,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
@@ -98,7 +98,7 @@ class RuntimeService {
// 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) {
// check if we need to call any side effects
const keepOffset = newState.runtime.offset;
const keepOffset = newState.runtime.offsetAbs;
if (hasSecondaryTimerFinished) {
// if the secondary timer has finished, we need to call roll
// since event is already loaded
@@ -715,14 +715,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) &&
!deepEqual(RuntimeService.previousState?.runtime, state.runtime);
/**
* the currentBlock object has the potential to tick on expected end
* TODO: the value shows up one tick to late
*/
// TODO: the value shows up one tick to late
const shouldBlockUpdate =
!deepEqual(RuntimeService?.previousState.blockNow, state.blockNow) ||
RuntimeService?.previousState.blockNext !== state.blockNext;
// TODO: the value shows up one tick to late
const shouldNextFlagUpdate = !deepEqual(RuntimeService?.previousState?.nextFlag, state.nextFlag);
/**
* Many other values are calculated based on the clock
* so if any of them are updated we also need to send the clock
@@ -755,9 +755,9 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.blockNext = state.blockNext;
}
if (RuntimeService.previousState?.nextFlag !== state.nextFlag) {
if (shouldNextFlagUpdate) {
batch.add('nextFlag', state.nextFlag);
RuntimeService.previousState.nextFlag = state.nextFlag;
RuntimeService.previousState.nextFlag = structuredClone(state.nextFlag);
}
if (hasImmediateChanges) {
@@ -78,7 +78,8 @@ export function findNextPlayableId(playableEventsOrder: EntryId[], currentEventI
}
/**
* returns first event that matches a given cue
* returns next event that matches a given cue
* then loops from the start
*/
export function findNextPlayableWithCue(
rundown: Rundown,
@@ -95,6 +96,14 @@ export function findNextPlayableWithCue(
return event;
}
}
for (let i = 0; i < currentEventIndex; i++) {
const eventId = playableEventsOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
}
/**

Some files were not shown because too many files have changed in this diff Show More