Compare commits

..

5 Commits

Author SHA1 Message Date
Claude ccdfb64921 perf(rundown): keep entry revisions in step with the server
An entry revision advances on every change to that entry, which makes it a
cheap marker for what has moved on in a rundown. The client did not maintain
it: an optimistic entry carried the old revision, so the entry the server
returned always differed from ours by that one field.

That difference is enough to defeat structural sharing. A new entries object
recomputes the rundown metadata, which fires the effect behind the list, which
re-renders every visible row. Editing one title on a fourteen entry rundown
cost eight structural and sixty-one attribute mutations, twice over.

- optimistic patches apply the same revision bump the server does, so an
  unchanged entry keeps its identity and a refetch costs nothing to render
- the rundown metadata memo keys on the fields the derivation reads, so a
  change of rundown revision alone no longer churns the list
- editing an entry now reads the server response, which is the authority on
  what was applied, and only writes when it differs from what we predicted

Measured on the same edit: eight to four structural, sixty-one to forty-one
attribute mutations. Both changes are needed, neither helps on its own.

Two bugs found on the way. A swap stored the pre increment revision, so
swapped entries were indistinguishable from untouched ones. The optimistic
batch edit spread the request envelope onto the entry rather than the patch,
so it never applied the edit it was predicting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-30 12:04:47 +00:00
Claude a5086aed0b fix(rundown): narrow rundown invalidation
Two independent issues in the revision strategy.

The client guard only skipped a refetch when the revision matched exactly,
while its comment described skipping anything not newer. A message which
arrives after a refetch has already brought in a later revision forced a
needless refetch. Compare with <= so the guard does what it claims.

Renaming a rundown re-initialised it, which stops playback. Renaming the
loaded rundown during a show therefore stopped the show. A title has no
bearing on the schedule, so it now goes through an ordinary transaction:
the title is persisted, the revision bumped and clients notified, with no
runtime involvement. Renaming to the current title is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-30 10:51:20 +00:00
Claude 1fa6175fb8 refactor(rundown): keep runtime views on the loaded rundown
Only the editor and the cuesheet can be pointed at a background rundown.
Everything else always works against the rundown being played, so it has
no business taking part in a rundown scope.

Split the readers by that assumption:

- useRundownById holds the query and the shared derivation
- useRundown reads from the enclosing scope, for the editor and cuesheet
- useLoadedRundown resolves the loaded rundown directly, for the viewers,
  the operator, app settings and the overview

This drops the app wide scope provider, so views which read no rundown
(studio, project info) no longer subscribe to the rundown list, and a
scope becomes what it claims to be: a panel concern which throws when
used outside a panel.

It also fixes the overview, which reads runtime ids (the playing group,
the next flag). Those only exist in the loaded rundown, so resolving them
against a scope returned nothing while a background rundown was open in
the cuesheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 17:48:42 +00:00
Claude bc8407c0e6 refactor(rundown): address review on the rundown scope
- extract getRundownCacheKey, the fallback to the bootstrap alias for an
  unresolved rundown id was spelled out in two readers and missing in the
  selection store, which could not resolve shift/ctrl ranges during startup
- an unresolved target is no longer reported as the loaded rundown, it was
  briefly enabling run mode and playback following before a rundown existed
- only the reader which bootstrapped drops the /current alias, a reader on
  another rundown was evicting a cache others still relied on
- the selection store returns a new Set on toggle and unselect, components
  selecting the set by reference never saw the change
- useSelectAndRevealEntry takes its rundown from the scope, callers were
  threading an id for no other reason

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 17:21:24 +00:00
Claude 802521c706 refactor(rundown): resolve rundown data from a scope context
The editor and cuesheet had three overlapping notions of which rundown
they were showing: loaded-only data hooks, a prop-drilled RundownSource,
and a selection store which read the loaded rundown out of the query
cache. Rendering two rundowns at once was not possible.

Introduce a rundown scope: a context carrying the rundown identity
(id, isLoaded and a selection store) which the utility hooks read from.
useRundown, useEntry and friends keep their signatures and call sites,
they simply resolve their rundown from the enclosing scope. The app
mounts a scope at the root which follows the loaded rundown, so runtime
views are unaffected; the editor and cuesheet mount an editable scope
which also binds entry actions to the same rundown.

- useEventSelection becomes a per-scope store factory, so panels hold
  independent cursors and the store is handed its rundown instead of
  reaching for the loaded one
- the cuesheet selection is generalised to useRundownSelection with a
  namespace, so each surface persists its own choice
- the clipboard records the rundown an entry was copied from, leaving
  room for pasting across rundowns
- removes useScopedRundown and the prop-drilled RundownSource

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 13:21:59 +00:00
83 changed files with 840 additions and 1223 deletions
+4 -1
View File
@@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -112,7 +113,9 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<RundownPanel />
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper>
}
/>
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
@@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -0,0 +1,77 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -0,0 +1,81 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
+31 -100
View File
@@ -1,62 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useRundownScope } from '../context/RundownScopeContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* 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`.
* Normalised rundown data for the rundown of the enclosing scope
*/
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// 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 };
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const selectedEventId = useScopedSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
@@ -66,41 +42,23 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const selectedEventId = useScopedSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
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,66 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -1,54 +0,0 @@
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],
);
}
@@ -1,13 +1,13 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
getRundownSelectionStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
} from '../useRundownSelection';
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',
describe('useRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project',
);
});
@@ -1,81 +0,0 @@
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
import { resolveTimerDisplay } from '../useSocket.utils';
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
const groupTimer = {
...runtimeStorePlaceholder.timer,
current: 25_000,
phase: TimerPhase.Default,
playback: Playback.Play,
};
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
return {
...runtimeStorePlaceholder,
timer: eventTimer,
eventNow: {
id: 'event-1',
timerType: TimerType.CountUp,
countToEnd: true,
} as RuntimeStore['eventNow'],
...patch,
};
}
describe('resolveTimerDisplay()', () => {
it('uses the event timer by default', () => {
expect(resolveTimerDisplay(makeState())).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
it('uses the group timer and display type when enabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display).toMatchObject({
time: groupTimer,
timerType: TimerType.CountDown,
countToEnd: false,
usesGroupTimer: true,
eventTimer,
eventTimerType: TimerType.CountUp,
});
});
it('ignores a group timer when the group setting is disabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display.time).toBe(eventTimer);
expect(display.usesGroupTimer).toBe(false);
});
it('falls back entirely to the event display while group timer data is unavailable', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer: null,
}),
);
expect(display).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
});
+47 -18
View File
@@ -8,7 +8,6 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -32,9 +31,10 @@ import {
swapEventData,
} from 'ontime-utils';
import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import { getRundownCacheKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,6 +52,7 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -64,22 +65,38 @@ export type EventOptions = Partial<{
lastEventId: MaybeString;
}>;
/**
* Applies a patch the way the server does, revision included.
*
* An entry revision advances on every change to that entry, which makes it a
* cheap marker for whether an entry has moved on. The optimistic entry has to
* carry the revision the server will return, otherwise the two disagree and the
* refetch resolves to a different object for no reason.
* Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
*/
function patchEntry(entry: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
}
return { ...entry, ...patch } as OntimeEntry;
}
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
* Gather utilities for actions on entries in the rundown of the enclosing scope.
*/
export const useEntryActions = () => useEntryActionsForRundown(undefined);
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
const previousEntry = newRundown[eventId];
if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Return a context with the previous and new events
return { previousData, newEvent, queryKey };
},
// the server is the authority on the applied patch, it may normalise what we sent
onSuccess: (response, _variables, context) => {
const serverEntry = response.data;
if (!serverEntry || !context?.queryKey) return;
const cachedRundown = queryClient.getQueryData<Rundown>(context.queryKey);
if (!cachedRundown) return;
// our optimistic entry usually describes the change exactly, writing an
// identical entry would discard the cached reference for nothing
if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
queryClient.setQueryData<Rundown>(context.queryKey, {
...cachedRundown,
entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
});
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) {
@@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
newRundown[eventId] = patchEntry(event, data.data);
}
}
});
@@ -2,14 +2,21 @@ 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';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
/** each surface keeps its own selection, so panels can point at different rundowns */
export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
/**
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() {
return {
loadedRundownId,
selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
@@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
+24 -35
View File
@@ -1,8 +1,7 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket';
import { resolveTimerDisplay } from './useSocket.utils';
const createSelector =
<T>(selector: (state: RuntimeStore) => T) =>
@@ -39,19 +38,15 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'secondary',
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => {
const timerDisplay = resolveTimerDisplay(state);
return {
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: timerDisplay.time.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: timerDisplay.timerType,
countToEnd: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
};
});
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
@@ -235,26 +230,20 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
/* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => {
const timerDisplay = resolveTimerDisplay(state);
return {
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: timerDisplay.time,
eventTimer: timerDisplay.eventTimer,
clock: state.clock,
timerTypeNow: timerDisplay.timerType,
eventTimerType: timerDisplay.eventTimerType,
countToEndNow: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
};
});
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
clock: state.clock,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
countToEndNow: state.eventNow?.countToEnd ?? false,
auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
}));
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
@@ -1,27 +0,0 @@
import { RuntimeStore, TimerType } from 'ontime-types';
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
export function resolveTimerDisplay(state: TimerDisplaySource) {
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
return {
time: state.groupTimer,
timerType: state.groupNow.timerType,
countToEnd: false,
usesGroupTimer: true,
eventTimer: state.timer,
eventTimerType,
};
}
return {
time: state.timer,
timerType: eventTimerType,
countToEnd: state.eventNow?.countToEnd ?? false,
usesGroupTimer: false,
eventTimer: state.timer,
eventTimerType,
};
}
@@ -0,0 +1,64 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,14 +1,21 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
}));
@@ -0,0 +1,158 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -1,6 +1,6 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
import { initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => {
@@ -300,36 +300,3 @@ describe('initRundownMetadata()', () => {
});
});
});
describe('getFlatRundownMetadata()', () => {
it('exposes group timer settings on a group and its events', () => {
const group = {
id: 'group',
type: SupportedEntry.Group,
entries: ['event'],
colour: 'red',
useGroupTimer: true,
timerType: TimerType.CountUp,
} as OntimeGroup;
const event = {
id: 'event',
type: SupportedEntry.Event,
parent: group.id,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent;
const flat = getFlatRundownMetadata(
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
null,
);
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
});
});
@@ -3,11 +3,9 @@ import {
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PlayableEvent,
Rundown,
TimerType,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
@@ -31,11 +29,7 @@ export type RundownMetadata = {
isFirstAfterGroup: boolean;
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
RundownMetadata & {
groupUsesTimer?: boolean;
groupTimerType?: TimerType;
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
export const lastMetadataKey = 'LAST';
@@ -71,23 +65,10 @@ export function getFlatRundownMetadata(
): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = [];
let activeGroup: OntimeGroup | null = null;
for (const id of data.flatOrder) {
const entry = data.entries[id];
if (isOntimeGroup(entry)) {
activeGroup = entry;
} else if (entry.parent !== activeGroup?.id) {
activeGroup = null;
}
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
const extendedEntry = {
...entry,
...process(entry),
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
groupTimerType: timerGroup?.timerType,
};
const extendedEntry = { ...entry, ...process(entry) };
flatRundown.push(extendedEntry);
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision === cachedRundown?.revision) {
// we already have the latest change
// we already have this change, or something newer
// messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return;
}
@@ -4,8 +4,8 @@ import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown';
import useReport from '../../../../common/hooks-query/useReport';
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();
const { data } = useLoadedRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
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';
@@ -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 { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
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();
const { data: currentRundown } = useLoadedRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -27,13 +27,6 @@
border-top: 1px solid $white-7;
}
.timerSource {
color: $active-indicator;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.blackout {
display: none;
}
@@ -1,5 +1,5 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,8 +20,7 @@ const secondarySourceLabels: Record<string, string> = {
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
useMessagePreview();
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => {
@@ -36,9 +35,7 @@ export default function TimerPreview() {
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage) return null;
if (usesGroupTimer) return 'Event timer';
if (!secondarySource) return null;
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
@@ -58,7 +55,6 @@ export default function TimerPreview() {
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
@@ -69,14 +65,6 @@ export default function TimerPreview() {
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Timer display controlled by group'
render={<span />}
className={style.statusIcon}
data-active={usesGroupTimer}
>
<IoTimerOutline />
</Tooltip>
<Tooltip
text='Time type: Count down'
render={<span />}
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import {
useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const group = useLoadedEntry(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(id) as OntimeEvent | null;
const entry = useLoadedEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
const { data: rundownData } = useLoadedRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+5 -1
View File
@@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
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 { 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';
@@ -39,28 +36,25 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<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 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>
</div>
</ProtectRoute>
);
}
@@ -70,30 +64,28 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<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}>
<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>
</div>
</ProtectRoute>
);
}
@@ -105,8 +97,6 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog />
</div>
);
@@ -1,15 +1,22 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList);
function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -32,12 +32,6 @@
gap: 1rem;
}
.timerDisplaySettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column {
display: flex;
flex-direction: column;
@@ -1,12 +1,10 @@
import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
import { MaybeNumber, OntimeGroup } from 'ontime-types';
import { millisToString } from 'ontime-utils';
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 Select from '../../../common/components/select/Select';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
@@ -109,38 +107,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
</div>
<div className={style.column}>
<Editor.Title>Timer display</Editor.Title>
<div className={style.timerDisplaySettings}>
<div>
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
<div>
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
<Select
id='groupTimerType'
disabled={!group.useGroupTimer}
value={group.timerType}
onValueChange={(timerType: TimerType | null) => {
if (timerType !== null) updateEntry({ id: group.id, timerType });
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
]}
/>
</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>
Custom Fields
@@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
@@ -26,6 +27,7 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -43,12 +45,17 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
@@ -65,7 +72,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -74,7 +81,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -85,7 +92,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
[entries, flatOrder, clone, reorderEntry, rundownId],
);
/**
@@ -335,7 +335,6 @@ export default function RundownEvent({
eventIndex={eventIndex}
endAction={endAction}
timerType={timerType}
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
title={title}
note={note}
delay={delay}
@@ -9,7 +9,6 @@ import {
IoPlayForward,
IoPlaySkipForward,
IoTime,
IoTimerOutline,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
@@ -36,7 +35,6 @@ interface RundownEventInnerProps {
eventIndex: number;
endAction: EndAction;
timerType: TimerType;
groupTimerType?: TimerType;
title: string;
note: string;
delay: number;
@@ -64,7 +62,6 @@ function RundownEventInner({
countToEnd,
endAction,
timerType,
groupTimerType,
title,
note,
delay,
@@ -153,14 +150,6 @@ function RundownEventInner({
{loaded && <EventBlockProgressBar />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
{groupTimerType && (
<Tooltip
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span />}
>
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
</Tooltip>
)}
<Tooltip text={`Time type: ${timerType}`} render={<span />}>
<TimerIcon type={timerType} className={style.statusIcon} />
</Tooltip>
@@ -55,14 +55,6 @@
gap: 0.5rem;
}
.timerIndicator {
display: grid;
flex: 0 0 1.5rem;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
.metaRow {
display: flex;
gap: $block-clearance; // same as RundownEvent.eventTimers
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useCallback, useRef } from 'react';
import {
@@ -9,7 +9,6 @@ import {
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTimerOutline,
IoTrash,
IoLockClosed,
} from 'react-icons/io5';
@@ -175,14 +174,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.header}>
<div className={style.titleRow}>
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
{data.useGroupTimer && (
<Tooltip
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span className={style.timerIndicator} />}
>
<IoTimerOutline />
</Tooltip>
)}
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
@@ -1,9 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
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';
@@ -15,8 +12,6 @@ function RundownTable() {
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -32,10 +27,8 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
);
}
@@ -1,155 +1,15 @@
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useStore } from 'zustand';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
* Selection and cursor for the rundown of the enclosing scope
*/
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
const { selectionStore } = useRundownScope();
return useStore(selectionStore, selector);
}
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState,
} from 'react';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(filterCallback);
const { data: events } = useLoadedPartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,35 +3,31 @@ 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 { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import { useRundownSelection } from '../../common/hooks/useRundownSelection';
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);
const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } =
useRundownSelection('cuesheet');
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<EntryActionsProvider actions={actions}>
<EditableRundownScopeProvider rundownId={scopedRundownId}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
@@ -42,13 +38,12 @@ export default function CuesheetPage() {
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
</EditableRundownScopeProvider>
);
}
@@ -3,19 +3,18 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection';
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;
@@ -24,15 +23,14 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { isLoaded } = useRundownScope();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded });
const { data: customFields } = useCustomFields();
const columns = useMemo(
@@ -44,11 +42,9 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}>
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
@@ -1,16 +1,13 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) {
function EntryEditModal() {
const { data: rundown } = useRundown();
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -14,7 +14,8 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -41,20 +42,17 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: CuesheetColumnDef[];
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;
@@ -62,13 +60,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { isLoaded: isCurrentRundown } = useRundownScope();
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -1,13 +0,0 @@
.timerOverrideCell {
display: grid;
grid-template-columns: minmax(0, 1fr) 1.5rem;
align-items: center;
gap: 0.25rem;
}
.timerOverrideIndicator {
display: grid;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
@@ -1,18 +1,8 @@
import {
CustomFields,
TimeStrategy,
TimerType,
URLPreset,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback } from 'react';
import { IoTimerOutline } from 'react-icons/io5';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
@@ -27,8 +17,6 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
import style from './cuesheetColsFactory.module.scss';
function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
@@ -205,38 +193,17 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
}
const canWrite = column.columnDef.meta?.canWrite;
const content = canWrite ? (
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
) : (
<GhostedText>{initialValue}</GhostedText>
);
if (column.id !== 'title') {
return content;
}
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
if (!isGroupOverride && !isEventOverride) {
return content;
}
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
return (
<div className={style.timerOverrideCell}>
{content}
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
<IoTimerOutline />
</Tooltip>
</div>
);
}
+23 -15
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -21,7 +22,9 @@ export default function Editor() {
<TimerControl />
<MessageControl />
</div>
<Rundown />
<EditableRundownScopeProvider rundownId={null}>
<Rundown />
</EditableRundownScopeProvider>
</div>
);
}
@@ -29,14 +32,17 @@ export default function Editor() {
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} />
{/* the titles and the rundown share a scope, so they follow one cursor */}
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
<TrackingPlaybackBar />
</div>
);
@@ -44,14 +50,16 @@ export default function Editor() {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
</div>
);
}
@@ -207,10 +207,10 @@ export function searchByText(
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown();
const { data } = useFlatRundown();
const { data: customFields } = useCustomFields();
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
@@ -35,14 +35,6 @@
width: 100%;
overflow: hidden;
.timer-source {
color: $viewer-label-color;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.timer {
opacity: 1;
font-family: $viewer-font-family;
@@ -7,7 +7,6 @@ import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
@@ -24,18 +23,7 @@ interface PipTimerProps {
}
export function PipTimer({ viewSettings }: PipTimerProps) {
const {
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
@@ -71,9 +59,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return null;
})();
const secondaryContent = usesGroupTimer
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
@@ -91,7 +77,6 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
</div>
<div className='timer-container'>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
@@ -111,11 +96,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={usesGroupTimer ? undefined : eventNow?.id}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData}
selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/>
);
}
@@ -56,21 +55,14 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
resolvedFollowEventId: string | null;
rundownId: string;
}
function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
const selectAndRevealEntry = useSelectAndRevealEntry();
// Calculate current event info
const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
-8
View File
@@ -89,14 +89,6 @@
width: 100%;
overflow: hidden;
.timer-source {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.end-message {
text-align: center;
font-size: 11.5vw;
+13 -31
View File
@@ -22,7 +22,6 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
import {
getCardData,
getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying,
getSecondaryDisplay,
getShowClock,
@@ -53,19 +52,7 @@ export default function TimerLoader() {
}
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const {
eventNext,
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const {
hideClock,
hideCards,
@@ -91,7 +78,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
@@ -141,18 +128,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
return null;
})();
const secondaryContent =
usesGroupTimer && !hideSecondary
? getEventTimerSecondary(
eventTimer,
eventTimerType,
clock,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
timeformat,
)
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
const secondaryContent = getSecondaryDisplay(
message,
currentAux,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
hideSecondary,
);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
@@ -193,7 +176,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
{showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'>
{freezeMessage}
@@ -220,11 +202,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={usesGroupTimer ? undefined : eventNow?.id}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -1,32 +1,6 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { TimerPhase } from 'ontime-types';
import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
describe('getEventTimerSecondary()', () => {
it('formats the event countdown as a labelled secondary value', () => {
expect(
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
).toBe('Event timer 00:01:05');
});
it('preserves the event count-up display', () => {
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('falls back to remaining time when the event timer is hidden', () => {
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('shows event progress instead of wall-clock time for clock events', () => {
expect(
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
).toBe('Event timer 00:00:05');
});
});
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
+1 -21
View File
@@ -6,12 +6,11 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
/**
* Whether a message should be shown
@@ -145,25 +144,6 @@ export function getSecondaryDisplay(
return;
}
export function getEventTimerSecondary(
timer: Pick<TimerState, 'current' | 'elapsed'>,
timerType: TimerType,
clock: number,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
clockFormat?: string | null,
): string {
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
const value = getTimerByType(false, effectiveType, clock, timer);
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
removeSeconds,
removeLeadingZero,
clockFormat,
});
return `Event timer ${display}`;
}
/**
* What should we be showing in the cards?
*/
+2 -2
View File
@@ -1,8 +1,8 @@
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
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();
const { data: rundown, status: rundownStatus } = useLoadedRundown();
const { entries } = rundown;
return {
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null,
duration: 0,
isFirstLinked: false,
} as unknown as OntimeEntry);
});
} else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
}
@@ -1718,16 +1718,18 @@ describe('rundownMutation.swap()', () => {
expect((testRundown.entries['1'] as OntimeEvent).id).toBe('1');
expect((testRundown.entries['1'] as OntimeEvent).cue).toBe('data2');
expect((testRundown.entries['1'] as OntimeEvent).timeStart).toBe(1);
expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(1);
// the swapped entries changed, their revision has to say so
expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(2);
expect((testRundown.entries['2'] as OntimeEvent).id).toBe('2');
expect((testRundown.entries['2'] as OntimeEvent).cue).toBe('data1');
expect((testRundown.entries['2'] as OntimeEvent).timeStart).toBe(2);
expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(1);
expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(2);
expect((testRundown.entries['3'] as OntimeEvent).id).toBe('3');
expect((testRundown.entries['3'] as OntimeEvent).cue).toBe('data3');
expect((testRundown.entries['3'] as OntimeEvent).timeStart).toBe(3);
// untouched, so it keeps its revision
expect((testRundown.entries['3'] as OntimeEvent).revision).toBe(1);
});
});
@@ -1,4 +1,4 @@
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,13 +276,7 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
expect(parsedRundown.entries).toMatchObject({
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['1', '2'],
useGroupTimer: false,
timerType: TimerType.CountDown,
},
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
'1': { id: '1', type: SupportedEntry.Event },
'2': { id: '2', type: SupportedEntry.Milestone },
});
@@ -25,7 +25,6 @@ import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
cloneEntryData,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -37,25 +36,6 @@ import {
} from '../rundown.utils.js';
describe('test event validator', () => {
it('creates groups with the shared timer disabled by default', () => {
expect(createGroup({ id: 'group' })).toMatchObject({
useGroupTimer: false,
timerType: TimerType.CountDown,
});
});
it('limits group timers to count down and count up', () => {
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
});
it('rejects non-boolean group timer updates', () => {
const group = createGroup({ useGroupTimer: false });
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
expect(updated.useGroupTimer).toBe(false);
});
it('validates a good object', () => {
const event = {
title: 'test',
@@ -422,7 +422,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
gap: eventFrom.gap,
dayOffset: eventFrom.dayOffset,
// keep revision number but increment it
revision: eventFrom.revision++,
revision: eventFrom.revision + 1,
};
rundown.entries[eventTo.id] = {
@@ -440,7 +440,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
gap: eventTo.gap,
dayOffset: eventTo.dayOffset,
// keep revision number but increment it
revision: eventTo.revision++,
revision: eventTo.revision + 1,
};
}
@@ -816,27 +816,23 @@ export async function createNewRundown(title: string) {
* @throws if the provided id does not exist
*/
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const { rundown, commit } = createTransaction({ rundownId: id, mutableRundown: true });
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
/**
* If we are modifying the loaded rundown we re-init it
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else {
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
if (rundown.title === title) {
return getDataProvider().getProjectRundowns();
}
return dataProvider.getProjectRundowns();
rundown.title = title;
// a title has no bearing on the schedule, there is nothing to process and no runtime to notify
const { rundownMetadata, revision } = await commit(false);
setImmediate(() => {
notifyChanges(id, rundownMetadata, revision, { external: true });
sendRefetch(RefetchKey.ProjectRundowns);
});
return getDataProvider().getProjectRundowns();
}
/**
@@ -33,7 +33,6 @@ import {
makeString,
maxDuration,
validateEndAction,
validateGroupTimerType,
validateTimerType,
validateTimes,
} from 'ontime-utils';
@@ -181,9 +180,6 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
-1
View File
@@ -206,7 +206,6 @@ 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,
-22
View File
@@ -39,8 +39,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -174,8 +172,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -223,8 +219,6 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -366,8 +360,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -484,8 +476,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -598,8 +588,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -646,8 +634,6 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -782,8 +768,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -867,8 +851,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -977,8 +959,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1049,8 +1029,6 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -4,7 +4,6 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getGroupTimer,
getCurrent,
getElapsed,
getExpectedFinish,
@@ -17,91 +16,6 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getGroupTimer()', () => {
const makeState = (patch: Partial<RuntimeState> = {}) =>
({
clock: asTimeOfDay(10_000),
groupNow: {
duration: 20_000,
},
rundown: {
actualGroupStart: 5_000,
},
timer: {
addedTime: 4_000,
current: 1_000,
elapsed: 99_000,
phase: TimerPhase.Danger,
playback: Playback.Play,
},
...patch,
}) as RuntimeState;
it('returns null outside a group', () => {
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
});
it('derives elapsed and remaining time from the group start and duration', () => {
expect(getGroupTimer(makeState())).toMatchObject({
addedTime: 0,
current: 15_000,
duration: 20_000,
elapsed: 5_000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 5_000,
});
});
it('does not inherit event timer progress or added time', () => {
const first = getGroupTimer(makeState());
const second = getGroupTimer(
makeState({
timer: {
...makeState().timer,
addedTime: -8_000,
current: -50_000,
elapsed: 500_000,
},
}),
);
expect(second).toEqual(first);
});
it('keeps running while the loaded event is paused', () => {
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
expect(timer?.playback).toBe(Playback.Play);
});
it('handles a group running across midnight', () => {
const timer = getGroupTimer(
makeState({
clock: asTimeOfDay(1_000),
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
}),
);
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
});
it('uses the event phase before the group starts and overtime afterwards', () => {
const pending = getGroupTimer(
makeState({
rundown: { ...makeState().rundown, actualGroupStart: null },
timer: { ...makeState().timer, phase: TimerPhase.Pending },
}),
);
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
});
});
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
@@ -7,7 +7,6 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
@@ -96,34 +95,6 @@ describe('getShouldTimerUpdate()', () => {
});
});
describe('getShouldGroupTimerUpdate()', () => {
const timer: TimerState = {
addedTime: 0,
current: 10_000,
duration: 10_000,
elapsed: 0,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 0,
};
it('updates when a group timer appears or disappears', () => {
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
});
it('does not repeatedly publish an absent group timer', () => {
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
});
it('uses normal timer tick semantics while a group timer exists', () => {
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
});
});
describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = {
absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js';
import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import { EventTimer } from '../EventTimer.js';
import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,7 +36,6 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
@@ -52,11 +51,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1;
/** last known state */
static previousState: RuntimeStateSnapshot;
static previousState: RuntimeState;
constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeStateSnapshot;
RuntimeService.previousState = {} as RuntimeState;
}
@broadcastResult
@@ -711,12 +710,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
if (updateGroupTimer) {
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
@@ -54,15 +54,6 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
);
}
export function getShouldGroupTimerUpdate(
previousValue: TimerState | null | undefined,
currentValue: TimerState | null,
): boolean {
if (previousValue === undefined) return true;
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
return getShouldTimerUpdate(previousValue, currentValue);
}
/**
* Checks whether we should update the offset values
* - `mode` triggers update
+1 -32
View File
@@ -1,4 +1,4 @@
import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,37 +111,6 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return Math.max(0, activeElapsed);
}
/**
* Derives a timer for the active group from wall-clock time.
* Event timer controls such as pause and add time intentionally do not affect it.
*/
export function getGroupTimer(state: RuntimeState): TimerState | null {
if (state.groupNow === null) {
return null;
}
const { actualGroupStart } = state.rundown;
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
let phase = state.timer.phase;
if (actualGroupStart !== null) {
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
}
return {
addedTime: 0,
current,
duration: state.groupNow.duration,
elapsed,
expectedFinish: null,
phase,
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
secondaryTimer: null,
startedAt: actualGroupStart,
};
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) {
return clock + dayInMs - startedAt;
+1 -6
View File
@@ -13,7 +13,6 @@ import {
Playback,
Rundown,
RundownState,
RuntimeStore,
TimeOfDay,
TimerPhase,
TimerState,
@@ -40,7 +39,6 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -106,9 +104,7 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
export function getState(): Readonly<RuntimeStateSnapshot> {
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
...runtimeState,
@@ -119,7 +115,6 @@ export function getState(): Readonly<RuntimeStateSnapshot> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
+4
View File
@@ -9,6 +9,10 @@ Load only for touched domains. Add only stable, recurring invariants; not one-of
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
- No caller-owned rundown mutation unless explicitly contracted.
- Revisions mark change: a rundown revision advances on every commit, an entry revision on every
change to that entry. Delays carry no revision. Optimistic client updates apply the same bump the
server does, so an unchanged entry keeps its identity and clients can compare revisions to tell
what moved on.
## Persistence, realtime, cache
@@ -44,8 +44,6 @@ export type OntimeGroup = OntimeBaseEvent & {
note: string;
entries: EntryId[];
targetDuration: MaybeNumber;
useGroupTimer: boolean;
timerType: TimerType;
colour: string;
custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! //
@@ -17,7 +17,6 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null,
message: {
timer: {
text: '',
@@ -9,7 +9,6 @@ export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
groupTimer: TimerState | null;
// messages service
message: MessageState;
+1 -8
View File
@@ -34,14 +34,7 @@ export {
group as groupDef,
milestone as milestoneDef,
} from './src/rundown-utils/entryDefinitions.js';
export {
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateGroupTimerType,
} from './src/rundown-utils/entryUtils.js';
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
// time format utils
export {
@@ -52,8 +52,6 @@ export const group: Omit<OntimeGroup, 'id'> = {
note: '',
entries: [],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '',
custom: {},
// !==== RUNTIME METADATA ====! //
+1 -13
View File
@@ -1,5 +1,5 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { SupportedEntry, TimeStrategy } from 'ontime-types';
import { generateId } from '../generate-id/generateId.js';
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
@@ -67,8 +67,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
note: patch.note ?? '',
entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null,
useGroupTimer: patch.useGroupTimer === true,
timerType: validateGroupTimerType(patch.timerType),
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
@@ -79,16 +77,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
};
}
export function validateGroupTimerType(
value: unknown,
fallback: unknown = TimerType.CountDown,
): TimerType.CountDown | TimerType.CountUp {
if (value === TimerType.CountDown || value === TimerType.CountUp) {
return value;
}
return fallback === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
}
/**
* Creates a new milestone from an optional patch
*/