Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35347e8d63 | |||
| e37d2ce50f | |||
| 49e923026e | |||
| f1a8db9649 | |||
| dddeefd544 | |||
| b4aaa8966f | |||
| ad9db4edd3 | |||
| fef9ed4fcc | |||
| 9ec47eda6e | |||
| 32edf3404c | |||
| bcf80f9d77 | |||
| df137402cf | |||
| daddb7f7b7 | |||
| 82e8b9d66a | |||
| 765d5d97cf | |||
| ed4d3ba265 | |||
| a358ec0f17 | |||
| 524721002a | |||
| 9ab54edbf5 | |||
| 8a7f8c8ebc | |||
| 3ce539af2a |
@@ -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,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],
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 151 KiB |
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||