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