diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 28b5498dd..b723476bf 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -9,7 +9,6 @@ import AppRouter from './AppRouter'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay'; import { AppContextProvider } from './common/context/AppContext'; -import { RundownScopeProvider } from './common/context/RundownScopeContext'; import { ontimeQueryClient } from './common/queryClient'; import { connectSocket } from './common/utils/socket'; import { baseURI } from './externals'; @@ -29,10 +28,7 @@ function App() { - {/* the app follows the loaded rundown, panels nest a scope to point elsewhere */} - - - + diff --git a/apps/client/src/common/hooks-query/useLoadedRundown.ts b/apps/client/src/common/hooks-query/useLoadedRundown.ts new file mode 100644 index 000000000..f1da2e6df --- /dev/null +++ b/apps/client/src/common/hooks-query/useLoadedRundown.ts @@ -0,0 +1,73 @@ +import { EntryId, OntimeEntry } from 'ontime-types'; +import { useMemo } from 'react'; + +import { useSelectedEventId } from '../hooks/useSocket'; +import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { useProjectRundowns } from './useProjectRundowns'; +import { flattenRundown, useRundownById } from './useRundownById'; + +/** + * Rundown data for surfaces which only ever work against the rundown being played: + * the viewers, the operator, app settings and the runtime overview. + * + * These take no part in a rundown scope, they resolve the loaded rundown directly. + * Anything which can be pointed at a background rundown reads from its scope instead. + */ +export function useLoadedRundown() { + const { + data: { loaded }, + } = useProjectRundowns(); + return useRundownById(loaded); +} + +export function useLoadedRundownWithMetadata() { + const { data, status } = useLoadedRundown(); + const selectedEventId = useSelectedEventId(); + const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); + return { data, status, rundownMetadata }; +} + +export function useLoadedFlatRundown() { + const { data, status } = useLoadedRundown(); + const flatRundown = useMemo(() => flattenRundown(data), [data]); + return { data: flatRundown, rundownId: data.id, status }; +} + +export function useLoadedFlatRundownWithMetadata() { + const { data, status } = useLoadedRundown(); + const selectedEventId = useSelectedEventId(); + const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); + return { data: rundownWithMetadata, status }; +} + +/** + * Provides access to a partial rundown based on a filter callback + * + * Callers MUST memoize the callback with useCallback to prevent + * re-filtering on every render. + */ +export function useLoadedPartialRundown(cb: (event: ExtendedEntry) => boolean) { + const { data, status } = useLoadedFlatRundownWithMetadata(); + const filteredData = useMemo(() => data.filter(cb), [data, cb]); + return { data: filteredData, status }; +} + +/** + * Hook to get a specific entry by ID from the loaded rundown. + * Runtime ids (the playing event, its group, the next flag) only exist here. + */ +export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null { + const { data: rundown } = useLoadedRundown(); + + if (entryId === null) return null; + return rundown.entries[entryId] ?? null; +} + +export function useLoadedRundownAuxData() { + const { data, status } = useLoadedRundown(); + const filteredData = useMemo(() => { + const { title, id } = data; + return { title, id }; + }, [data]); + return { data: filteredData, status }; +} diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index c0d1f7551..51b84bcbe 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,60 +1,10 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; -import { useEffect, useMemo, useRef } from 'react'; +import { EntryId, OntimeEntry } from 'ontime-types'; +import { useMemo } from 'react'; -import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants'; -import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; import { useRundownScope } from '../context/RundownScopeContext'; import { useSelectedEventId } from '../hooks/useSocket'; -import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; - -// revision is -1 so that the remote revision is higher -const cachedRundownPlaceholder: Rundown = { - id: 'default', - title: '', - order: [], - flatOrder: [], - entries: {}, - revision: -1, -}; - -/** - * Provides access to a specific rundown by ID. - * - * Without an ID we do not yet know which rundown is loaded, so we bootstrap via - * the `/current` alias to keep the first paint to a single round-trip, then seed - * the id-keyed cache that every other reader shares. - */ -export function useRundownById(rundownId: string | null | undefined) { - const queryClient = useQueryClient(); - const id = rundownId ?? ''; - const isBootstrap = id === ''; - - const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: getRundownCacheKey(id), - queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })), - placeholderData: (previousData, _previousQuery) => previousData, - refetchInterval: queryRefetchIntervalSlow, - }); - - // Seed the id-keyed cache when fetching via the bootstrap alias - useEffect(() => { - if (!data || !isBootstrap) return; - queryClient.setQueryData(getRundownQueryKey(data.id), data); - }, [data, isBootstrap, queryClient]); - - // Once we have the ID, drop the temporary current cache. - // Only the reader which bootstrapped may do so, others are still relying on it. - const didBootstrap = useRef(isBootstrap); - useEffect(() => { - if (isBootstrap || !didBootstrap.current) return; - didBootstrap.current = false; - queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); - }, [isBootstrap, queryClient]); - - return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; -} +import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; +import { flattenRundown, useRundownById } from './useRundownById'; /** * Normalised rundown data for the rundown of the enclosing scope @@ -87,13 +37,7 @@ export function useRundownWithMetadata() { */ export function useFlatRundown() { const { data, status } = useRundown(); - - const flatRundown = useMemo(() => { - if (data.revision === -1) { - return []; - } - return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined); - }, [data]); + const flatRundown = useMemo(() => flattenRundown(data), [data]); return { data: flatRundown, rundownId: data.id, status }; } @@ -106,22 +50,6 @@ export function useFlatRundownWithMetadata() { return { data: rundownWithMetadata, status }; } -/** - * Provides access to a partial rundown based on a filter callback - * - * Callers MUST memoize the callback with useCallback to prevent - * re-filtering on every render. - * - */ -export function usePartialRundown(cb: (event: ExtendedEntry) => boolean) { - const { data, status } = useFlatRundownWithMetadata(); - const filteredData = useMemo(() => { - return data.filter(cb); - }, [data, cb]); - - return { data: filteredData, status }; -} - /** * Hook to get a specific entry by ID from the rundown */ @@ -131,12 +59,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null { if (entryId === null) return null; return rundown.entries[entryId] ?? null; } - -export function useRundownAuxData() { - const { data, status } = useRundown(); - const filteredData = useMemo(() => { - const { title, id } = data; - return { title, id }; - }, [data]); - return { data: filteredData, status }; -} diff --git a/apps/client/src/common/hooks-query/useRundownById.ts b/apps/client/src/common/hooks-query/useRundownById.ts new file mode 100644 index 000000000..f4631beb7 --- /dev/null +++ b/apps/client/src/common/hooks-query/useRundownById.ts @@ -0,0 +1,66 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { OntimeEntry, Rundown } from 'ontime-types'; +import { useEffect, useRef } from 'react'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants'; +import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; + +// revision is -1 so that the remote revision is higher +const cachedRundownPlaceholder: Rundown = { + id: 'default', + title: '', + order: [], + flatOrder: [], + entries: {}, + revision: -1, +}; + +/** + * Provides access to a specific rundown by ID. + * + * Without an ID we do not yet know which rundown is loaded, so we bootstrap via + * the `/current` alias to keep the first paint to a single round-trip, then seed + * the id-keyed cache that every other reader shares. + */ +export function useRundownById(rundownId: string | null | undefined) { + const queryClient = useQueryClient(); + const id = rundownId ?? ''; + const isBootstrap = id === ''; + + const { data, status, isError, refetch, isFetching } = useQuery({ + queryKey: getRundownCacheKey(id), + queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })), + placeholderData: (previousData, _previousQuery) => previousData, + refetchInterval: queryRefetchIntervalSlow, + }); + + // Seed the id-keyed cache when fetching via the bootstrap alias + useEffect(() => { + if (!data || !isBootstrap) return; + queryClient.setQueryData(getRundownQueryKey(data.id), data); + }, [data, isBootstrap, queryClient]); + + // Once we have the ID, drop the temporary current cache. + // Only the reader which bootstrapped may do so, others are still relying on it. + const didBootstrap = useRef(isBootstrap); + useEffect(() => { + if (isBootstrap || !didBootstrap.current) return; + didBootstrap.current = false; + queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); + }, [isBootstrap, queryClient]); + + return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; +} + +/** + * Builds a flat rundown from the order and entries fields + */ +export function flattenRundown(rundown: Rundown): OntimeEntry[] { + if (rundown.revision === -1) { + return []; + } + return rundown.flatOrder + .map((id) => rundown.entries[id]) + .filter((entry): entry is OntimeEntry => entry !== undefined); +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index 924bda3d4..e0a4483c5 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -4,8 +4,8 @@ import { IoTrashBin } from 'react-icons/io5'; import { deleteAllReport } from '../../../../common/api/report'; import { createBlob, downloadBlob } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; +import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown'; import useReport from '../../../../common/hooks-query/useReport'; -import useRundown from '../../../../common/hooks-query/useRundown'; import { cx } from '../../../../common/utils/styleUtils'; import { formatTime } from '../../../../common/utils/time'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss'; export default function ReportSettings() { const { data: reportData } = useReport(); - const { data } = useRundown(); + const { data } = useLoadedRundown(); const clearReport = async () => await deleteAllReport(); const downloadCSV = (combinedReport: CombinedReport[]) => { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx index 87180a840..7ef599955 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx @@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button'; import Info from '../../../../../common/components/info/Info'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import Modal from '../../../../../common/components/modal/Modal'; -import useRundown from '../../../../../common/hooks-query/useRundown'; +import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; import GSheetSetup from './GSheetSetup'; @@ -59,7 +59,7 @@ export default function SourcesPanel() { const [activeSource, setActiveSource] = useState(null); const [completedRundownTitle, setCompletedRundownTitle] = useState(''); - const { data: currentRundown } = useRundown(); + const { data: currentRundown } = useLoadedRundown(); const { applyImport } = useSpreadsheetImport(); const navigate = useNavigate(); diff --git a/apps/client/src/features/operator/useOperatorData.ts b/apps/client/src/features/operator/useOperatorData.ts index 8eeeae6b2..12dbda6eb 100644 --- a/apps/client/src/features/operator/useOperatorData.ts +++ b/apps/client/src/features/operator/useOperatorData.ts @@ -1,7 +1,7 @@ import { CustomFields, Rundown, Settings } from 'ontime-types'; import useCustomFields from '../../common/hooks-query/useCustomFields'; -import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; +import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown'; import useSettings from '../../common/hooks-query/useSettings'; import { RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils'; @@ -14,7 +14,7 @@ export interface OperatorData { } export function useOperatorData(): ViewData { - const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); + const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata(); const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: settings, status: settingsStatus } = useSettings(); diff --git a/apps/client/src/features/overview/composite/TimeElements.tsx b/apps/client/src/features/overview/composite/TimeElements.tsx index 27e8c8f50..c844c2f9b 100644 --- a/apps/client/src/features/overview/composite/TimeElements.tsx +++ b/apps/client/src/features/overview/composite/TimeElements.tsx @@ -12,7 +12,7 @@ import { } from 'react-icons/tb'; import Tooltip from '../../../common/components/tooltip/Tooltip'; -import { useEntry } from '../../../common/hooks-query/useRundown'; +import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown'; import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock'; import { useCurrentGroupId, @@ -210,7 +210,7 @@ export function MetadataTimes() { function GroupTimes() { const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const currentGroupId = useCurrentGroupId(); - const group = useEntry(currentGroupId) as OntimeGroup | null; + const group = useLoadedEntry(currentGroupId) as OntimeGroup | null; const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); @@ -266,7 +266,7 @@ function GroupTimes() { function FlagTimes() { const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { id, expectedStart } = useNextFlag(); - const entry = useEntry(id) as OntimeEvent | null; + const entry = useLoadedEntry(id) as OntimeEvent | null; const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); diff --git a/apps/client/src/features/overview/composite/TitleOverview.tsx b/apps/client/src/features/overview/composite/TitleOverview.tsx index 5f29c0bf3..7d8f3f586 100644 --- a/apps/client/src/features/overview/composite/TitleOverview.tsx +++ b/apps/client/src/features/overview/composite/TitleOverview.tsx @@ -1,12 +1,12 @@ +import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown'; import useProjectData from '../../../common/hooks-query/useProjectData'; -import { useRundownAuxData } from '../../../common/hooks-query/useRundown'; import style from './TitleOverview.module.scss'; export default function TitleOverview() { 'use memo'; const { data: projectData } = useProjectData(); - const { data: rundownData } = useRundownAuxData(); + const { data: rundownData } = useLoadedRundownAuxData(); const projectTitle = projectData.title.trim(); const rundownTitle = rundownData.title.trim(); diff --git a/apps/client/src/views/backstage/useBackstageData.ts b/apps/client/src/views/backstage/useBackstageData.ts index 1a804edac..ad5381240 100644 --- a/apps/client/src/views/backstage/useBackstageData.ts +++ b/apps/client/src/views/backstage/useBackstageData.ts @@ -1,8 +1,8 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import useCustomFields from '../../common/hooks-query/useCustomFields'; +import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { useFlatRundown } from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; @@ -20,7 +20,7 @@ export function useBackstageData(): ViewData { const isMirrored = useViewOptionsStore((state) => state.mirror); // HTTP API data - const { data: rundownData, status: rundownStatus } = useFlatRundown(); + const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown(); const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: settings, status: settingsStatus } = useSettings(); const { data: customFields, status: customFieldsStatus } = useCustomFields(); diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx index c260c1958..090011fe8 100644 --- a/apps/client/src/views/common/schedule/ScheduleContext.tsx +++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx @@ -12,7 +12,7 @@ import { useState, } from 'react'; -import { usePartialRundown } from '../../../common/hooks-query/useRundown'; +import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown'; import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { useScheduleOptions } from './schedule.options'; @@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre [filter], ); - const { data: events } = usePartialRundown(filterCallback); + const { data: events } = useLoadedPartialRundown(filterCallback); const [firstIndex, setFirstIndex] = useState(-1); const [numPages, setNumPages] = useState(0); diff --git a/apps/client/src/views/countdown/useCountdownData.ts b/apps/client/src/views/countdown/useCountdownData.ts index 9faaf6c82..0c45b24e1 100644 --- a/apps/client/src/views/countdown/useCountdownData.ts +++ b/apps/client/src/views/countdown/useCountdownData.ts @@ -1,8 +1,8 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import useCustomFields from '../../common/hooks-query/useCustomFields'; +import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { ExtendedEntry } from '../../common/utils/rundownMetadata'; @@ -21,7 +21,7 @@ export function useCountdownData(): ViewData { const isMirrored = useViewOptionsStore((state) => state.mirror); // HTTP API data - const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); + const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata(); const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: settings, status: settingsStatus } = useSettings(); const { data: customFields, status: customFieldsStatus } = useCustomFields(); diff --git a/apps/client/src/views/timeline/useTimelineData.ts b/apps/client/src/views/timeline/useTimelineData.ts index bd2f996f6..9abc78d94 100644 --- a/apps/client/src/views/timeline/useTimelineData.ts +++ b/apps/client/src/views/timeline/useTimelineData.ts @@ -1,8 +1,8 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import useCustomFields from '../../common/hooks-query/useCustomFields'; +import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; @@ -16,7 +16,7 @@ export interface TimelineData { export function useTimelineData(): ViewData { // HTTP API data - const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); + const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata(); const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: settings, status: settingsStatus } = useSettings(); const { data: customFields, status: customFieldsStatus } = useCustomFields(); diff --git a/apps/client/src/views/timer/useTimerData.ts b/apps/client/src/views/timer/useTimerData.ts index 406912e0b..6212b4945 100644 --- a/apps/client/src/views/timer/useTimerData.ts +++ b/apps/client/src/views/timer/useTimerData.ts @@ -1,8 +1,8 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types'; import useCustomFields from '../../common/hooks-query/useCustomFields'; +import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown'; import useProjectData from '../../common/hooks-query/useProjectData'; -import useRundown from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings'; import { useViewOptionsStore } from '../../common/stores/viewOptions'; @@ -26,7 +26,7 @@ export function useTimerData(): ViewData { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const { data: settings, status: settingsStatus } = useSettings(); const { data: customFields, status: customFieldsStatus } = useCustomFields(); - const { data: rundown, status: rundownStatus } = useRundown(); + const { data: rundown, status: rundownStatus } = useLoadedRundown(); const { entries } = rundown; return {