Compare commits

..

11 Commits

Author SHA1 Message Date
Claude 1fa6175fb8 refactor(rundown): keep runtime views on the loaded rundown
Only the editor and the cuesheet can be pointed at a background rundown.
Everything else always works against the rundown being played, so it has
no business taking part in a rundown scope.

Split the readers by that assumption:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 13:21:59 +00:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
92 changed files with 2060 additions and 1132 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.12.0",
"version": "4.13.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.12.0",
"version": "4.13.0",
"private": true,
"type": "module",
"dependencies": {
+4 -1
View File
@@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -112,7 +113,9 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<RundownPanel />
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper>
}
/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -0,0 +1,77 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -0,0 +1,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<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
@@ -1,61 +1,32 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useRundownScope } from '../context/RundownScopeContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
* Normalised rundown data for the rundown of the enclosing scope
*/
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const selectedEventId = useScopedSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -66,41 +37,19 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const selectedEventId = useScopedSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -110,30 +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 };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,66 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -1,54 +0,0 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -1,13 +1,13 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
getRundownSelectionStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
} from '../useRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
describe('useRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project',
);
});
+8 -12
View File
@@ -8,7 +8,6 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -34,7 +33,7 @@ import {
import { useCallback, useMemo } from 'react';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import { getRundownCacheKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,6 +51,7 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -70,16 +70,16 @@ type ClientInsertOptions = {
};
/**
* Gather utilities for actions on entries in the loaded rundown.
* Gather utilities for actions on entries in the rundown of the enclosing scope.
*/
export const useEntryActions = () => useEntryActionsForRundown(undefined);
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,12 +93,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -2,14 +2,21 @@ import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
/** each surface keeps its own selection, so panels can point at different rundowns */
export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
/**
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() {
return {
loadedRundownId,
selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
@@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
+12 -17
View File
@@ -22,14 +22,10 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null,
}));
export const useScreenControl = createSelector((state: RuntimeStore) => ({
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
isScreenModified:
state.message.timer.visible ||
state.message.timer.blink ||
state.message.timer.blackout ||
state.message.timer.secondarySource !== null,
secondarySource: state.message.timer.secondarySource,
}));
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
@@ -37,12 +33,17 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible,
}));
export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary,
source: state.message.timer.secondarySource,
visible: state.message.timer.secondarySource === 'secondary',
}));
export const useTimerStatus = createSelector((state: RuntimeStore) => ({
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
@@ -51,16 +52,10 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
// blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
clearScreen: () =>
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
};
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -0,0 +1,64 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,14 +1,21 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
}));
@@ -0,0 +1,158 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -4,8 +4,8 @@ import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const { data } = useLoadedRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { data: currentRundown } = useLoadedRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -2,12 +2,14 @@
display: grid;
grid-template-columns: 1fr auto;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
&.withSource {
grid-template-columns: auto 1fr auto;
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
.label.active {
color: $action-text-color;
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Input from '../../../common/components/input/input/Input';
import { cx } from '../../../common/utils/styleUtils';
@@ -10,15 +9,12 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
/** whether this text is currently on the audience screen */
visible: boolean;
changeHandler: (newValue: string) => void;
/** control which picks where the text is shown, rendered before the input */
sourcePicker?: ReactNode;
}
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
@@ -47,11 +43,10 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return (
<div>
<Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</Editor.Label>
<div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
</label>
<div className={style.inputItems}>
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children}
</div>
@@ -1,17 +0,0 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
than growing to whatever height the panel happens to have free */
.screenGroup {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
}
}
@@ -1,22 +1,18 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Select from '../../../common/components/select/Select';
import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import {
setMessage,
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
import TimerControlsPreview from './TimerViewControl';
export default function MessageControl() {
return (
<>
<div className={style.screenGroup}>
<TimerPreview />
<ScreenControl />
</div>
<TimerControlsPreview />
<TimerMessageInput />
<SecondaryInput />
</>
@@ -28,7 +24,7 @@ function TimerMessageInput() {
return (
<InputRow
label='Timer message'
label='Timer Message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
@@ -36,7 +32,6 @@ function TimerMessageInput() {
>
<IconButton
aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'}
>
@@ -46,46 +41,31 @@ function TimerMessageInput() {
);
}
/**
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
* The select owns which one, the eye owns whether the line is shown at all.
*/
function SecondaryInput() {
const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const selectedSource = source ?? 'aux1';
const { text, visible } = useSecondaryMessageInput();
const toggleSecondary = () => {
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
return (
<InputRow
label='Secondary'
label='Secondary Message'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={source === 'secondary'}
visible={visible}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
sourcePicker={
<Select
value={selectedSource}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
setMessage.timerSecondarySource(value);
}}
/>
}
>
<IconButton
aria-label='Toggle secondary visibility'
aria-pressed={isShowingSecondaryLine}
onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
aria-label='Toggle secondary message visibility'
onClick={toggleSecondary}
variant={visible ? 'primary' : 'subtle'}
>
{isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
{visible ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
@@ -1,21 +1,8 @@
.growPanel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* the extracted route has no flex parent to grow into */
.extractedPanel {
height: 100%;
}
.contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex;
flex-direction: column;
gap: $section-spacing;
@@ -5,7 +5,6 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
@@ -17,10 +16,7 @@ function MessageControlExport() {
return (
<ProtectRoute permission='editor'>
<Editor.Panel
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
data-testid='panel-messages-control'
>
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
@@ -1,14 +0,0 @@
.screenControl {
display: grid;
grid-auto-flow: row;
gap: $element-spacing;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
width: 100%;
}
}
@@ -1,40 +0,0 @@
import Button from '../../../common/components/buttons/Button';
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
import style from './ScreenControl.module.scss';
export default function ScreenControl() {
const { blackout, blink, isScreenModified } = useScreenControl();
return (
<div className={style.screenControl}>
<Button
variant={blink ? 'primary' : 'subtle'}
aria-pressed={blink}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'destructive' : 'subtle'}
aria-pressed={blackout}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout
</Button>
<Button
variant='subtle'
fluid
disabled={!isScreenModified}
onClick={() => setMessage.clearScreen()}
data-testid='clear screen'
>
Clear screen
</Button>
</div>
);
}
@@ -1,26 +1,49 @@
/* fixed shape, not stretched to whatever height the panel has free - it fills the
available width and keeps a 16:9 proportion, which is also what the font size
estimate in the embedded timer assumes */
.stage {
width: 100%;
aspect-ratio: 16 / 9;
min-width: 0;
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
/* a query container so that the embedded timer scales to the preview, not to the viewport */
container-type: size;
contain: paint;
overflow: hidden;
border-radius: $component-border-radius-md;
}
/* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
.stageChrome {
position: absolute;
inset: 0;
z-index: calc($zindex-floating + 2);
pointer-events: none;
.mainContent {
font-size: 1rem;
font-weight: 600;
width: 100%;
color: var(--override-colour, $ui-white);
> * {
pointer-events: auto;
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
}
.secondaryContent {
border-top: 1px solid $white-7;
}
.blackout {
display: none;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -1,21 +1,110 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, 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 { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import TimerStatus from './TimerStatus';
import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
aux3: 'Aux 3',
secondary: 'Secondary message',
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
if (timerType === TimerType.Clock) return 'Clock';
if (countToEnd) return 'Count to End';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return (
<div className={style.stage}>
<PipTimer viewSettings={data} />
<div className={style.stageChrome}>
<TimerStatus />
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
</div>
);
@@ -1,17 +0,0 @@
.timerStatus {
position: absolute;
top: 0;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -1,58 +0,0 @@
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useTimerStatus } from '../../../common/hooks/useSocket';
import style from './TimerStatus.module.scss';
/** Read only summary of how the loaded event drives the stage timer */
export default function TimerStatus() {
const { countToEnd, timerType } = useTimerStatus();
return (
<div className={style.timerStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
);
}
@@ -0,0 +1,11 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -0,0 +1,92 @@
import { SecondarySource } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Select from '../../../common/components/select/Select';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<SecondarySourceControl />
<Editor.Separator orientation='horizontal' />
<Button
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
function SecondarySourceControl() {
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
useEffect(() => {
if (secondarySource !== null) {
setValue(secondarySource);
}
}, [secondarySource]);
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource(value);
}
};
return (
<>
<Select
value={value}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value);
}
setValue(value);
}}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
+19 -30
View File
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -55,72 +56,60 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<Button
variant={showUser ? 'primary' : 'subtle'}
<ToggleButton
pressed={showUser}
size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showClient}
size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showServer}
size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showPlayback}
size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showRx}
size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showTx}
size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</Button>
</ToggleButton>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import {
useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const group = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const entry = useLoadedEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
const { data: rundownData } = useLoadedRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+5 -1
View File
@@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -39,28 +36,25 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</ProtectRoute>
</EntryActionsProvider>
</div>
</ProtectRoute>
);
}
@@ -70,30 +64,28 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
<RundownEntryEditor />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
)}
</div>
</ProtectRoute>
</EntryActionsProvider>
</div>
</ProtectRoute>
);
}
@@ -105,8 +97,6 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog />
</div>
);
@@ -1,15 +1,22 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList);
function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
@@ -26,6 +27,7 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -43,12 +45,17 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
@@ -65,7 +72,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -74,7 +81,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -85,7 +92,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
[entries, flatOrder, clone, reorderEntry, rundownId],
);
/**
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
/**
* The empty tagsToIgnore is significant: by default the hook skips input elements,
* which would make the shortcut dead while editing an entry.
*
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick}
onContextMenu={onContextMenu}
data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})}
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -1,9 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -15,8 +12,6 @@ function RundownTable() {
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -32,10 +27,8 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
);
}
@@ -1,155 +1,15 @@
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useStore } from 'zustand';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
* Selection and cursor for the rundown of the enclosing scope
*/
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
const { selectionStore } = useRundownScope();
return useStore(selectionStore, selector);
}
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState,
} from 'react';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(filterCallback);
const { data: events } = useLoadedPartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,35 +3,31 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import { useRundownSelection } from '../../common/hooks/useRundownSelection';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } =
useRundownSelection('cuesheet');
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<EntryActionsProvider actions={actions}>
<EditableRundownScopeProvider rundownId={scopedRundownId}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
@@ -42,13 +38,12 @@ export default function CuesheetPage() {
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
</EditableRundownScopeProvider>
);
}
@@ -3,19 +3,18 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
@@ -24,15 +23,14 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { isLoaded } = useRundownScope();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded });
const { data: customFields } = useCustomFields();
const columns = useMemo(
@@ -44,11 +42,9 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}>
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
@@ -1,16 +1,13 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) {
function EntryEditModal() {
const { data: rundown } = useRundown();
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -14,7 +14,8 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -41,20 +42,17 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: CuesheetColumnDef[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
@@ -62,13 +60,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { isLoaded: isCurrentRundown } = useRundownScope();
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
+23 -15
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -21,7 +22,9 @@ export default function Editor() {
<TimerControl />
<MessageControl />
</div>
<Rundown />
<EditableRundownScopeProvider rundownId={null}>
<Rundown />
</EditableRundownScopeProvider>
</div>
);
}
@@ -29,14 +32,17 @@ export default function Editor() {
if (layoutMode === EditorLayoutMode.TRACKING) {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
{/* the titles and the rundown share a scope, so they follow one cursor */}
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
<TrackingPlaybackBar />
</div>
);
@@ -44,14 +50,16 @@ export default function Editor() {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</EditableRundownScopeProvider>
</div>
);
}
@@ -3,11 +3,14 @@
.error {
padding-inline: 0.5rem;
font-size: 1rem;
height: 3rem;
// rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.entry[data-selected='true'] {
@@ -18,21 +21,47 @@
color: $label-gray;
}
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error {
color: $error-red;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title';
'index title'
'index match';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
min-width: 0;
.index {
grid-area: index;
background-color: var(--color, $gray-1000);
// background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
@@ -42,14 +71,33 @@
.title {
grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
}
}
@@ -63,13 +111,14 @@
color: $label-gray;
}
.filterHint {
.count {
text-align: right;
white-space: nowrap;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
.go {
white-space: nowrap;
padding-left: 1rem;
}
.hints {
@@ -98,7 +147,7 @@
flex-direction: column;
}
.filterHint {
.count {
text-align: left;
}
}
+117 -52
View File
@@ -1,11 +1,12 @@
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import { MaybeString } from 'ontime-types';
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import style from './Finder.module.scss';
@@ -15,46 +16,76 @@ interface FinderProps {
}
export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const debouncedFind = useDebouncedCallback(find, 100);
/**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length);
setSelectedId(results[(activeIndex + 1) % results.length].id);
}
if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length);
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit();
submit(activeEntry);
}
};
const submit = () => {
const selectedEvent = results[selected];
select(selectedEvent);
const submit = (entry: FinderResult | undefined) => {
if (!entry) {
return;
}
select(entry);
onClose();
};
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
/** Scopes the search to a single field, or back to all fields when tapped again */
const handleFilter = (filterKey: string) => {
setFilter((previous) => (previous === filterKey ? null : filterKey));
inputRef.current?.focus();
};
const hiddenResults = total - results.length;
return (
<Modal
title=''
@@ -63,35 +94,68 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop
bodyElements={
<div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
<Input
ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = 'cue' in entry ? entry.cue : '';
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.map((entry) => {
const isSelected = activeEntry?.id === entry.id;
// the title and cue are already on the row, a match anywhere else needs showing
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
return (
<li
key={entry.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
</div>
<div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</div>
return (
<li
key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry}
data-testid='finder-result'
data-selected={isSelected}
onClick={() => submit(entry)}
onPointerEnter={() => setSelectedId(entry.id)}
>
<div className={style.data}>
<div className={style.index} style={getAccessibleColour(entry.colour)}>
{entry.eventIndex ?? '-'}
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
<div className={style.cue}>{entry.cue}</div>
<div className={style.title}>{entry.title}</div>
{showMatch && (
<div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
</div>
)}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li>
);
})}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul>
</div>
}
@@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close
</span>
</div>
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
{total > 0 && (
<div className={style.count} data-testid='finder-count'>
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div>
)}
</div>
}
/>
@@ -0,0 +1,162 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+220 -200
View File
@@ -1,239 +1,259 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import {
CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12;
/** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
type FilterableGroup = {
type: SupportedEntry.Group;
id: EntryId;
index: number;
title: string;
colour: string;
};
const indexFilter = 'index';
type FilterableEvent = {
type: SupportedEntry.Event;
/** Everything except delays, which carry no text to search */
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number;
eventIndex: number;
/** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string;
/** groups have no cue */
cue: string;
colour: string;
parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
};
type FilterableMilestone = {
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
/** Groups are the only searchable entry with neither a cue nor a parent */
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
return {
id: entry.id,
index,
eventIndex,
title: entry.title,
cue: 'cue' in entry ? entry.cue : '',
colour: entry.colour,
parent: 'parent' in entry ? entry.parent : null,
match,
} satisfies FinderResult;
}
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
/** Filters the rundown to a given evaluation */
const find = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
}
const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
}
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
}
if (event.target.value === '') {
setResults([]);
return;
}
// groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
// custom fields are named by the project, so these can only be reached generically
for (const [key, value] of Object.entries(entry.custom)) {
const definition = customFields[key];
if (definition?.type !== 'text') {
continue;
}
const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
if (searchValue.startsWith('index ')) {
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
return null;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
eventIndex++;
if (eventIndex === target) {
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
}
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
return noResults;
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
return { results, error: null };
}
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
}
const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
const match = findMatch(entry, customFields, filterKey, searchString);
if (match === null) {
continue;
}
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
return { results, error: null, total };
}
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data } = useFlatRundown();
const { data: customFields } = useCustomFields();
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
}
},
[data],
);
const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
const customFilters = Object.entries(customFields)
.filter(([_key, field]) => field.type === 'text')
.map(([key, field]) => ({ key, label: field.label || key }));
return [...staticFilters, ...customFilters];
}, [customFields]);
const { results, error, total, appliedFilter } = useMemo(() => {
if (data.length === 0) {
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
}
const normalised = searchValue.trim().toLowerCase();
if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
/**
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
*/
const { filterKey, searchString } = activeFilter
? { filterKey: activeFilter, searchString: normalised }
: parseQuery(normalised, filters);
if (filterKey === indexFilter) {
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
}
if (searchString === '') {
// a filter is selected, but there is nothing to match on yet
return { ...noResults, appliedFilter: filterKey };
}
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
}, [data, customFields, filters, searchValue, activeFilter]);
const select = useCallback(
(selectedEvent: FilterableEntry) => {
selectAndRevealEntry({
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
(result: FinderResult) => {
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
},
[selectAndRevealEntry],
);
/** clear results when source data changes */
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
return { select, results, error, total, filters, appliedFilter };
}
@@ -6,9 +6,8 @@
padding: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%;
height: 100%; /* fill the pip window or the editor preview frame */
position: relative; /* anchor for the blackout and message overlays */
width: 100%; /* restrict the page width to viewport */
height: 100vh;
transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family;
@@ -20,8 +19,8 @@
flex-direction: column;
&--finished {
outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
@@ -108,24 +107,10 @@
/* =================== OVERLAY ===================*/
.blackout {
position: absolute;
inset: 0;
z-index: 0;
background-color: #000;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: calc($zindex-floating + 1);
opacity: 1;
}
}
.message-overlay {
position: absolute;
position: fixed;
inset: 0;
padding: 2cqw;
padding: 2vw;
background: $viewer-background-color;
opacity: 0;
transition: opacity $viewer-transition-time;
@@ -41,10 +41,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
// match the defaults of the timer view, which is what the preview is standing in for
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: true,
removeLeadingZero: false,
});
const currentAux = (() => {
@@ -64,27 +63,23 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
// the estimate is tuned for a 16:9 screen, so cap it by height for containers wider than that
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const timerFontRule = `min(${timerFontSize}cqw, ${((timerFontSize * 16) / 9).toFixed(2)}cqh)`;
const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
};
return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
<div className='timer-container'>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: timerFontRule }}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
>
{display}
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData}
selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/>
);
}
@@ -56,21 +55,14 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
resolvedFollowEventId: string | null;
rundownId: string;
}
function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
const selectAndRevealEntry = useSelectAndRevealEntry();
// Calculate current event info
const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
+18
View File
@@ -195,6 +195,24 @@
font-weight: 600;
}
.sound-prompt {
position: absolute;
bottom: $view-block-padding;
left: $view-inline-padding;
padding: 0.5em 0.75em;
border-radius: $element-border-radius;
background-color: $viewer-card-bg-color;
color: $viewer-secondary-color;
font-size: $timer-label-size;
text-transform: uppercase;
pointer-events: none;
transition: opacity $viewer-transition-time;
&--hidden {
opacity: 0;
}
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
+17
View File
@@ -8,6 +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 { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
@@ -30,6 +31,7 @@ import {
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss';
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime,
freezeMessage,
hidePhase,
endSound,
font,
keyColour,
timerColour,
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
@@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div>
);
}
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -0,0 +1,34 @@
import { TimerPhase } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
'sounds when a running timer goes into overtime from %s',
(previousPhase) => {
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
},
);
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
});
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
});
it('stays silent for a roll timer waiting to start', () => {
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
});
it('sounds once, not on every update while in overtime', () => {
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
});
it('stays silent on phases which are not the end of the timer', () => {
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
});
});
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean',
defaultValue: false,
},
{
id: 'endSound',
title: 'Play sound on timer end',
description:
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
type: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
],
},
{
@@ -193,6 +203,7 @@ type TimerOptions = {
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
endSound: EndSound;
font?: string;
keyColour?: string;
timerColour?: string;
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return {
hideClock: isStringBoolean(getValue('hideClock')),
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
@@ -0,0 +1,20 @@
import arpeggio from '../../assets/sounds/arpeggio.wav';
import bell from '../../assets/sounds/bell.wav';
import chime from '../../assets/sounds/chime.wav';
import type { SelectOption } from '../../common/components/select/Select';
// synthesised tones bundled with the app, no external source or licence to track
export const endSoundSources = { chime, bell, arpeggio } as const;
export type EndSound = 'none' | keyof typeof endSoundSources;
export const endSoundOptions: SelectOption[] = [
{ value: 'none', label: 'None' },
{ value: 'chime', label: 'Chime' },
{ value: 'bell', label: 'Bell' },
{ value: 'arpeggio', label: 'Arpeggio' },
];
export function isEndSound(value: string | null): value is EndSound {
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
}
@@ -189,3 +189,18 @@ export function getCardData(
nextSecondary,
};
}
/**
* Whether the end of timer sound should play for a given phase transition
* We only sound the transition into overtime from a phase that was already counting,
* which keeps a client that connects or reloads mid-overtime silent
*/
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
if (phase !== TimerPhase.Overtime) {
return false;
}
return (
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { data: rundown, status: rundownStatus } = useLoadedRundown();
const { entries } = rundown;
return {
@@ -0,0 +1,99 @@
import { TimerPhase } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { endSoundSources, type EndSound } from './timer.sound';
import { shouldPlayEndSound } from './timer.utils';
/**
* Plays a sound when the timer reaches its end
*
* Browsers reject playback until the document has been interacted with, and that permission
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
* audio element on the first interaction and let the view prompt for one if it never comes.
* Safari grants the permission per element, so priming has to call play() on this element from
* inside the event handler, it is not enough to know that an interaction happened.
*/
export function useTimerSound(phase: TimerPhase, sound: EndSound): boolean {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previousPhaseRef = useRef<TimerPhase | null>(null);
const [isArmed, setIsArmed] = useState(false);
const enabled = sound !== 'none';
// Create and clean up the audio element; changing sounds requires re-arming it in Safari.
useEffect(() => {
setIsArmed(false);
if (sound === 'none') {
return;
}
const audio = new Audio(endSoundSources[sound]);
audioRef.current = audio;
return () => {
audio.pause();
audioRef.current = null;
};
}, [sound]);
// Listen for user interaction until muted playback succeeds and arms the selected audio element.
useEffect(() => {
if (!enabled || isArmed) {
return;
}
const controller = new AbortController();
const prime = () => {
const audio = audioRef.current;
if (!audio) {
return;
}
const wasMuted = audio.muted;
audio.muted = true;
audio
.play()
.then(() => {
if (audioRef.current !== audio) {
return;
}
audio.pause();
audio.currentTime = 0;
setIsArmed(true);
})
.catch(() => {
// playback is still blocked, a later interaction will try again
})
.finally(() => {
audio.muted = wasMuted;
});
};
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
return () => {
controller.abort();
};
}, [enabled, isArmed]);
// Track phase transitions and play only when a running timer enters overtime.
useEffect(() => {
const previousPhase = previousPhaseRef.current;
previousPhaseRef.current = phase;
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
return;
}
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => {
// the screen has not been interacted with, the view shows a prompt for it
});
}, [enabled, phase]);
return enabled && !isArmed;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.12.0",
"version": "4.13.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.12.0",
"version": "4.13.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
@@ -17,7 +17,7 @@
"parser"
],
"author": "",
"license": "AGPL-3.0-only",
"license": "ISC",
"devDependencies": {
"ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.12.0",
"version": "4.13.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -1,5 +1,7 @@
import { deepEqual } from 'fast-equals';
import {
EndAction,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
@@ -8,13 +10,21 @@ import {
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
cloneEntryData,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -715,3 +725,68 @@ describe('eventDurationMatchGroupTarget()', () => {
expect(result).toStrictEqual(null);
});
});
describe('cloneEntryData()', () => {
const trigger: Trigger = {
id: 'trigger-1',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-1',
};
// the real factories, so these are complete entries exactly as the rundown holds them
const entries: [string, OntimeEntry][] = [
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
['delay', createDelay({ id: 'delay-1', duration: 10 })],
];
/**
* Fails if any nested object or array in the clone is the same reference as the source,
* so a field added later that needs a copy of its own is caught here without the clone
* having to enumerate fields.
*/
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
if (typeof source !== 'object' || source === null) return;
expect(clone, `${path} is shared with the source`).not.toBe(source);
const cloneRecord = clone as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
for (const key of Object.keys(sourceRecord)) {
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
}
}
/** the two halves of the structuredClone contract: same value, no shared references */
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
});
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
});
/**
* Regression: normalising an absent container to an empty one makes deepEqual report a
* change on every comparison, which would have the runtime re-broadcast and re-save the
* restore point on every tick. See PR #2178.
*/
it.each([
['event', makeOntimeEvent({ id: 'partial' })],
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
const clone = cloneEntryData(entry);
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
// key holding undefined, and it is key presence that decides the deepEqual below
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
// entry; if the clone gains a key, every tick looks like a change
expect(deepEqual(clone, entry)).toBe(true);
});
it('throws on an entry type it does not know how to clone', () => {
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
'Unsupported entry type for cloning',
);
});
});
+38 -14
View File
@@ -27,6 +27,7 @@ import {
isPlayableEvent,
} from 'ontime-types';
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
import type { DeepReadonly } from 'ts-essentials';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
@@ -34,6 +35,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneRundown,
cloneSimpleRundownEntry,
deleteById,
doesInvalidateMetadata,
@@ -43,9 +45,14 @@ import {
} from './rundown.utils.js';
/**
* The currently loaded rundown in cache
* The currently loaded rundown in cache.
*
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
* layer stores this object by reference, so repurposing it for another rundown would rewrite
* the previously loaded rundown's stored record. Mutating it in place while it represents the
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
*/
const cachedRundown: Rundown = {
let cachedRundown: Rundown = {
id: '',
title: '',
order: [],
@@ -79,9 +86,16 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
type Transaction = {
/**
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
* - which would silently corrupt the live cache without going through commit() - is a
* compile-time error instead of a runtime bug.
*/
type Transaction<R> = {
customFields: CustomFields;
rundown: Rundown;
rundown: R;
commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>;
@@ -102,11 +116,17 @@ type TransactionOptions = {
rundownId?: string;
};
export function createTransaction(options: TransactionOptions): Transaction {
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
export function createTransaction(
options: TransactionOptions & { mutableRundown?: false },
): Transaction<DeepReadonly<Rundown>>;
export function createTransaction(
options: TransactionOptions,
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/**
@@ -707,21 +727,25 @@ export const customFieldMutation = {
* Expose function to add an initial rundown to the system
*/
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown);
const rundown = cloneRundown(initialRundown);
const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder;
cachedRundown.revision = rundown.revision;
// a fresh object, so that the record already stored for a previously loaded rundown keeps
// pointing at that rundown's data - see the note on cachedRundown
cachedRundown = {
id: rundown.id,
title: rundown.title,
entries,
order,
flatOrder: metadata.flatEntryOrder,
revision: rundown.revision,
};
rundownMetadata = metadata;
// defer writing to the database
@@ -33,7 +33,7 @@ import {
import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
/**
* Parse a rundowns object along with the project custom fields
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
};
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
}
function getMetadata(): ProcessedRundownMetadata {
@@ -39,6 +39,7 @@ import {
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import {
cloneRundown,
generateEvent,
getFirstInsertId,
getIntegerAndFraction,
@@ -626,7 +627,7 @@ export async function editCustomField(
// ... reassign references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
@@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
@@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = structuredClone(rundown);
const duplicatedRundown: Rundown = cloneRundown(rundown);
duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0;
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure });
entries[id] = cloneEntryData({ ...merged, ...structure });
}
return {
@@ -499,6 +499,61 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
throw new Error(`Unsupported entry type for cloning: ${entry}`);
}
/**
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
* Drop-in replacement for `structuredClone(entry)`
*/
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
switch (entry.type) {
case SupportedEntry.Event: {
const clone: OntimeEvent = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
return clone as T;
}
case SupportedEntry.Group: {
const clone: OntimeGroup = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.entries) clone.entries = clone.entries.slice();
return clone as T;
}
case SupportedEntry.Milestone: {
const clone: OntimeMilestone = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
return clone as T;
}
case SupportedEntry.Delay:
return { ...entry } as T;
default: {
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
const unhandled: never = entry;
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
}
}
}
/**
* Fast, shape-aware clone of a whole rundown.
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
* the result freely without touching the source - same contract as structuredClone,
* at a fraction of the cost since we skip the generic serialization algorithm.
*/
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
const entries: RundownEntries = {};
for (const id in rundown.entries) {
entries[id] = cloneEntryData(rundown.entries[id]);
}
return {
id: rundown.id,
title: rundown.title,
revision: rundown.revision,
order: rundown.order.slice(),
flatOrder: rundown.flatOrder.slice(),
entries,
};
}
/**
* Utility for calculating if the current events should have a day offset
* @param current the current event under test
@@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) {
sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
});
} catch (e) {
@@ -101,8 +101,16 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
/**
* Stores a rundown, replacing any existing entry for the same key.
* Takes ownership of `newData` and stores it by reference - the caller must not mutate it
* afterward. Every call site either hands over a freshly-built object it never touches again,
* or (for the loaded rundown) the cache's own long-lived object, which is already the single
* source of truth for that data - aliasing it here costs nothing and avoids a second full
* deep copy of the rundown on every commit.
*/
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
db.data.rundowns[rundownKey] = structuredClone(newData);
db.data.rundowns[rundownKey] = newData;
await persist();
return db.data.rundowns;
}
@@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
// rundowns are merged separately below by reference (only the top-level map is copied,
// same as the other properties here) - deep-cloning them here would be wasted work,
// since a project's rundowns are by far the largest part of this object
const { rundowns: existingRundowns, ...existingRest } = existing;
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
const deepExisting = structuredClone(existingRest);
const deepNewData = structuredClone(newDataRest);
// destructure each property to simplify merging not provided ie: ...{} has no effect
const {
rundowns = {},
project = {},
settings = {},
viewSettings = {},
@@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
} = deepNewData;
return {
rundowns: { ...existing.rundowns, ...rundowns },
rundowns: { ...existingRundowns, ...newRundowns },
project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
@@ -20,6 +20,7 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -754,7 +755,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
}
// at this point we know that either the id or the contents has changed
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
RuntimeService.previousState[key] = structuredClone(currentEntry);
RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null;
return true;
}
+4
View File
@@ -36,6 +36,10 @@ Remove comments that:
Update adjacent comments with code. Stale comments are defects.
Add a short comment immediately above every React `useEffect` describing the external synchronization or lifecycle
responsibility it owns. Explain non-obvious reasons or constraints when they matter; for files with multiple effects, a
brief responsibility label is useful even when the mechanics are straightforward.
## Naming and types
- Prefer Ontime terms over vague `data`, `result`, `item`.
@@ -19,29 +19,3 @@ test('message control sends messages to screens', async ({ context }) => {
await expect(featurePage.getByText('TIME NOW')).toBeVisible();
});
test('message control drives the stage screen state', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
await editorPage.goto('/messagecontrol');
await featurePage.goto('/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
// the secondary line defaults to an aux timer, the select is what puts our text on screen
await editorPage.getByPlaceholder('Message shown as secondary text in stage timer').fill('testing secondary');
await editorPage.getByRole('combobox').click();
await editorPage.getByRole('option', { name: 'Message' }).click();
await expect(featurePage.getByText('testing secondary')).toBeVisible();
await editorPage.getByTestId('toggle timer blackout').click();
await expect(featurePage.locator('.blackout')).toHaveClass(/blackout--active/);
// clearing returns the screen to normal, but keeps what the operator typed
await editorPage.getByTestId('clear screen').click();
await expect(featurePage.locator('.blackout')).not.toHaveClass(/blackout--active/);
await expect(featurePage.getByText('testing secondary')).toHaveCount(0);
await expect(editorPage.getByPlaceholder('Message shown as secondary text in stage timer')).toHaveValue(
'testing secondary',
);
});
@@ -222,15 +222,57 @@ test('Delete event', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible();
});
test('Find in rundown', async ({ page }) => {
test('Finder searches the rundown and reveals a result', async ({ page }) => {
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// two events, where the one we are looking for is identified only by its note
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').press('Escape');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E');
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('closing');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').click();
await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish');
await page.getByLabel('Note', { exact: true }).press('Tab');
// the shortcut has to work from a focused field, which is where it is usually reached for
await page.getByTestId('entry-2').getByTestId('entry__title').click();
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
await expect(page.getByPlaceholder('Search...')).toBeFocused();
await page.keyboard.press('Escape');
// a bare query reaches the note, and the result names the field it matched
await page.getByPlaceholder('Search...').fill('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-result-match')).toContainText('Note');
// a badge scopes the search to one field, without putting syntax in the input
const titleFilter = page.getByTestId('finder-filters').getByRole('button', { name: 'Title', exact: true });
await titleFilter.click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(0);
// pressing it again searches every field once more
await titleFilter.click();
await expect(page.getByTestId('finder-result')).toHaveCount(1);
// choosing a result closes the finder and selects the entry in the rundown
await page.getByPlaceholder('Search...').press('Enter');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
});
test('Open settings', async ({ page }) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.12.0",
"version": "4.13.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.12.0",
"version": "4.13.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",