Compare commits

..

3 Commits

Author SHA1 Message Date
Claude fec1651e39 fix(timer): address review findings on the group timer
- keep the pending and none phases when showing the group timer. These describe the
  playback state rather than a threshold, so collapsing them made roll standby render
  as a running countdown and silently dropped user styling keyed on the phase
- only show the group indicator for timer types which render the running timer. It was
  labelling the wall clock, and the invisible display of the none timer type, as a group
- accept useGroupTimer in the MCP group patch. It was documented in the group shape but
  dropped on write, which reported success without applying the change
- narrow the timer preview tooltip, which claimed all views follow the group timer when
  only the timer views do

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-08-07 19:32:16 +00:00
Claude 1fad99f505 refactor(runtime): derive the group timer on the server
The group timer was derived in the client, which meant every view that wanted it
had to depend on the rundown query. That coupling was already awkward for the PiP
timer, whose separate react root needed its own query client, and it would have
spread to every remaining view.

Moving the derivation to the server removes the coupling. The value is still the
running event timer offset by the content scheduled around it, so the group keeps
behaving as if it were a single event containing all its children.

Because the group timer is the event timer offset by a constant, it changes exactly
when the event timer does and can share its broadcast throttling.

- add RuntimeStore.groupTimer, null unless the running group opted in
- split the group around the loaded event when the group is loaded, so the per tick
  cost is an addition rather than a walk of the rundown
- derive on the getState() projection so it cannot drift from the timer it is built on
- reduce the client to a plain selector, dropping the PiP query client workaround

Also fixes elapsed time, which was calculated from the group duration and so clamped
to zero for as long as time added to an event kept the group in credit. It is now
derived symmetrically with the remaining time, and the two always add up to the total.

Timer and PiP shared eight identical branches for choosing between the two timers.
These now go through a single resolver. A group has no warning or danger thresholds,
so it only reports as running or overtime, and feeding that phase through the existing
modifiers makes the suppression of warning and danger a consequence of what a group is
rather than something each view has to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 15:46:58 +00:00
Claude 636b2e7c3e feat(rundown): add opt-in shared group timer
Groups can now opt into a shared timer, so stage outputs show how much time
the whole block has left instead of only the running event.

The value is derived in the client from the running event timer plus the
content still scheduled after it, which makes the group behave as if it were
a single event containing all its children. Keeping it relative to the event
timer (rather than recalculating from the clock) means pause, added time,
overtime, roll and midnight rollovers are all inherited, and moving between
events inside a group cannot introduce a discontinuity.

The remainder aggregation mirrors the server side group duration calculation:
non playable entries are skipped and gaps are accounted for.

Warning and danger thresholds belong to the event and carry no meaning against
a group duration, so they are suppressed while the group timer is showing.
Overtime is kept, but only once the group itself has run out of time.

- add OntimeGroup.useGroupTimer, defaulting to false
- show the group timer in the timer view and the PiP timer
- indicate the override in the timer view, PiP, group header and timer preview
- add a switch to the group editor

The PiP window mounts its own react root, which had no query client and could
therefore not read the rundown. It is now wrapped in a provider, which also
means its view settings are no longer frozen at the moment the window opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 06:24:15 +00:00
76 changed files with 1208 additions and 700 deletions
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
export function EntryActionsProvider({ children }: PropsWithChildren) {
const { effectiveRundownId } = useRundownSelectionContext();
const actions = useScopedEntryActions(effectiveRundownId);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
@@ -1,120 +0,0 @@
import { Maybe, ProjectRundown } from 'ontime-types';
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export type RundownScopeValue = {
loadedRundownId: string;
selectedRundownId: Maybe<string>;
isLoadedRundown: boolean;
effectiveRundownId: string;
selectRundownId: (val: Maybe<string>) => void;
rundowns: ProjectRundown[];
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
'use memo';
const { data } = useProjectRundowns();
const { loaded, rundowns } = data;
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
const selectRundownId = useCallback(
(rundownId: Maybe<string>) => {
startTransition(() => {
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
else setSelectedRundownId(null);
});
},
[rundowns, setSelectedRundownId],
);
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
const isLoadedRundown = effectiveRundownId === loaded;
useEffect(() => {
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
const value = useMemo(
(): RundownScopeValue => ({
loadedRundownId: loaded,
isLoadedRundown,
selectedRundownId,
effectiveRundownId,
selectRundownId,
rundowns,
}),
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownSelectionContext() {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
}
return context;
}
const rundownParam = 'rundownId';
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const selectedRundownId = searchParams.get(rundownParam);
const setSelectedRundownId = useCallback(
(id: Maybe<string>) => {
if (id === null) {
setSearchParams((searchParams) => {
searchParams.delete(rundownParam);
return searchParams;
});
} else {
setSearchParams((searchParams) => {
searchParams.set(rundownParam, id);
return searchParams;
});
}
},
[setSearchParams],
);
return [selectedRundownId, setSelectedRundownId];
}
/**
*
* mutates the provided `searchParams`
*/
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
if (id === null) {
searchParams.delete(rundownParam);
} else {
searchParams.set(rundownParam, id);
}
}
export function useDirectLinkToBackgroundEdit() {
const navigate = useNavigate();
const [search] = useSearchParams();
return useCallback(
async (rundownId: string) => {
setSelectRundownInParams(rundownId, search);
navigate({
pathname: '/cuesheet',
search: search.toString(),
});
},
[navigate, search],
);
}
@@ -1,101 +0,0 @@
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { useMemo } from 'react';
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useFlatRundown, useRundown } from './useRundown';
export function useContextRundownEditModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown } = useRundown(effectiveRundownId);
return { rundown };
}
export function useContextRundownCueRenumberModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data } = useRundown(effectiveRundownId);
const { flatOrder } = data;
return { flatOrder };
}
export function useContextRundownList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const rundownMetadata = useMemo(
() => getRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundown,
rundownMetadata,
status,
isLoadedRundown,
}),
[rundown, rundownMetadata, status, isLoadedRundown],
);
}
export function useContextRundownTitleList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(() => {
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
}, [effectiveSelectedEventId, rundown]);
return useMemo(
() => ({
flatRundown,
status,
isLoadedRundown,
}),
[status, isLoadedRundown, flatRundown],
);
}
export function useContextRundownTable() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
flatRundown,
status,
loadedEventId,
}),
[flatRundown, status, loadedEventId],
);
}
export function useContextRundownFinder() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
return useMemo(
() => ({
rundown,
rundownId: effectiveRundownId,
status,
}),
[rundown, status, effectiveRundownId],
);
}
@@ -1,18 +1,24 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
export function useProjectList() {
const { data, status, refetch } = useSuspenseQuery({
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }),
staleTime: MILLIS_PER_HOUR,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data, status, refetch };
return { data: data ?? placeholderProjectList, status, refetch };
}
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
@@ -1,7 +1,7 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import {
createRundown,
@@ -11,21 +11,24 @@ import {
loadRundown,
renameRundown,
} from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/**
* Project rundowns
*/
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
staleTime: MILLIS_PER_HOUR,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data, status, isError, refetch, isFetching };
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getRundownQueryKey } from '../api/constants';
import { fetchRundown } from '../api/rundown';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -20,36 +20,43 @@ const cachedRundownPlaceholder: Rundown = {
};
/**
* Provides access to a specific rundown by ID.
* When rundownId is not provided the loaded rundown is provided
* Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/
export function useRundown(rundownId: Maybe<string>) {
'use memo';
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -57,8 +64,8 @@ export function useRundownWithMetadata(rundownId: Maybe<string>) {
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -70,13 +77,11 @@ export function useFlatRundown(rundownId: Maybe<string>) {
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId);
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
@@ -87,8 +92,8 @@ export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
* re-filtering on every render.
*
*/
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(rundownId);
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
@@ -98,20 +103,37 @@ export function usePartialRundown(rundownId: Maybe<string>, cb: (event: Extended
/**
* Hook to get a specific entry by ID from the rundown
* @deprecated
*/
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(rundownId);
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -154,6 +154,9 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
/** Shared timer for the running group, null unless the group opted in */
export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
@@ -90,7 +90,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -108,7 +107,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -126,7 +124,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: true,
isNextDay: false,
isParentToLoaded: true,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -143,7 +140,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -160,7 +156,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -177,7 +172,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
@@ -194,7 +188,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
@@ -211,7 +204,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
@@ -264,7 +256,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -282,7 +273,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -300,7 +290,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -27,7 +27,6 @@ export type RundownMetadata = {
groupColour: string | undefined;
groupEntries: number | undefined;
isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -95,7 +94,6 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
isParentToLoaded: false,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -119,7 +117,6 @@ function processEntry(
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
@@ -135,7 +132,6 @@ function processEntry(
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else {
// for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+8 -4
View File
@@ -14,6 +14,7 @@ import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
@@ -24,7 +25,6 @@ import {
VIEW_SETTINGS,
getRundownQueryKey,
PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
@@ -216,9 +216,6 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: {
target satisfies never;
break;
@@ -241,6 +238,7 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return;
}
@@ -254,6 +252,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
}
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
}
export function sendSocket<T extends MessageTag | ApiActionTag>(
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import { useRundown } from '../../../../common/hooks-query/useRundown';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown(null);
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks';
import { Suspense, useState } from 'react';
import { useState } from 'react';
import {
IoAdd,
IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -28,20 +28,6 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import { useRundown } from '../../../../../common/hooks-query/useRundown';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(null);
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -1,4 +1,4 @@
import { Suspense, useState } from 'react';
import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info';
@@ -11,25 +11,11 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ProjectListSuspend />
</Suspense>
);
}
function ProjectListSuspend() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch } = useOrderedProjectList(sortMode);
const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -52,6 +38,14 @@ function ProjectListSuspend() {
});
};
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length;
return (
@@ -1,11 +1,11 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
@@ -22,6 +22,7 @@ const secondarySourceLabels: Record<string, string> = {
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const isGroupTimerActive = useGroupTimer() !== null;
const main = (() => {
if (showTimerMessage) return 'Message';
@@ -105,6 +106,14 @@ export default function TimerPreview() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text={`Timer views are showing the ${isGroupTimerActive ? 'shared group timer' : 'event timer'}`}
render={<span />}
className={style.statusIcon}
data-active={isGroupTimerActive}
>
<IoFolderOutline />
</Tooltip>
</div>
</div>
);
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -1,10 +1,9 @@
import { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode, Suspense } from 'react';
import { PropsWithChildren, ReactNode } from 'react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss';
@@ -16,37 +15,18 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline();
return (
<Suspense fallback={<OverviewFallback navElements={navElements} />}>
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</ScrollArea>
</ErrorBoundary>
</div>
</Suspense>
);
}
function OverviewFallback({ navElements }: OverviewWrapperProps) {
return (
<div className={style.overview}>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{/* TODO: this could be alined in a nicer way */}
<Loader />
</ScrollArea>
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</ScrollArea>
</ErrorBoundary>
</div>
);
}
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(null, currentGroupId) as OntimeGroup | null;
const group = useEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(null, id) as OntimeEvent | null;
const entry = useEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(null);
const { data: rundownData } = useRundownAuxData();
if (!projectData.title && !rundownData.title) {
return null;
@@ -6,7 +6,8 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -38,29 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<RundownSelectionContextProvider>
<EntryActionsProvider>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -70,32 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<RundownSelectionContextProvider>
<EntryActionsProvider>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
<RundownEntryEditor />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
)}
</div>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -107,16 +105,17 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog />
</div>
);
@@ -1,23 +1,16 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList);
function RundownList() {
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const isLoading = status !== 'success' || !rundown || !rundownMetadata;
const isLoading = status !== 'success' || !data || !rundownMetadata;
if (isLoading) {
return <Empty text='Connecting to server' />;
@@ -25,12 +18,12 @@ function RundownList() {
return (
<Rundown
order={rundown.order}
flatOrder={rundown.flatOrder}
entries={rundown.entries}
id={rundown.id}
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
featureData={isLoadedRundown ? featureData : backgroundFeatureData}
featureData={featureData}
/>
);
}
@@ -1,3 +0,0 @@
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -1,42 +0,0 @@
import Select from '../../../common/components/select/Select';
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
import { AppMode } from '../../../ontimeConfig';
import styles from './RundownSelect.module.scss';
const FOLLOW = '___null___';
interface RundownSelectProps {
appMode: AppMode;
}
export function RundownSelect({ appMode }: RundownSelectProps) {
'use memo';
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
const options = rundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
// add a follow option
options.unshift({
value: FOLLOW,
label: 'Follow loaded',
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? FOLLOW}
options={options}
onValueChange={(value) => {
if (value === FOLLOW) selectRundownId(null);
else selectRundownId(value);
}}
disabled={appMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -5,6 +5,8 @@ import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
submitHandler={handleSubmit}
/>
</div>
<div>
<Tooltip
text='Timer views count the scheduled duration of the whole group instead of the running event'
render={<Editor.Label htmlFor='useGroupTimer' />}
>
Group timer
</Tooltip>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
@@ -1,7 +1,7 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import { useRundown } from '../../../common/hooks-query/useRundown';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(null);
const { data } = useRundown();
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
import useRundown from '../../../common/hooks-query/useRundown';
import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
@@ -17,7 +17,8 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() {
'use memo';
const { flatOrder } = useContextRundownCueRenumberModal();
const { data } = useRundown();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -15,6 +15,7 @@ import {
import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
{data.useGroupTimer && (
<div className={style.metaEntry}>
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
<Tag>Group timer</Tag>
</Tooltip>
</div>
)}
</div>
</div>
</div>
@@ -8,7 +8,6 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu';
@@ -25,7 +24,6 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean;
showOffsetToggle: boolean;
showOverflowMenu: boolean;
showRundownSelect: boolean;
}
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -33,19 +31,16 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true,
showOffsetToggle: true,
showOverflowMenu: true,
showRundownSelect: false,
},
[EditorLayoutMode.PLANNING]: {
showRunEditToggle: false,
showOffsetToggle: false,
showOverflowMenu: true,
showRundownSelect: true,
},
[EditorLayoutMode.TRACKING]: {
showRunEditToggle: false,
showOffsetToggle: true,
showOverflowMenu: false,
showRundownSelect: false,
},
} as const;
@@ -55,8 +50,7 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
HEADER_CONTROLS_CONFIG[layoutMode];
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
@@ -128,8 +122,6 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup>
)}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root>
);
@@ -1,7 +1,10 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -13,6 +16,8 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -30,12 +35,14 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(null);
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(null, filterCallback);
const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -14,3 +14,7 @@
'table';
color: $ui-white;
}
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
+28 -23
View File
@@ -4,46 +4,51 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<RundownSelectionContextProvider>
<EntryActionsProvider>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
<IconButton
aria-label='Toggle navigation'
variant='subtle-white'
size='xlarge'
onClick={menuHandler.open}
>
<IoApps />
</IconButton>
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper />
</div>
</EntryActionsProvider>
</RundownSelectionContextProvider>
<EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
<IoApps />
</IconButton>
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
);
}
@@ -1,20 +1,39 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const { isLoadedRundown } = useRundownSelectionContext();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo(
@@ -31,11 +50,66 @@ function CuesheetTableWrapper() {
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
</CuesheetDnd>
);
}
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -0,0 +1,21 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
});
it('keeps the stored selection when it still exists in the current project', () => {
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
});
});
@@ -1,13 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal() {
const { rundown } = useContextRundownEditModal();
function EntryEditModal({ rundown }: EntryEditModalProps) {
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
@@ -14,7 +14,7 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownTable } from '../../../common/hooks-query/useContextRundown';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -35,22 +35,34 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { flatRundown, status, loadedEventId } = useContextRundownTable();
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -131,17 +143,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
// in Run mode, follow the current event
useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
return;
}
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) {
return;
}
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, flatRundown, loadedEventId]);
}, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => {
@@ -230,9 +242,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
appMode={cuesheetMode}
tableRoot={tableRoot}
setCuesheetMode={setCuesheetMode}
insertElement={insertElement}
modeControls={
tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/>
<TableVirtuoso
ref={virtuosoRef}
@@ -10,9 +10,7 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal';
@@ -32,15 +30,21 @@ type TableHeaderOptionValues = Pick<
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
setCuesheetMode?: (mode: AppMode) => void;
appMode: AppMode;
tableRoot: 'editor' | 'cuesheet';
insertElement?: ReactNode;
modeControls?: TableModeControls;
showShare?: boolean;
}
export default function CuesheetTableHeaderToolbar({
@@ -49,23 +53,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing,
handleResetReordering,
handleClearToggles,
setCuesheetMode,
tableRoot,
appMode,
insertElement,
modeControls,
showShare = false,
}: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0);
if (!newValue || !setCuesheetMode) return;
setCuesheetMode(newValue);
if (!newValue || !modeControls) return;
modeControls.setCuesheetMode(newValue);
};
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={optionsStore} />
<ColumnSettings
columns={columns}
@@ -73,14 +77,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
<div className={style.apart}>
{showRundownSelect && <RundownSelect appMode={appMode} />}
{canChangeMode && (
{modeControls && canChangeMode && (
<div className={style.apart}>
{insertElement}
<ToggleGroup
value={[appMode]}
value={[modeControls.cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={style.group}
disabled={!isLoadedRundown}
disabled={!modeControls.isCurrentRundown}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
@@ -89,10 +93,10 @@ export default function CuesheetTableHeaderToolbar({
Edit
</Toolbar.Button>
</ToggleGroup>
)}
</div>
</div>
)}
{canShare && (
{showShare && canShare && (
<>
<Editor.Separator orientation='vertical' />
<CuesheetShareModal />
@@ -0,0 +1,65 @@
import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
});
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
const { loaded: loadedRundownId } = projectRundowns;
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
return {
loadedRundownId,
selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
setStoredSelectedRundownId(rundownId);
});
},
};
}
export function useDirectLinkToBackgroundEdit() {
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
async (rundownId: string) => {
await navigate('/cuesheet');
startTransition(() => setStoredSelectedRundownId(rundownId));
},
[setStoredSelectedRundownId, navigate],
);
}
+35 -43
View File
@@ -1,6 +1,5 @@
import { lazy } from 'react';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -15,51 +14,44 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() {
const { layoutMode } = useEditorLayout();
switch (layoutMode) {
case EditorLayoutMode.TRACKING: {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Run} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<TrackingPlaybackBar />
if (layoutMode === EditorLayoutMode.CONTROL) {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
);
}
case EditorLayoutMode.PLANNING: {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Edit} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
<Rundown />
</div>
);
}
if (layoutMode === EditorLayoutMode.TRACKING) {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
);
}
case EditorLayoutMode.CONTROL:
default: {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
<TrackingPlaybackBar />
</div>
);
}
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
);
}
}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12;
@@ -38,7 +38,7 @@ type FilterableMilestone = {
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { rundown: data, rundownId } = useContextRundownFinder();
const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
@@ -68,6 +68,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 2.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, $ui-white);
}
}
.secondary {
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
@@ -10,9 +10,8 @@ import {
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
getShowsTimerValue,
resolveTimerDisplay,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
@@ -24,23 +23,28 @@ interface PipTimerProps {
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const groupTimer = useGroupTimer();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
const { showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showProgressBar = getShowProgressBar(timerTypeNow);
const showsTimerValue = getShowsTimerValue(timerTypeNow);
const showProgressBar = showsTimerValue;
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
@@ -80,10 +84,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
{timerDisplay.isGroup && showsTimerValue && <div className='group-indicator'>group</div>}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
@@ -94,12 +99,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
import { useContextRundownList } from '../../../common/hooks-query/useContextRundown';
import useRundown from '../../../common/hooks-query/useRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -20,7 +20,7 @@ interface TitleListProps {
}
export default function TitleList({ mode }: TitleListProps) {
const { rundown } = useContextRundownList();
const { data: rundown } = useRundown();
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
@@ -1,9 +1,6 @@
import { isValueOfEnum } from 'ontime-utils';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
const layoutParam = 'layout';
export enum EditorLayoutMode {
@@ -23,29 +20,14 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
}
export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
useEffect(() => {
setSearchParams((searchParams) => {
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
return searchParams;
});
}, [setSearchParams, layoutMode]);
const setLayoutMode = useCallback(
(mode: EditorLayoutMode) => {
setSearchParams((searchParams) => {
searchParams.set(layoutParam, mode);
// Only the Planning layout is allowed to look at something other than the current rundown
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
return searchParams;
});
},
[setSearchParams],
);
const setLayoutMode = (mode: EditorLayoutMode) => {
const nextParams = new URLSearchParams(searchParams);
nextParams.set(layoutParam, mode);
setSearchParams(nextParams, { replace: true });
};
return { layoutMode, setLayoutMode };
}
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
+12
View File
@@ -131,6 +131,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 1.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, var(--timer-color-override, $ui-white));
}
}
.secondary {
+24 -17
View File
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
@@ -25,9 +25,8 @@ import {
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
getShowsTimerValue,
resolveTimerDisplay,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
@@ -72,23 +71,29 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
timeformat,
} = useTimerOptions();
const groupTimer = useGroupTimer();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime,
freezeMessage,
hidePhase,
);
});
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
const showsTimerValue = getShowsTimerValue(viewTimerType);
const showProgressBar = !hideProgress && showsTimerValue;
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
@@ -102,8 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
@@ -178,11 +182,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
)}
{timerDisplay.isGroup && showsTimerValue && !showEndMessage && (
<div className='group-indicator'>group</div>
)}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondaryContent}
@@ -193,12 +200,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -0,0 +1,110 @@
import { GroupTimerState, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { getShowsTimerValue, resolveTimerDisplay } from '../timer.utils';
const makeTime = (patch: Partial<TimerState> = {}): TimerState => ({
addedTime: 0,
current: 10000,
duration: 60000,
elapsed: 50000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: null,
...patch,
});
const makeGroupTimer = (patch: Partial<GroupTimerState> = {}): GroupTimerState => ({
current: 100000,
elapsed: 80000,
duration: 180000,
...patch,
});
const resolve = (time: TimerState, groupTimer: GroupTimerState | null) =>
resolveTimerDisplay({
time,
groupTimer,
event: { timeWarning: 120000, timeDanger: 60000 },
timerType: TimerType.CountDown,
countToEnd: false,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
describe('resolveTimerDisplay()', () => {
it('uses the event timer when there is no group timer', () => {
const time = makeTime();
const result = resolve(time, null);
expect(result.isGroup).toBe(false);
expect(result.source).toBe(time);
expect(result.total).toBe(60000);
expect(result.warning).toBe(120000);
expect(result.danger).toBe(60000);
});
it('adds the time added to an event into the event total', () => {
expect(resolve(makeTime({ addedTime: 30000 }), null).total).toBe(90000);
});
it('uses the group timer when one is present', () => {
const groupTimer = makeGroupTimer();
const result = resolve(makeTime(), groupTimer);
expect(result.isGroup).toBe(true);
expect(result.source).toBe(groupTimer);
expect(result.total).toBe(180000);
});
it('drops the event thresholds while showing the group', () => {
const result = resolve(makeTime({ phase: TimerPhase.Danger }), makeGroupTimer());
expect(result.warning).toBeUndefined();
expect(result.danger).toBeUndefined();
});
it.each([TimerPhase.Warning, TimerPhase.Danger])(
'reports the group as running while the event is in %s',
(phase) => {
const result = resolve(makeTime({ phase }), makeGroupTimer());
expect(result.phase).toBe(TimerPhase.Default);
expect(result.showWarning).toBe(false);
expect(result.showDanger).toBe(false);
expect(result.showFinished).toBe(false);
},
);
it('reports overtime only once the group itself has run out of time', () => {
const overrunningEvent = makeTime({ current: -5000, phase: TimerPhase.Overtime });
// the event is over, but the group still has time left
const stillRunning = resolve(overrunningEvent, makeGroupTimer({ current: 60000 }));
expect(stillRunning.phase).toBe(TimerPhase.Default);
expect(stillRunning.showFinished).toBe(false);
const overtime = resolve(overrunningEvent, makeGroupTimer({ current: -1000 }));
expect(overtime.phase).toBe(TimerPhase.Overtime);
expect(overtime.showFinished).toBe(true);
});
it.each([TimerPhase.Pending, TimerPhase.None])('keeps the %s playback phase on the group', (phase) => {
// these describe the playback state rather than a threshold, so they are true of the group too
expect(resolve(makeTime({ phase }), makeGroupTimer()).phase).toBe(phase);
});
});
describe('getShowsTimerValue()', () => {
it('is true for timer types which render the running timer', () => {
expect(getShowsTimerValue(TimerType.CountDown)).toBe(true);
expect(getShowsTimerValue(TimerType.CountUp)).toBe(true);
});
it('is false for timer types which do not reflect what is loaded', () => {
expect(getShowsTimerValue(TimerType.Clock)).toBe(false);
expect(getShowsTimerValue(TimerType.None)).toBe(false);
});
});
+59 -2
View File
@@ -1,4 +1,5 @@
import {
GroupTimerState,
MaybeNumber,
MessageState,
OntimeEvent,
@@ -6,6 +7,7 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
@@ -34,9 +36,11 @@ export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): num
}
/**
* Whether the progress bar should be shown for this timer type
* Whether this timer type renders a value derived from the running timer.
* Clock shows the time of day and none shows nothing, so neither reflects what is loaded,
* which also means neither can carry a progress bar or a group timer indicator
*/
export function getShowProgressBar(timerType: TimerType) {
export function getShowsTimerValue(timerType: TimerType) {
return timerType !== TimerType.None && timerType !== TimerType.Clock;
}
@@ -114,6 +118,59 @@ export function getShowModifiers(
};
}
interface ResolveTimerDisplayOptions {
time: TimerState;
/** when present, views show the group instead of the running event */
groupTimer: GroupTimerState | null;
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | null;
timerType: TimerType;
countToEnd: boolean;
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
}
/**
* Resolves which timer a view should display, and the modifiers that go with it.
*
* A group has no warning or danger thresholds of its own, so it only ever reports as
* running or overtime. Feeding that phase through `getShowModifiers` means the warning
* and danger states are suppressed as a consequence of what a group is, rather than
* every view having to remember to special case them.
*/
export function resolveTimerDisplay({
time,
groupTimer,
event,
timerType,
countToEnd,
freezeOvertime,
freezeMessage,
hidePhase,
}: ResolveTimerDisplayOptions) {
const isGroup = groupTimer !== null;
const phase = (() => {
if (!isGroup) return time.phase;
// pending and none describe the playback state rather than a threshold,
// they are just as true of the group as they are of the event
if (time.phase === TimerPhase.Pending || time.phase === TimerPhase.None) return time.phase;
return groupTimer.current <= 0 ? TimerPhase.Overtime : TimerPhase.Default;
})();
return {
isGroup,
phase,
/** the values to render, shaped for getTimerByType */
source: isGroup ? groupTimer : time,
/** the target of a progress bar */
total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime),
// thresholds belong to the event, they carry no meaning against a group duration
warning: isGroup ? undefined : event?.timeWarning,
danger: isGroup ? undefined : event?.timeDanger,
...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase),
};
}
/**
* What, if anything, should be displayed in the secondary field
*/
+2 -2
View File
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundown } from '../../common/hooks-query/useRundown';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown(null);
const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown;
return {
@@ -411,6 +411,7 @@ export function migrateRundown(
targetDuration: null,
colour: '', //leave default colour
custom: {}, // leave empty
useGroupTimer: false, // opt-in feature, off for migrated projects
// !==== RUNTIME METADATA ====! //
revision: -1,
timeStart: null,
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
event2: {
id: 'event2',
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
delay: {
type: SupportedEntry.Delay,
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
it('normalises groups from projects made before the group timer existed', () => {
const rundown = {
id: 'test',
title: '',
order: ['group'],
flatOrder: ['group'],
entries: {
// a group as it would have been persisted by a previous version
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
},
revision: 1,
} as unknown as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
});
});
describe('sanitiseCustomFields()', () => {
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
describe('createGroupPatch()', () => {
it('preserves useGroupTimer when it is not part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
const patched = createGroupPatch(original, { title: 'Morning sessions' });
expect(patched.title).toBe('Morning sessions');
expect(patched.useGroupTimer).toBe(true);
});
it('updates useGroupTimer when it is part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
});
it('ignores non boolean values for useGroupTimer', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
// @ts-expect-error -- testing a value coming from an unvalidated request
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
});
});
@@ -179,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
colour: makeString(patchGroup.colour, originalGroup.colour),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
timeEnd: originalGroup.timeEnd,
+1
View File
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
note: string
entries: EntryId[]
targetDuration: number | null
useGroupTimer: boolean // views show a shared timer for the whole group
custom: { [key: string]: string }
timeStart: number | null // calculated from nested entries (runtime)
timeEnd: number | null // calculated from nested entries (runtime)
+4 -1
View File
@@ -54,7 +54,9 @@ export type EventFieldArgs = Partial<
>;
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
export type GroupFieldArgs = Partial<
Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom' | 'useGroupTimer'>
>;
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
export type TargetRundownArgs = { rundownId?: string };
@@ -170,6 +172,7 @@ function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGro
if (args.colour !== undefined) patch.colour = args.colour;
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
if (args.custom !== undefined) patch.custom = args.custom;
if (args.useGroupTimer !== undefined) patch.useGroupTimer = args.useGroupTimer;
return Object.keys(patch).length > 1 ? patch : null;
}
+1
View File
@@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({
clock: state.clock,
timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message },
offset: state.offset,
rundown: state.rundown,
+11
View File
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -1,4 +1,16 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
EndAction,
MaybeNumber,
OntimeEvent,
OntimeGroup,
Playback,
Rundown,
SupportedEntry,
TimeOfDay,
TimeStrategy,
TimerPhase,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import type { RuntimeState } from '../../stores/runtimeState.js';
@@ -7,6 +19,8 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -1452,3 +1466,138 @@ describe('findDay()', () => {
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
});
});
describe('getGroupTiming()', () => {
const makeEvent = (id: string, duration: number, patch: object = {}) =>
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
const makeEntries = (...entries: OntimeEvent[]) =>
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
it('splits the scheduled content around the loaded event', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
});
it('accounts for the gaps between events', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
// the gap before the loaded event has already passed, so it counts as time spent
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
});
it('splits into values which add up to the group duration', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
const groupDuration = 10 + 20 + 5 + 30;
for (const id of ['1', '2', '3']) {
const timing = getGroupTiming(group, entries, id)!;
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
}
});
it('skips entries which are not playable events', () => {
const group = makeGroup(['1', '2', '3', '4']);
const entries = {
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
} as Rundown['entries'];
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
});
it('returns null when the loaded event is not part of the group', () => {
const group = makeGroup(['1', '2']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
});
});
describe('getGroupTimer()', () => {
const makeState = (patch: {
useGroupTimer?: boolean;
duration?: number;
timing?: { before: number; after: number } | null;
current?: MaybeNumber;
elapsed?: MaybeNumber;
addedTime?: number;
}) =>
({
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
timer: {
current: patch.current ?? null,
elapsed: patch.elapsed ?? null,
addedTime: patch.addedTime ?? 0,
},
}) as RuntimeState;
it('is null when there is no group running', () => {
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
});
it('is null when the group has not opted in', () => {
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
});
it('is null when the loaded event is not part of the group', () => {
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
});
it('offsets the event timer by the content scheduled around it', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
});
it('keeps elapsed and current adding up to the total', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('grows the total with the time added to the running event', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 160,
elapsed: 0,
addedTime: 60,
});
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.duration).toBe(160);
// elapsed keeps counting up rather than clamping at zero once time is added
expect(groupTimer.elapsed).toBe(0);
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('reports elapsed time while the group is in credit from added time', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 150,
elapsed: 10,
addedTime: 60,
});
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
});
it('goes negative when the group runs into overtime', () => {
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
expect(getGroupTimer(state)?.current).toBe(-15);
});
});
@@ -1,10 +1,9 @@
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -106,9 +105,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
currentProjectName: fileName,
};
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return fileName;
}
@@ -267,10 +263,6 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return;
}
@@ -301,10 +293,6 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const newFileName = await loadProject(projectData.data, newFilename);
return newFileName;
}
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return newFilename;
}
@@ -344,9 +332,6 @@ export async function deleteProjectFile(filename: string) {
}
await deleteFile(projectFilePath);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
}
/**
@@ -389,7 +374,6 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
}
}
const updatedData = getDataProvider().getData();
const updatedData = await getDataProvider().getData();
return updatedData;
}
@@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
/**
* the group timer is the event timer offset by a constant, so it changes exactly when
* the event timer does and can share its throttling.
* entry changes are also considered since loading an event can move us between groups
*/
if (updateTimer || entryChanged) {
if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
+86 -2
View File
@@ -1,7 +1,18 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import {
Day,
EntryId,
GroupTimerState,
MaybeNumber,
OntimeGroup,
Rundown,
TimeOfDay,
TimerPhase,
isOntimeEvent,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
/**
* Splits a group's scheduled content around the loaded event.
*
* The result is stable for as long as the same event is loaded, so it is calculated
* when the group is loaded rather than on every update.
* The aggregation mirrors the group duration calculated in the rundown
* (see rundown.dao.ts): non playable entries are skipped and the gap is
* accounted for in every entry other than the first.
*/
export function getGroupTiming(
group: OntimeGroup,
entries: Rundown['entries'],
currentEventId: EntryId,
): GroupTiming | null {
const currentIndex = group.entries.indexOf(currentEventId);
if (currentIndex === -1) {
return null;
}
let before = 0;
let after = 0;
for (let i = 0; i < group.entries.length; i++) {
const entry = entries[group.entries[i]];
if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) {
continue;
}
// the first entry of the group has no gap to account for,
// any other entry could be preceded by idle time
const gap = i > 0 ? entry.gap : 0;
if (i < currentIndex) {
before += gap + entry.duration;
} else if (i === currentIndex) {
// the loaded event contributes its own duration through the event timer,
// but the idle time before it has already passed
before += gap;
} else {
after += gap + entry.duration;
}
}
return { before, after };
}
/**
* Derives the shared group timer from the event timer.
*
* Keeping this relative to the event timer is what makes the group behave as if it were
* a single event containing all its children: pause, added time, overtime, roll and
* midnight rollovers are all inherited instead of being recalculated.
*/
export function getGroupTimer(state: RuntimeState): GroupTimerState | null {
const { groupNow, _groupTiming } = state;
if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) {
return null;
}
const { current, elapsed, addedTime } = state.timer;
if (current === null) {
return null;
}
return {
current: current + _groupTiming.after,
elapsed: (elapsed ?? 0) + _groupTiming.before,
// mirrors the total time of an event, which grows with the time added to it
duration: groupNow.duration + addedTime,
};
}
@@ -9,6 +9,8 @@ const baseState: RuntimeState = {
eventNext: null,
eventFlag: null,
groupNow: null,
groupTimer: null,
_groupTiming: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
+23
View File
@@ -16,6 +16,7 @@ import {
TimeOfDay,
TimerPhase,
TimerState,
GroupTimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
@@ -39,6 +40,8 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -51,6 +54,12 @@ type ExpectedMetadata = {
isLinkedToLoaded: boolean;
} | null;
/** scheduled content of the running group, split around the loaded event */
export type GroupTiming = {
before: number;
after: number;
};
export type RuntimeState = {
clock: TimeOfDay;
groupNow: OntimeGroup | null;
@@ -59,6 +68,8 @@ export type RuntimeState = {
eventFlag: PlayableEvent | null;
offset: Offset;
timer: TimerState;
/** derived from the timer, only refreshed on the getState() projection */
groupTimer: GroupTimerState | null;
rundown: RundownState;
// private properties of the timer calculations
_timer: {
@@ -72,6 +83,7 @@ export type RuntimeState = {
totalDelay: number; // this value comes from rundown service
};
_group: ExpectedMetadata;
_groupTiming: GroupTiming | null;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
@@ -86,6 +98,7 @@ const runtimeState: RuntimeState = {
eventFlag: null,
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
groupTimer: null,
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: {
forceFinish: null,
@@ -98,6 +111,7 @@ const runtimeState: RuntimeState = {
totalDelay: 0,
},
_group: null,
_groupTiming: null,
_flag: null,
_end: null,
_startEpoch: null,
@@ -115,6 +129,8 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
// derived here so it can never drift from the timer values it is built on
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
@@ -156,6 +172,7 @@ export function clearState() {
runtimeState.groupNow = null;
runtimeState._group = null;
runtimeState._groupTiming = null;
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
@@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd(
const previousGroup = state.groupNow?.id;
state.groupNow = null;
state._group = null;
state._groupTiming = null;
state.eventFlag = null;
state._flag = null;
state._end = null;
@@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd(
state.rundown.actualGroupStart = null;
}
// the split is stable while the same event is loaded, so we only calculate it here
if (state.groupNow !== null) {
state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id);
}
// if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent;
// if we don't have a last event for the group there is no need to find its end time
@@ -1,7 +1,6 @@
export enum RefetchKey {
All = 'all',
CustomFields = 'custom-fields',
ProjectFiles = 'project-files',
ProjectData = 'project-data',
ProjectRundowns = 'project-rundowns',
Report = 'report',
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
targetDuration: MaybeNumber;
colour: string;
custom: EntryCustomFields;
/** whether views should display a shared timer for the group instead of the event timer */
useGroupTimer: boolean;
// !==== RUNTIME METADATA ====! //
revision: number;
timeStart: MaybeNumber; // calculated at runtime
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null, // derived from the timer, null unless the running group opted in
message: {
timer: {
text: '',
@@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Offset } from './Offset.type.js';
import type { RundownState } from './RundownState.type.js';
import type { TimerState } from './TimerState.type.js';
import type { GroupTimerState, TimerState } from './TimerState.type.js';
export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
/** shared timer for the running group, null unless the group opted in */
groupTimer: GroupTimerState | null;
// messages service
message: MessageState;
@@ -11,6 +11,22 @@ export enum TimerPhase {
Pending = 'pending',
}
/**
* Timer for the running group, present when the group opted into a shared timer.
*
* The group is treated as a single event containing all its children: the values are
* derived from the running event timer plus the content scheduled around it, so pause,
* added time, overtime and roll are inherited from the event timer rather than recalculated.
*/
export type GroupTimerState = {
/** Time remaining in the group */
current: number;
/** Time elapsed since the group started */
elapsed: number;
/** Total time in the group, includes time added to the running event */
duration: number;
};
/**
* Gathers the current running timer state
*/
+1 -1
View File
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
export { OffsetMode } from './definitions/runtime/Offset.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
targetDuration: null,
colour: '',
custom: {},
useGroupTimer: false,
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
timeStart: null, // calculated at runtime
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
revision: 0,
timeStart: null,
timeEnd: null,