Compare commits

..

2 Commits

Author SHA1 Message Date
Claude e29a019376 feat(mcp): validate tool arguments with zod
The MCP endpoint was the only route accepting a body without validation. Its
JSON Schema tool declarations were advertisement only: the low-level SDK Server
validates the JSON-RPC envelope but never the arguments of a tool call, and
every handler cast them with `args as SomeType`.

Tools are now declared with `defineTool(name, config, handler)`, the argument
shape `registerTool` takes, and the JSON Schema advertised in tools/list is
generated from the same zod schema that parses incoming arguments, so the two
cannot drift. Handlers receive inferred arguments and pass them to the existing
services, whose argument types are derived from ontime-types — which means a
schema that drifts from the domain model now fails typecheck.

mcp.registry.ts is the only module aware of the SDK internals. Migrating to the
SDK v2 registerTool API means replacing its two functions with a registration
loop, leaving the tool declarations untouched.

Malformed calls are now protocol errors, matching what the SDK does on the
registerTool path and what v2 will do. Failures raised by the services are
still returned as tool errors so agents can read them and recover.

Fixes three bugs the missing validation allowed:

- Project filenames reached `join(projectsDir, name)` unsanitised, so
  `../canary.json` resolved outside the projects directory. They are now
  confined with the same sanitize-filename + ensureJsonExtension pair the HTTP
  routes use in db.validation.ts.
- ontime_update_project_info forwarded its whole argument object into
  setProjectData, which spreads it into the stored project; an undeclared
  `logo` key also caused the current logo file to be deleted. Only declared
  fields reach it now.
- Batch creation declared nested entries as a bare object. They are modelled
  two levels deep, which encodes that groups cannot be nested and keeps the
  generated schema free of $ref/$defs.

Unknown keys are rejected rather than stripped, so an agent gets an actionable
error instead of a write that silently does less than it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:22 +00:00
Claude ae16491599 chore(server): add zod as a direct dependency
Zod 4.4.3 is already resolved in the lockfile as a transitive dependency of
@modelcontextprotocol/sdk, but pnpm's strict node_modules will not resolve it
from apps/server without an explicit entry. Pinned to the resolved version so
the server and the SDK share a single instance.

Version >= 4.2.0 is also what the SDK v2 upgrade will require, since v2 takes
Standard Schema objects for tool schemas and drops zod v3 support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:08 +00:00
50 changed files with 1307 additions and 3125 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.13.0-beta.1",
"version": "4.12.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.13.0-beta.1",
"version": "4.12.0",
"private": true,
"type": "module",
"dependencies": {
+2 -19
View File
@@ -20,7 +20,6 @@ const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const Teleprompter = lazy(() => import('./views/teleprompter/Teleprompter'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
@@ -96,15 +95,6 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='teleprompter'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSpaceHotkey />
<Teleprompter />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='editor' element={<Editor />} />
<Route path='cuesheet' element={<Cuesheet />} />
@@ -175,7 +165,6 @@ const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
[OntimeView.Teleprompter]: Teleprompter,
};
/**
@@ -225,14 +214,8 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
<ViewLoader>
<ViewNavigationMenu
isNavigationLocked={getIsNavigationLocked()}
suppressSettings
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
/>
{Component ? <Component /> : <NotFound />}
</ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
}
@@ -1,66 +0,0 @@
.groups {
display: grid;
gap: 0.875rem;
}
.group {
h3 {
margin: 0 0 0.375rem;
color: var(--shortcut-title-color, currentColor);
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.list {
display: grid;
gap: 0.25rem;
}
.row {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.label {
min-width: 0;
line-height: 1.2;
color: var(--shortcut-label-color, currentColor);
}
.keys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.combo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: var(--shortcut-separator-color, currentColor);
font-size: calc(1rem - 5px);
}
@media (max-width: 680px) {
.row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.keys {
justify-content: flex-start;
}
}
@@ -1,42 +0,0 @@
import type { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import Kbd from '../kbd/Kbd';
import style from './KeyboardShortcuts.module.scss';
export function ShortcutGroups({ className, children }: PropsWithChildren<{ className?: string }>) {
return <div className={cx([style.groups, className])}>{children}</div>;
}
export function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.group}>
<h3>{title}</h3>
<div className={style.list}>{children}</div>
</section>
);
}
export function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={style.keys}>{children}</span>
</div>
);
}
export function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.combo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
export function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -1,4 +1,4 @@
import { type HotkeyItem, useDisclosure, useHotkeys } from '@mantine/hooks';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
@@ -13,32 +13,24 @@ interface ViewNavigationMenuProps {
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean;
suppressSpaceHotkey?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
// Omitting the binding also avoids useHotkeys preventing the default action.
const spaceHotkey: HotkeyItem[] = suppressSpaceHotkey
? []
: [
[
'Space',
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
];
useHotkeys([
...spaceHotkey,
[
'Space',
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
[
'mod + ,',
() => {
@@ -49,13 +49,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'boolean') {
const paramValue = searchParams.get(id);
return (
<ControlledSwitch
id={id}
initialValue={paramValue === null ? Boolean(defaultValue) : isStringBoolean(paramValue)}
/>
);
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
}
if (type === 'number') {
@@ -228,41 +228,11 @@ describe('getURLSearchParamsFromObj()', () => {
},
];
const params = {
bool1: 'off',
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
it('omits booleans which match their default', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'onByDefault',
title: 'onByDefault',
description: 'On by default',
type: 'boolean',
defaultValue: true,
},
{
id: 'offByDefault',
title: 'offByDefault',
description: 'Off by default',
type: 'boolean',
defaultValue: false,
},
],
},
];
const untouched = getURLSearchParamsFromObj({ onByDefault: 'on' }, mockOptionsWithBooleans);
expect(untouched.toString()).toBe('');
const flipped = getURLSearchParamsFromObj({ offByDefault: 'on' }, mockOptionsWithBooleans);
expect(flipped.get('onByDefault')).toBe('false');
expect(flipped.get('offByDefault')).toBe('true');
});
});
@@ -155,14 +155,6 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
});
});
// Unchecked checkboxes are absent from FormData. Persist false when true is the default.
metadata.booleanFields.forEach((id) => {
if (id in paramsObj) return;
if (metadata.defaultValues[id] !== 'false') {
addUniqueParam(id, 'false');
}
});
// Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
@@ -26,7 +26,6 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
{ value: OntimeView.Countdown, label: 'Countdown' },
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
];
const formId = 'url-preset-form';
@@ -17,10 +17,59 @@
}
.shortcuts {
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
--shortcut-title-color: #{$ui-white};
--shortcut-separator-color: #{$gray-500};
.shortcutGroup {
h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.shortcutList {
display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
}
.prompt {
@@ -35,4 +84,13 @@
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
}
@@ -1,13 +1,7 @@
import { memo } from 'react';
import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
@@ -19,7 +13,7 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<ShortcutGroups className={style.shortcuts}>
<div className={style.shortcuts}>
<ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} />
@@ -106,8 +100,40 @@ function EventEditorEmpty() {
<Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut>
</ShortcutGroup>
</ShortcutGroups>
</div>
</div>
</div>
);
}
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -35,7 +35,6 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
{ value: '<<companion>>', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: `preset-${preset.alias}`,
-1
View File
@@ -4,7 +4,6 @@ export const navigatorConstants = [
{ url: 'timeline', label: 'Timeline' },
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
{ url: 'teleprompter', label: 'Teleprompter' },
{ url: 'info', label: 'Project Info' },
];
@@ -1,201 +0,0 @@
@use '@/theme/viewerDefs' as *;
.teleprompter {
--tp-flip-x: 1;
--tp-flip-y: 1;
/* Keep shared stage-screen settings usable on smaller clients. */
--tp-font-size: min(var(--tp-configured-font-size), 8vw);
position: relative;
height: 100dvh;
width: 100%;
overflow: hidden;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, #000000);
color: var(--color-override, #ffffff);
transform: scale(var(--tp-flip-x), var(--tp-flip-y));
transform-origin: center center;
&--flip-h {
--tp-flip-x: -1;
}
&--flip-v {
--tp-flip-y: -1;
}
}
.teleprompter__scroller {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
/* The animation loop is the only writer of scrollTop. */
overflow-anchor: none;
scroll-behavior: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.teleprompter__content {
width: var(--tp-text-width);
margin-inline: auto;
font-size: var(--tp-font-size);
line-height: var(--tp-line-height);
/* Percent padding resolves against width, so use viewport-height units. */
padding-top: calc(var(--tp-reading-line) * 1dvh);
padding-bottom: calc(100dvh - var(--tp-reading-line) * 1dvh);
}
.teleprompter__block {
margin-bottom: 1em;
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
}
.teleprompter__group {
font-size: 0.34em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $viewer-label-color;
margin-bottom: 0.35em;
}
.teleprompter__heading {
font-size: 0.38em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $viewer-secondary-color;
margin-bottom: 0.3em;
}
.teleprompter__body {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.teleprompter__dim {
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--tp-reading-line) * 1%);
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.teleprompter__reading-line {
position: absolute;
top: calc(var(--tp-reading-line) * 1%);
left: 0;
right: 0;
width: var(--tp-text-width);
margin-inline: auto;
height: calc(var(--tp-font-size) * var(--tp-line-height));
pointer-events: none;
}
.teleprompter__reading-marker {
position: absolute;
top: 0;
bottom: 0;
background: $accent-color;
clip-path: polygon(0 0, 100% 50%, 0 100%);
font-size: var(--tp-font-size);
--tp-gutter: max(0px, calc((100vw - 100%) / 2));
width: min(1.2em, calc(var(--tp-gutter) * 0.6));
right: calc(100% + var(--tp-gutter) * 0.2);
}
.teleprompter__controls {
position: absolute;
bottom: min(2vh, 16px);
left: 50%;
/* Cancel the view flip so controls remain readable to the operator. */
transform: translateX(-50%) scale(var(--tp-flip-x), var(--tp-flip-y));
display: flex;
align-items: center;
gap: min(1vh, 8px);
padding: min(1vh, 8px) clamp(8px, 1vw, 16px);
background: rgba(white, 8%);
border-radius: $element-border-radius;
backdrop-filter: blur(6px);
opacity: 1;
transition: opacity $viewer-transition-time;
&--idle {
opacity: 0;
pointer-events: none;
}
}
.teleprompter__speed {
display: flex;
align-items: baseline;
gap: 0.2em;
min-width: 4em;
justify-content: center;
font-size: clamp(14px, 1.4vw, 22px);
font-variant-numeric: tabular-nums;
}
.teleprompter__speed-unit {
font-size: 0.6em;
color: $viewer-label-color;
}
.teleprompter__help {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 32rem);
max-height: 85dvh;
overflow-y: auto;
padding: 1.5rem;
background: $viewer-background-color;
color: $viewer-color;
border-radius: $element-border-radius;
font-size: 1rem;
}
.teleprompter__help-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.teleprompter__help-title {
font-size: 1.25rem;
font-weight: 600;
}
.teleprompter__help-groups {
--shortcut-title-color: #{$viewer-label-color};
--shortcut-label-color: #{$viewer-secondary-color};
--shortcut-separator-color: #{$viewer-label-color};
}
@@ -1,175 +0,0 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useState } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../common/loader/Loader';
import ControlOverlay from './control-overlay/ControlOverlay';
import HelpOverlay from './help-overlay/HelpOverlay';
import ScriptBlockView from './script-block/ScriptBlock';
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { stepFontSize } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
import { useTeleprompterControls } from './useTeleprompterControls';
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
import { useTeleprompterScroll } from './useTeleprompterScroll';
import './Teleprompter.scss';
export default function TeleprompterLoader() {
const { data, status } = useTeleprompterData();
useWindowTitle('Teleprompter');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Teleprompter {...data} />;
}
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
'use memo';
const options = useTeleprompterOptions();
const selectedEventId = useSelectedEventId();
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [showHelp, setShowHelp] = useState(false);
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
const [live, setLive] = useState(fromParams);
const [seededFrom, setSeededFrom] = useState(paramsKey);
// Reset live controls before commit when the URL configuration changes.
if (seededFrom !== paramsKey) {
setSeededFrom(paramsKey);
setLive(fromParams);
}
const viewOptions = getTeleprompterOptions(customFields);
const blocks = buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
showGroups: options.showGroups,
});
const {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
} = useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const handleFlip = (axis: 'h' | 'v') =>
setLive((current) => {
const key = axis === 'h' ? 'flipH' : 'flipV';
return { ...current, [key]: !current[key] };
});
const handleFontSize = (steps: number) =>
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
const handleToggleHelp = () => setShowHelp((current) => !current);
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
useTeleprompterControls({
controller,
isHelpOpen: showHelp,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
});
const hasScriptSource = options.scriptSource !== 'none';
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
const viewStyles = {
'--tp-configured-font-size': `${live.fontSize}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
'--tp-reading-line': options.readingLinePos,
} as CSSProperties;
return (
<div
className={cx([
'teleprompter',
effectiveFlip.flipH && 'teleprompter--flip-h',
effectiveFlip.flipV && 'teleprompter--flip-v',
])}
style={viewStyles}
data-testid='teleprompter-view'
>
<ViewParamsEditor target={OntimeView.Teleprompter} viewOptions={viewOptions} />
{!hasScriptSource ? (
<EmptyPage text='Select which field holds the script in the view options' />
) : blocks.length === 0 ? (
<EmptyPage text='There is no script text in the selected field' />
) : (
<>
<div
className='teleprompter__scroller'
data-testid='teleprompter-scroller'
ref={scrollerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onPointerDown={handleUserScroll}
>
<div className='teleprompter__content' ref={contentRef}>
{blocks.map((block) => (
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
))}
</div>
</div>
<div className='teleprompter__dim' />
{options.readingLine && (
<div className='teleprompter__reading-line'>
<span className='teleprompter__reading-marker' />
</div>
)}
<ControlOverlay
isRunning={isRunning}
speed={speed}
canReengageFollow={followLocked && options.followLoaded}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
</>
)}
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
</div>
);
}
@@ -1,96 +0,0 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
return {
code: '',
key: '',
shiftKey: false,
ctrlKey: false,
metaKey: false,
altKey: false,
repeat: false,
...overrides,
};
}
describe('resolveTeleprompterAction()', () => {
test('space toggles playback', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space' }))).toEqual({ type: 'togglePlay' });
});
test('ignores a repeating space', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', repeat: true }))).toBeNull();
});
test('vertical arrows nudge by a line', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown' }))).toEqual({ type: 'nudge', lines: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp' }))).toEqual({ type: 'nudge', lines: -1 });
});
test('a repeating arrow still nudges, so the key can be held', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', repeat: true }))).toEqual({
type: 'nudge',
lines: 1,
});
});
test('page keys jump a screen', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'PageDown' }))).toEqual({ type: 'page', direction: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'PageUp' }))).toEqual({ type: 'page', direction: -1 });
});
test('horizontal arrows change speed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: SPEED_STEP });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -SPEED_STEP });
});
test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed',
delta: SPEED_STEP_COARSE,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed',
delta: -SPEED_STEP_COARSE,
});
});
test('home rewinds and escape rewinds and stops', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Home' }))).toEqual({ type: 'rewind' });
expect(resolveTeleprompterAction(makeEvent({ code: 'Escape' }))).toEqual({ type: 'rewindAndPause' });
expect(resolveTeleprompterAction(makeEvent({ code: 'End' }))).toEqual({ type: 'jumpToEnd' });
});
test('f flips, shift+f flips the other axis', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'f' }))).toEqual({ type: 'flip', axis: 'h' });
expect(resolveTeleprompterAction(makeEvent({ key: 'F', shiftKey: true }))).toEqual({ type: 'flip', axis: 'v' });
});
test('changes and resets the font size', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '_' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
});
test('l re-engages the follow and ? shows the help', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'l' }))).toEqual({ type: 'reengageFollow' });
expect(resolveTeleprompterAction(makeEvent({ key: '?', shiftKey: true }))).toEqual({ type: 'toggleHelp' });
});
test('never shadows a shortcut which carries a modifier', () => {
expect(resolveTeleprompterAction(makeEvent({ key: ',', metaKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', ctrlKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', altKey: true }))).toBeNull();
});
test('ignores keys it does not bind', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'KeyQ', key: 'q' }))).toBeNull();
});
test('leaves Enter alone, so a focused transport button can still be pressed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Enter', key: 'Enter' }))).toBeNull();
});
});
@@ -1,109 +0,0 @@
import { getOptionsFromParams, getTeleprompterOptions } from '../teleprompter.options';
import { DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from '../teleprompter.scroll';
describe('getOptionsFromParams()', () => {
test('provides sensible defaults with no params', () => {
const options = getOptionsFromParams(new URLSearchParams());
expect(options).toMatchObject({
scriptSource: 'none',
heading: 'title',
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
});
});
test('reads the script source verbatim so it can be handed to getPropertyValue', () => {
const options = getOptionsFromParams(new URLSearchParams('script=custom-prompter'));
expect(options.scriptSource).toBe('custom-prompter');
});
test('booleans which default to true can be turned off', () => {
const options = getOptionsFromParams(
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&readingLine=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
readingLine: false,
});
});
test('booleans which default to false can be turned on', () => {
const options = getOptionsFromParams(new URLSearchParams('flipH=true&flipV=true'));
expect(options).toMatchObject({
flipH: true,
flipV: true,
});
});
test('clamps the speed to the usable range', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=1000')).speed).toBe(MAX_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=0')).speed).toBe(MIN_SPEED);
});
test('falls back to the default for a non numeric value', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(52);
});
test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
});
test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=20&script=custom-b'),
);
expect(options.speed).toBe(20);
expect(options.scriptSource).toBe('custom-b');
});
});
describe('getTeleprompterOptions()', () => {
test('every declared default is what parsing an empty query produces', () => {
const parsed = getOptionsFromParams(new URLSearchParams()) as Record<string, unknown>;
const parsedByParamId: Record<string, unknown> = { ...parsed, script: parsed.scriptSource };
const declared = getTeleprompterOptions({}).flatMap((section) => section.options);
expect(declared.length).toBeGreaterThan(0);
for (const field of declared) {
if (!('defaultValue' in field) || field.defaultValue === undefined) continue;
const expected = field.defaultValue;
expect({ id: field.id, value: parsedByParamId[field.id] }).toEqual({ id: field.id, value: expected });
}
});
});
describe('option value round trip', () => {
test('every value the editor offers survives parsing', () => {
const selects = getTeleprompterOptions({})
.flatMap((section) => section.options)
.filter((field) => field.type === 'option' && field.id !== 'script');
expect(selects.length).toBeGreaterThan(0);
for (const field of selects) {
for (const { value } of field.values) {
const parsed = getOptionsFromParams(new URLSearchParams(`${field.id}=${value}`)) as Record<string, unknown>;
expect({ id: field.id, value, parsed: parsed[field.id] }).toEqual({ id: field.id, value, parsed: value });
}
}
});
});
@@ -1,139 +0,0 @@
import {
advance,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_FONT_SIZE,
MIN_SPEED,
stepFontSize,
} from '../teleprompter.scroll';
describe('linesPerMinuteToPxPerSecond()', () => {
test('converts a read rate into a pixel rate', () => {
// 30 lines a minute over a 64px line is half a line a second
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
});
});
describe('stepFontSize()', () => {
test('steps by a ratio, so a press is the same visual change at any size', () => {
// a fixed pixel step would be a big jump at 20px and imperceptible at 200px
expect(stepFontSize(100, 1)).toBe(110);
expect(stepFontSize(20, 1)).toBe(22);
});
test('shrinking undoes growing', () => {
expect(stepFontSize(stepFontSize(50, 1), -1)).toBe(50);
});
test('stays inside the range the option accepts', () => {
expect(stepFontSize(MAX_FONT_SIZE, 5)).toBe(MAX_FONT_SIZE);
expect(stepFontSize(MIN_FONT_SIZE, -5)).toBe(MIN_FONT_SIZE);
});
});
describe('clampSpeed()', () => {
test('bounds the speed to the usable range', () => {
expect(clampSpeed(MIN_SPEED - 10)).toBe(MIN_SPEED);
expect(clampSpeed(MAX_SPEED + 10)).toBe(MAX_SPEED);
expect(clampSpeed(30)).toBe(30);
});
test('falls back to the minimum for a non number, rather than stalling at zero', () => {
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
});
});
describe('frameDeltaSeconds()', () => {
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
// requestAnimationFrame is suspended while hidden, so the first timestamp
// back can be minutes stale
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
});
test('ignores nonsense deltas', () => {
expect(frameDeltaSeconds(-5)).toBe(0);
expect(frameDeltaSeconds(Number.NaN)).toBe(0);
});
});
describe('advance()', () => {
test('accumulates sub-pixel movement without losing any to rounding', () => {
// 32px/s sampled at 60fps is 0.53px a frame: rounding each frame would stall
const pxPerSecond = 32;
const frame = 1 / 60;
let position = 0;
for (let i = 0; i < 100; i += 1) {
position = advance(position, pxPerSecond, frame, 10_000).position;
}
expect(position).toBeCloseTo((pxPerSecond * 100) / 60, 5);
});
test('reports the end of the script once the bottom is reached', () => {
expect(advance(499, 100, 1, 500).atEnd).toBe(true);
expect(advance(100, 100, 1, 500).atEnd).toBe(false);
});
test('reports the end without bounding the position, which the caller owns', () => {
// the loop has to bound the position anyway, for nudges and for a document
// which shrank, so this does not do it a second time
expect(advance(490, 100, 1, 500)).toEqual({ position: 590, atEnd: true });
});
test('never reports the end for a document which does not overflow', () => {
// it may simply not have been measured yet, and stopping playback on an
// unmeasured document would look like the prompter refusing to run
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
});
});
describe('easeCatchUp()', () => {
test('approaches the target monotonically from either side', () => {
let fromAbove = 500;
let fromBelow = 0;
let previousAbove = 501;
let previousBelow = -1;
for (let i = 0; i < 20; i += 1) {
fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
expect(fromBelow).toBeGreaterThan(previousBelow);
expect(fromBelow).toBeLessThanOrEqual(500);
expect(fromAbove).toBeLessThan(previousAbove);
expect(fromAbove).toBeGreaterThanOrEqual(0);
previousBelow = fromBelow;
previousAbove = fromAbove;
}
});
test('settles exactly on the target instead of creeping forever', () => {
let position = 0;
for (let i = 0; i < 300; i += 1) {
position = easeCatchUp(position, 500, 1 / 60);
}
expect(position).toBe(500);
});
test('is framerate independent, so a jump takes the same time on any display', () => {
let atSixty = 0;
for (let i = 0; i < 60; i += 1) {
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
}
let atThirty = 0;
for (let i = 0; i < 30; i += 1) {
atThirty = easeCatchUp(atThirty, 1000, 1 / 30);
}
expect(atSixty).toBeCloseTo(atThirty, 3);
});
});
@@ -1,218 +0,0 @@
import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata';
import { buildScript, composeFlip } from '../teleprompter.utils';
function makeEvent(id: string, overrides: Partial<OntimeEntry> = {}): OntimeEntry {
return {
type: SupportedEntry.Event,
id,
cue: id.toUpperCase(),
title: `Title ${id}`,
note: `Note ${id}`,
skip: false,
custom: { script: `Script ${id}` },
parent: null,
...overrides,
} as OntimeEntry;
}
function makeGroup(id: string, title: string, entries: string[]): OntimeEntry {
return { type: SupportedEntry.Group, id, title, entries } as OntimeEntry;
}
function makeMetadata(overrides: Partial<RundownMetadata> = {}): RundownMetadata {
return { isPast: false, isLoaded: false, groupId: null, ...overrides } as RundownMetadata;
}
function makeRundown(entries: OntimeEntry[], flatOrder?: string[]): Rundown {
return {
id: 'default',
title: 'test',
order: flatOrder ?? entries.map((entry) => entry.id),
flatOrder: flatOrder ?? entries.map((entry) => entry.id),
entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
revision: 1,
};
}
const customFields: CustomFields = {
script: { type: 'text', colour: '', label: 'Script' },
poster: { type: 'image', colour: '', label: 'Poster' },
};
const defaultOptions = {
scriptSource: 'custom-script',
heading: 'title' as const,
hideEmpty: true,
showGroups: true,
};
function metadataFor(ids: string[], overrides: Record<string, Partial<RundownMetadata>> = {}): RundownMetadataObject {
return Object.fromEntries(ids.map((id) => [id, makeMetadata(overrides[id])]));
}
describe('buildScript()', () => {
test('resolves the script from the selected custom field, in rundown order', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks).toHaveLength(2);
expect(blocks.map((block) => block.id)).toEqual(['a', 'b']);
expect(blocks[0].text).toBe('Script a');
});
test('returns nothing when no script source is selected', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'none' })).toEqual(
[],
);
});
test('refuses an image custom field, which the select filters but the URL does not', () => {
const rundown = makeRundown([makeEvent('a', { custom: { poster: 'https://example.com/a.png' } })]);
const blocks = buildScript(rundown, metadataFor(['a']), customFields, {
...defaultOptions,
scriptSource: 'custom-poster',
});
expect(blocks).toEqual([]);
});
test('reads the note and the title as script sources', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'note' })[0].text,
).toBe('Note a');
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'title' })[0].text,
).toBe('Title a');
});
test('skips entries which are not events', () => {
const rundown = makeRundown([
makeEvent('a'),
{ type: SupportedEntry.Delay, id: 'd', duration: 10 } as OntimeEntry,
{ type: SupportedEntry.Milestone, id: 'm', title: 'milestone' } as OntimeEntry,
]);
const blocks = buildScript(rundown, metadataFor(['a', 'd', 'm']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['a']);
});
test('skips events flagged as skipped', () => {
const rundown = makeRundown([makeEvent('a', { skip: true }), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['b']);
});
test('hideEmpty drops events with no script text', () => {
const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]);
expect(buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions).map((b) => b.id)).toEqual(['b']);
expect(
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, hideEmpty: false }).map(
(b) => b.id,
),
).toEqual(['a', 'b']);
});
test('marks the block belonging to the loaded event', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.isLoaded)).toEqual([false, true]);
});
test('emits a group title once, on the first block of the group', () => {
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'b']), makeEvent('a'), makeEvent('b')],
['g', 'a', 'b'],
);
const metadata = metadataFor(['g', 'a', 'b'], { a: { groupId: 'g' }, b: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
});
test('emits each group title as the script moves between groups', () => {
const rundown = makeRundown(
[
makeGroup('g1', 'Morning session', ['a']),
makeEvent('a'),
makeGroup('g2', 'Afternoon session', ['b']),
makeEvent('b'),
],
['g1', 'a', 'g2', 'b'],
);
const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
});
test('repeats a group title when the script returns to it after an ungrouped event', () => {
// the reader has lost the context by then, so naming the group again is right
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
['g', 'a', 'b', 'c'],
);
const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
});
test('does not emit group titles when they are turned off', () => {
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, { ...defaultOptions, showGroups: false });
expect(blocks[0].groupTitle).toBeNull();
});
describe('headings', () => {
const rundown = makeRundown([makeEvent('a')]);
const metadata = metadataFor(['a']);
test('shows the title, the cue, both, or nothing', () => {
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'title' })[0].heading).toBe(
'Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'cue' })[0].heading).toBe('A');
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'A · Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
});
test('leaves no dangling separator when an event has no cue', () => {
const noCue = makeRundown([makeEvent('a', { cue: '' })]);
expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'Title a',
);
});
});
});
describe('composeFlip()', () => {
test('passes the per view flips through when Flip Screen is off', () => {
expect(composeFlip(false, false, false)).toEqual({ flipH: false, flipV: false });
expect(composeFlip(true, false, false)).toEqual({ flipH: true, flipV: false });
expect(composeFlip(false, true, false)).toEqual({ flipH: false, flipV: true });
});
test('Flip Screen alone flips both axes, matching rotate(180deg) in every other view', () => {
expect(composeFlip(false, false, true)).toEqual({ flipH: true, flipV: true });
});
test('a horizontal flip and Flip Screen leave only the vertical axis flipped', () => {
// scale(-1, 1) composed with scale(-1, -1) is scale(1, -1)
expect(composeFlip(true, false, true)).toEqual({ flipH: false, flipV: true });
});
test('both flips cancel Flip Screen out', () => {
expect(composeFlip(true, true, true)).toEqual({ flipH: false, flipV: false });
});
});
@@ -1,135 +0,0 @@
import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
canReengageFollow: boolean;
atEnd: boolean;
controller: TeleprompterController;
onToggleHelp: () => void;
}
export default function ControlOverlay({
isRunning,
speed,
canReengageFollow,
atEnd,
controller,
onToggleHelp,
}: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true);
// Pointer activation yields focus so the next Space reaches the transport.
const press = (action: () => void) => (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
action();
};
return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<Tooltip
text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(controller.togglePlay)}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
/>
}
>
{isRunning ? <IoPause /> : <IoPlay />}
</Tooltip>
<Tooltip
text='Slow down (Left arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down'
/>
}
>
<IoRemove />
</Tooltip>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<Tooltip
text='Speed up (Right arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up'
/>
}
>
<IoAdd />
</Tooltip>
<Tooltip
text='Rewind to the top (Home)'
render={
<IconButton
variant={atEnd ? 'primary' : 'subtle-white'}
size='large'
onClick={press(() => controller.rewind())}
aria-label='Rewind to top'
/>
}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text={
canReengageFollow ? 'Jump back to the loaded event and follow it again (L)' : 'Following the loaded event'
}
render={
<IconButton
variant={canReengageFollow ? 'primary' : 'subtle-white'}
size='large'
disabled={!canReengageFollow}
onClick={press(controller.reengageFollow)}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
/>
}
>
<IoLocate />
</Tooltip>
<Tooltip
text='Keyboard shortcuts (?)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(onToggleHelp)}
aria-label='Keyboard shortcuts'
/>
}
>
<IoHelpCircleOutline />
</Tooltip>
</div>
);
}
@@ -1,102 +0,0 @@
import { Dialog } from '@base-ui/react/dialog';
import { IoClose } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
interface HelpOverlayProps {
isOpen: boolean;
onClose: () => void;
}
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Dialog.Portal>
<Dialog.Backdrop className='teleprompter__help' />
<Dialog.Popup className='teleprompter__help-card'>
<div className='teleprompter__help-header'>
<Dialog.Title className='teleprompter__help-title'>Prompter shortcuts</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose} aria-label='Close'>
<IoClose />
</IconButton>
</div>
<ShortcutGroups className='teleprompter__help-groups'>
<ShortcutGroup title='Transport'>
<Shortcut label='Start / stop scrolling'>
<Combo keys={['Space']} />
</Shortcut>
<Shortcut label='Slower / faster'>
<Combo keys={['←']} />
<Separator />
<Combo keys={['→']} />
</Shortcut>
<Shortcut label='Larger speed steps'>
<Combo keys={['Shift', '←']} />
<Separator />
<Combo keys={['Shift', '→']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Nudge one line'>
<Combo keys={['↑']} />
<Separator />
<Combo keys={['↓']} />
</Shortcut>
<Shortcut label='Jump a screen'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to top / end'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Rewind and stop'>
<Combo keys={['Esc']} />
</Shortcut>
<Shortcut label='Follow the loaded event again'>
<Combo keys={['L']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Display'>
<Shortcut label='Font size'>
<Combo keys={['+']} />
<Separator />
<Combo keys={['-']} />
</Shortcut>
<Shortcut label='Reset font size'>
<Combo keys={['0']} />
</Shortcut>
<Shortcut label='Flip horizontally / vertically'>
<Combo keys={['F']} />
<Separator />
<Combo keys={['Shift', 'F']} />
</Shortcut>
<Shortcut label='Show this list'>
<Combo keys={['?']} />
</Shortcut>
</ShortcutGroup>
</ShortcutGroups>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -1,22 +0,0 @@
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
block: ScriptBlock;
registerRef: (id: string, element: HTMLElement | null) => void;
}
export default function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
'use memo';
return (
<section
className='teleprompter__block'
ref={(element) => registerRef(block.id, element)}
data-loaded={block.isLoaded || undefined}
>
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
<p className='teleprompter__body'>{block.text}</p>
</section>
);
}
@@ -1,65 +0,0 @@
import { SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
export type TeleprompterKeyEvent = {
code: string;
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
repeat: boolean;
};
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
if (event.ctrlKey || event.metaKey || event.altKey) {
return null;
}
switch (event.code) {
case 'Space':
return event.repeat ? null : { type: 'togglePlay' };
case 'ArrowDown':
return { type: 'nudge', lines: 1 };
case 'ArrowUp':
return { type: 'nudge', lines: -1 };
case 'PageDown':
return { type: 'page', direction: 1 };
case 'PageUp':
return { type: 'page', direction: -1 };
case 'ArrowRight':
return { type: 'speed', delta: event.shiftKey ? SPEED_STEP_COARSE : SPEED_STEP };
case 'ArrowLeft':
return { type: 'speed', delta: event.shiftKey ? -SPEED_STEP_COARSE : -SPEED_STEP };
case 'Home':
return { type: 'rewind' };
case 'End':
return { type: 'jumpToEnd' };
case 'Escape':
return { type: 'rewindAndPause' };
}
// Character bindings use key so they work across keyboard layouts.
switch (event.key) {
case '?':
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', steps: 1 };
case '-':
case '_':
return { type: 'fontSize', steps: -1 };
case '0':
return { type: 'resetFontSize' };
}
const lowerKey = event.key.toLowerCase();
if (lowerKey === 'f') {
return { type: 'flip', axis: event.shiftKey ? 'v' : 'h' };
}
if (lowerKey === 'l') {
return { type: 'reengageFollow' };
}
return null;
}
@@ -1,223 +0,0 @@
import type { CustomFields } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
import { DEFAULT_SPEED, MAX_FONT_SIZE, MAX_SPEED, MIN_FONT_SIZE, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
const headingOptions: { value: HeadingSource; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'cue', label: 'Cue' },
{ value: 'both', label: 'Cue and title' },
{ value: 'none', label: 'None' },
];
const headingSources = headingOptions.map((option) => option.value);
export const defaults = {
script: 'none',
heading: 'title' as HeadingSource,
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
};
const bounds = {
speed: [MIN_SPEED, MAX_SPEED],
fontSize: [MIN_FONT_SIZE, MAX_FONT_SIZE],
lineHeight: [1, 4],
textWidth: [20, 100],
readingLinePos: [0, 100],
} as const;
export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[] => {
const scriptOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
{ value: 'title', label: 'Title' },
]);
return [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'script',
title: 'Script',
description: 'Select the data source which holds the script to read',
type: 'option',
values: scriptOptions,
defaultValue: defaults.script,
},
{
id: 'heading',
title: 'Segment heading',
description: 'What to show above each segment of the script',
type: 'option',
values: headingOptions,
defaultValue: defaults.heading,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'speed',
title: 'Speed',
description: `Scroll speed in lines per minute (${MIN_SPEED}-${MAX_SPEED}). Adjustable live with the arrow keys`,
type: 'number',
defaultValue: defaults.speed,
},
{
id: 'followLoaded',
title: 'Follow loaded event',
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
type: 'boolean',
defaultValue: defaults.followLoaded,
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideEmpty',
title: 'Hide events without a script',
description: 'Prevents showing headings for events which have no script text',
type: 'boolean',
defaultValue: defaults.hideEmpty,
},
{
id: 'showGroups',
title: 'Show group names',
description: 'Shows the group name when the script moves into a new group',
type: 'boolean',
defaultValue: defaults.showGroups,
},
],
},
{
title: OptionTitle.StyleOverride,
collapsible: true,
options: [
{
id: 'fontSize',
title: 'Font size',
description: 'Base font size in pixels. Adjustable live with the + and - keys',
type: 'number',
defaultValue: defaults.fontSize,
},
{
id: 'lineHeight',
title: 'Line height',
description: 'Spacing between lines, as a multiple of the font size',
type: 'number',
defaultValue: defaults.lineHeight,
},
{
id: 'textWidth',
title: 'Text width',
description: 'Width of the text column as a percentage of the screen. Narrower means less eye movement',
type: 'number',
defaultValue: defaults.textWidth,
},
{
id: 'readingLine',
title: 'Reading line',
description: 'Shows a marker beside the line which should be read',
type: 'boolean',
defaultValue: defaults.readingLine,
},
{
id: 'readingLinePos',
title: 'Reading line position',
description: 'Position of the reading line as a percentage from the top of the screen',
type: 'number',
defaultValue: defaults.readingLinePos,
},
{
id: 'flipH',
title: 'Flip horizontally',
description:
'Mirrors the view horizontally, which is what a beam splitter rig needs. Toggled live with F. Flip Screen in the navigation menu flips both axes at once, which is a rotation rather than a mirror',
type: 'boolean',
defaultValue: defaults.flipH,
},
{
id: 'flipV',
title: 'Flip vertically',
description: 'Mirrors the view vertically. Note this also moves the reading line. Toggled live with Shift+F',
type: 'boolean',
defaultValue: defaults.flipV,
},
],
},
];
};
function toNumber(value: string | null, [min, max]: readonly [number, number], fallback: number): number {
if (value === null || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function toBoolean(value: string | null, fallback: boolean): boolean {
return value === null ? fallback : isStringBoolean(value);
}
function toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
export function getOptionsFromParams(
searchParams: URLSearchParams,
defaultValues?: URLSearchParams,
): TeleprompterOptions {
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
scriptSource: getValue('script') ?? defaults.script,
heading: toEnum(getValue('heading'), headingSources, defaults.heading),
hideEmpty: toBoolean(getValue('hideEmpty'), defaults.hideEmpty),
showGroups: toBoolean(getValue('showGroups'), defaults.showGroups),
speed: toNumber(getValue('speed'), bounds.speed, defaults.speed),
followLoaded: toBoolean(getValue('followLoaded'), defaults.followLoaded),
fontSize: toNumber(getValue('fontSize'), bounds.fontSize, defaults.fontSize),
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH),
flipV: toBoolean(getValue('flipV'), defaults.flipV),
};
}
export function useTeleprompterOptions(): TeleprompterOptions {
const [searchParams] = useSearchParams();
const maybePreset = use(PresetContext);
return useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
}
@@ -1,53 +0,0 @@
export const MIN_SPEED = 1;
export const MAX_SPEED = 40;
export const DEFAULT_SPEED = 14;
export const SPEED_STEP = 1;
export const SPEED_STEP_COARSE = 5;
export const MIN_FONT_SIZE = 12;
export const MAX_FONT_SIZE = 400;
const FONT_SIZE_STEP_RATIO = 1.1;
export function stepFontSize(current: number, steps: number): number {
return clamp(Math.round(current * FONT_SIZE_STEP_RATIO ** steps), MIN_FONT_SIZE, MAX_FONT_SIZE);
}
export const MAX_FRAME_DELTA_MS = 100;
const CATCH_UP_RATE = 8;
const CATCH_UP_EPSILON = 0.5;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(Math.max(value, min), max);
}
export function clampSpeed(value: number): number {
return clamp(value, MIN_SPEED, MAX_SPEED);
}
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
return (linesPerMinute / 60) * lineHeightPx;
}
export function frameDeltaSeconds(deltaMs: number): number {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
}
export function advance(
position: number,
pxPerSecond: number,
deltaSeconds: number,
maxScroll: number,
): { position: number; atEnd: boolean } {
const next = position + pxPerSecond * deltaSeconds;
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
}
export function easeCatchUp(current: number, target: number, deltaSeconds: number): number {
if (deltaSeconds <= 0) return current;
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
}
@@ -1,52 +0,0 @@
import type { MaybeString } from 'ontime-types';
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
export type ScriptBlock = {
id: string;
heading: string;
text: string;
groupTitle: MaybeString;
isLoaded: boolean;
};
export type TeleprompterOptions = {
scriptSource: string;
heading: HeadingSource;
hideEmpty: boolean;
showGroups: boolean;
/** lines per minute */
speed: number;
followLoaded: boolean;
fontSize: number;
lineHeight: number;
textWidth: number;
readingLine: boolean;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
};
export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
| { type: 'page'; direction: 1 | -1 }
| { type: 'speed'; delta: number }
| { type: 'rewind' }
| { type: 'rewindAndPause' }
| { type: 'jumpToEnd' }
| { type: 'flip'; axis: 'h' | 'v' }
| { type: 'fontSize'; steps: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
export type TeleprompterController = {
togglePlay: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
rewind: (alsoPause?: boolean) => void;
jumpToEnd: () => void;
reengageFollow: () => void;
};
@@ -1,80 +0,0 @@
import { type CustomFields, isOntimeEvent, isOntimeGroup, type MaybeString, type Rundown } from 'ontime-types';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { getPropertyValue } from '../common/viewUtils';
import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types';
type BuildScriptOptions = Pick<TeleprompterOptions, 'scriptSource' | 'heading' | 'hideEmpty' | 'showGroups'>;
function makeHeading(source: HeadingSource, cue: string, title: string): string {
switch (source) {
case 'cue':
return cue;
case 'title':
return title;
case 'both':
return [cue, title].filter(Boolean).join(' · ');
case 'none':
return '';
}
}
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
if (!scriptSource.startsWith('custom-')) {
return true;
}
const key = scriptSource.slice('custom-'.length);
return customFields[key]?.type === 'text';
}
/** Builds the continuous script in rundown order. */
export function buildScript(
rundown: Rundown,
rundownMetadata: RundownMetadataObject,
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, showGroups } = options;
if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) {
return [];
}
const blocks: ScriptBlock[] = [];
let lastGroupId: MaybeString = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (!isOntimeEvent(entry) || entry.skip) {
continue;
}
const metadata = rundownMetadata[id];
const text = getPropertyValue(entry, scriptSource, rundown.entries)?.trim() ?? '';
if (hideEmpty && !text) {
continue;
}
const groupId = metadata?.groupId ?? null;
let groupTitle: MaybeString = null;
if (showGroups && groupId && groupId !== lastGroupId) {
const group = rundown.entries[groupId];
groupTitle = isOntimeGroup(group) ? group.title : null;
}
lastGroupId = groupId;
blocks.push({
id,
heading: makeHeading(heading, entry.cue, entry.title),
text,
groupTitle,
isLoaded: Boolean(metadata?.isLoaded),
});
}
return blocks;
}
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
@@ -1,50 +0,0 @@
import { use, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
import { defaults } from './teleprompter.options';
const SETTLE_MS = 400;
interface LiveParams {
speed: number;
fontSize: number;
flipH: boolean;
flipV: boolean;
}
/** Keeps keyboard and overlay changes shareable through the URL. */
export function useSyncTeleprompterParams({ speed, fontSize, flipH, flipV }: LiveParams) {
const [, setSearchParams] = useSearchParams();
const isPreset = Boolean(use(PresetContext));
useEffect(() => {
if (isPreset) return;
const timeout = setTimeout(() => {
const values = {
speed: speed === defaults.speed ? null : String(speed),
fontSize: fontSize === defaults.fontSize ? null : String(fontSize),
flipH: flipH === defaults.flipH ? null : String(flipH),
flipV: flipV === defaults.flipV ? null : String(flipV),
};
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(values)) {
if (value === null) {
next.delete(key);
} else {
next.set(key, value);
}
}
return next;
},
{ replace: true },
);
}, SETTLE_MS);
return () => clearTimeout(timeout);
}, [flipH, flipV, fontSize, isPreset, setSearchParams, speed]);
}
@@ -1,74 +0,0 @@
import { useEffect, useRef } from 'react';
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
import { resolveTeleprompterAction } from './teleprompter.keymap';
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
interface UseTeleprompterControlsArgs {
controller: TeleprompterController;
isHelpOpen: boolean;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (steps: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
useEffect(() => {
argsRef.current = args;
});
useEffect(() => {
function applyAction(action: TeleprompterAction) {
const { controller, onFlip, onFontSize, onResetFontSize, onToggleHelp } = argsRef.current;
switch (action.type) {
case 'togglePlay':
return controller.togglePlay();
case 'nudge':
return controller.nudge(action.lines);
case 'page':
return controller.page(action.direction);
case 'speed':
return controller.changeSpeed(action.delta);
case 'rewind':
return controller.rewind();
case 'rewindAndPause':
return controller.rewind(true);
case 'jumpToEnd':
return controller.jumpToEnd();
case 'reengageFollow':
return controller.reengageFollow();
case 'flip':
return onFlip(action.axis);
case 'fontSize':
return onFontSize(action.steps);
case 'resetFontSize':
return onResetFontSize();
case 'toggleHelp':
return onToggleHelp();
}
}
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
return;
}
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
return;
}
const action = resolveTeleprompterAction(event);
if (!action) return;
event.preventDefault();
applyAction(action);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
@@ -1,26 +0,0 @@
import type { CustomFields, Rundown } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { type ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface TeleprompterData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
}
export function useTeleprompterData(): ViewData<TeleprompterData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
return {
data: {
rundown,
rundownMetadata,
customFields,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus]),
};
}
@@ -1,282 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
advance,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
const PAGE_FRACTION = 0.85;
const EXTERNAL_SCROLL_EPSILON = 2;
/**
* Uses layout coordinates because client rects include the teleprompter's CSS
* transform while scrollTop does not.
*/
function getLayoutTop(element: HTMLElement): number {
let top = 0;
let current: HTMLElement | null = element;
while (current) {
top += current.offsetTop;
current = current.offsetParent instanceof HTMLElement ? current.offsetParent : null;
}
return top;
}
interface UseTeleprompterScrollArgs {
initialSpeed: number;
followLoaded: boolean;
selectedEventId: string | null;
readingLinePos: number;
blocks: ScriptBlock[];
}
/**
* Owns the scroll position of the teleprompter.
*
* The animation frame is the only writer of scrollTop. Controls update refs so
* smooth scrolling and playback cannot compete for the DOM position.
*/
export function useTeleprompterScroll({
initialSpeed,
followLoaded,
selectedEventId,
readingLinePos,
blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const blockRefs = useRef(new Map<string, HTMLElement>());
const [isScrollerMounted, setIsScrollerMounted] = useState(false);
const attachScroller = useCallback((element: HTMLDivElement | null) => {
scrollerRef.current = element;
setIsScrollerMounted(Boolean(element && contentRef.current));
}, []);
const attachContent = useCallback((element: HTMLDivElement | null) => {
contentRef.current = element;
setIsScrollerMounted(Boolean(element && scrollerRef.current));
}, []);
// authoritative, sub-pixel scroll position
const posRef = useRef(0);
const lastTsRef = useRef(0);
const runningRef = useRef(false);
const speedRef = useRef(initialSpeed);
const lineHeightRef = useRef(0);
const maxScrollRef = useRef(0);
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
if (!el) return;
// Adopt wheel, touch, scrollbar, browser-clamp and find-in-page changes.
if (Math.abs(el.scrollTop - posRef.current) > EXTERNAL_SCROLL_EPSILON) {
posRef.current = el.scrollTop;
catchUpTargetRef.current = null;
}
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
let next = posRef.current;
if (pendingDeltaRef.current !== 0) {
next += pendingDeltaRef.current;
pendingDeltaRef.current = 0;
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (next === catchUpTargetRef.current) {
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
const clamped = clamp(next, 0, maxScrollRef.current);
if (clamped !== posRef.current) {
posRef.current = clamped;
el.scrollTop = clamped;
}
}, []);
useEffect(() => {
lastTsRef.current = performance.now();
let frame = requestAnimationFrame(function loop(timestamp) {
tick(timestamp);
frame = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(frame);
}, [tick]);
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const computed = getComputedStyle(content);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
const parsedFontSize = Number.parseFloat(computed.fontSize);
lineHeightRef.current = Number.isFinite(parsedFontSize) ? parsedFontSize * 1.2 : 0;
}
}, []);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
const [speedFromOption, setSpeedFromOption] = useState(initialSpeed);
// Keep the live speed in sync with view-param and remote redirect changes.
if (speedFromOption !== initialSpeed) {
setSpeedFromOption(initialSpeed);
setSpeed(initialSpeed);
}
useEffect(() => {
measure();
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
const observer = new ResizeObserver(() => measure());
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
}, [measure, isScrollerMounted]);
useEffect(() => {
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
return () => {
cancelled = true;
};
}, [measure]);
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
// Avoid re-following when a new blocks array contains the same selected event.
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
useEffect(() => {
if (!followLoaded || followLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
if (!scroller || !target) return;
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = getLayoutTop(target) - getLayoutTop(scroller) - offset;
catchUpTargetRef.current = clamp(top, 0, maxScrollRef.current);
setAtEnd(false);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock]);
const handleUserScroll = useCallback(() => {
if (followLoaded) {
setFollowLocked(true);
}
}, [followLoaded]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
blockRefs.current.set(id, element);
} else {
blockRefs.current.delete(id);
}
}, []);
const controller: TeleprompterController = useMemo(() => {
const play = () => {
if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) {
return;
}
runningRef.current = true;
setIsRunning(true);
setAtEnd(false);
};
const pause = () => {
runningRef.current = false;
setIsRunning(false);
};
return {
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;
setAtEnd(false);
},
page: (direction: 1 | -1) => {
const scroller = scrollerRef.current;
if (!scroller) return;
const distance = scroller.clientHeight * PAGE_FRACTION * direction;
catchUpTargetRef.current = clamp(posRef.current + distance, 0, maxScrollRef.current);
setAtEnd(false);
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
catchUpTargetRef.current = 0;
if (alsoPause) pause();
setAtEnd(false);
},
jumpToEnd: () => {
catchUpTargetRef.current = maxScrollRef.current;
if (maxScrollRef.current > 0) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
},
reengageFollow: () => setFollowLocked(false),
};
}, []);
return {
scrollerRef: attachScroller,
contentRef: attachContent,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.13.0-beta.1",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -151,7 +151,6 @@ function makeViewMenu(clientUrl) {
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
makeItemOpenInBrowser('Teleprompter', `${clientUrl}/teleprompter`),
{ type: 'separator' },
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.13.0-beta.1",
"version": "4.12.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.13.0-beta.1",
"version": "4.12.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -22,7 +22,8 @@
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
"ws": "^8.18.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"zod": "4.4.3"
},
"devDependencies": {
"@types/cookie-parser": "1.4.10",
@@ -0,0 +1,107 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { buildToolList, defineTool, makeToolRegistry, ok } from '../mcp.registry.js';
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
const echo = vi.fn(async (args: unknown) => ok(args));
const tools = [
defineTool(
'test_echo',
{
description: 'Echoes its arguments',
inputSchema: z.strictObject({
id: z.string().describe('An id'),
count: z.number().optional(),
filename: z
.string()
.optional()
.transform((filename) => filename?.toUpperCase()),
}),
annotations: READ,
},
echo,
),
defineTool(
'test_throws',
{
description: 'Always fails',
inputSchema: z.strictObject({}),
annotations: READ,
},
async () => {
throw new Error('the rundown is not loaded');
},
),
];
const dispatch = makeToolRegistry(tools);
describe('dispatchToolCall', () => {
it('passes validated arguments to the handler', async () => {
const result = await dispatch('test_echo', { id: 'entry-1', count: 2 });
expect(result.isError).toBeUndefined();
expect(echo).toHaveBeenCalledWith({ id: 'entry-1', count: 2 });
});
it('hands the handler the transformed value, not the raw one', async () => {
await dispatch('test_echo', { id: 'entry-1', filename: 'show.json' });
expect(echo).toHaveBeenLastCalledWith({ id: 'entry-1', filename: 'SHOW.JSON' });
});
it('rejects malformed arguments as a protocol error, without running the handler', async () => {
echo.mockClear();
// this is what the SDK itself does on the registerTool path, and what v2 will do
await expect(dispatch('test_echo', { count: 'two' })).rejects.toThrow(McpError);
await expect(dispatch('test_echo', { count: 'two' })).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
expect(echo).not.toHaveBeenCalled();
});
it('names every invalid field in a single message', async () => {
await expect(dispatch('test_echo', { count: 'two', bogus: true })).rejects.toThrow(
/Invalid arguments for test_echo:.*id.*count.*bogus/s,
);
});
it('rejects an unknown tool as a protocol error', async () => {
await expect(dispatch('test_missing', {})).rejects.toMatchObject({
code: ErrorCode.MethodNotFound,
message: expect.stringContaining('Unknown tool: test_missing'),
});
});
/**
* Business failures are returned to the agent rather than thrown, so that it can read the
* message and recover. Only malformed calls are protocol errors.
*/
it('returns failures from the services as tool errors', async () => {
const result = await dispatch('test_throws', {});
expect(result.isError).toBe(true);
expect(result.content).toEqual([{ type: 'text', text: '{"error":"the rundown is not loaded"}' }]);
});
});
describe('buildToolList', () => {
it('advertises the input shape, before any transform is applied', () => {
const [echoTool] = buildToolList(tools);
expect(echoTool.inputSchema).toEqual({
type: 'object',
properties: {
id: { type: 'string', description: 'An id' },
count: { type: 'number' },
filename: { type: 'string' },
},
required: ['id'],
additionalProperties: false,
});
});
it('carries the description and annotations through', () => {
const [echoTool] = buildToolList(tools);
expect(echoTool.description).toBe('Echoes its arguments');
expect(echoTool.annotations).toEqual(READ);
});
});
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import { TOOLS, TOOL_LIST } from '../mcp.tools.js';
describe('tool wiring', () => {
it('advertises every declared tool', () => {
expect(TOOL_LIST).toHaveLength(TOOLS.length);
expect(TOOL_LIST.map((tool) => tool.name)).toEqual(TOOLS.map((tool) => tool.name));
});
it('declares unique tool names', () => {
const names = TOOLS.map((tool) => tool.name);
expect(new Set(names).size).toBe(names.length);
});
it('gives every tool a handler, a description and annotations', () => {
const incomplete = TOOLS.filter(
(tool) => typeof tool.handler !== 'function' || !tool.config.description || !tool.config.annotations,
);
expect(incomplete.map((tool) => tool.name)).toEqual([]);
});
});
describe('generated input schemas', () => {
it('advertises an object schema for every tool', () => {
const notObjects = TOOL_LIST.filter((tool) => tool.inputSchema.type !== 'object');
expect(notObjects.map((tool) => tool.name)).toEqual([]);
});
/**
* Recursive schemas would generate $ref/$defs, which some MCP clients handle poorly.
* Batch creation is deliberately modelled two levels deep to avoid them.
*/
it('generates self contained schemas, without $ref or $defs', () => {
const withReferences = TOOL_LIST.filter((tool) => /\$ref|\$defs|\$schema/.test(JSON.stringify(tool.inputSchema)));
expect(withReferences.map((tool) => tool.name)).toEqual([]);
});
it('rejects unknown keys in every tool', () => {
const permissive = TOOL_LIST.filter((tool) => tool.inputSchema.additionalProperties !== false);
expect(permissive.map((tool) => tool.name)).toEqual([]);
});
it('describes required arguments', () => {
const byName = new Map(TOOL_LIST.map((tool) => [tool.name, tool.inputSchema]));
expect(byName.get('ontime_update_entry')?.required).toEqual(['id']);
expect(byName.get('ontime_delete_entries')?.required).toEqual(['ids']);
expect(byName.get('ontime_reorder_entry')?.required).toEqual(['entryId', 'destinationId', 'order']);
expect(byName.get('ontime_batch_update_entries')?.required).toEqual(['ids', 'data']);
expect(byName.get('ontime_create_custom_field')?.required).toEqual(['label', 'type', 'colour']);
expect(byName.get('ontime_rename_project')?.required).toEqual(['filename', 'newFilename']);
// tools which take no arguments advertise an empty object
expect(byName.get('ontime_list_rundowns')).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
});
it('advertises the pre-sanitisation shape of filenames', () => {
// the agent must be told the shape it should send, not the transformed value
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_delete_project')?.inputSchema;
expect(schema?.properties?.filename).toEqual({
type: 'string',
minLength: 1,
description: 'Project filename to delete (with .json extension)',
});
});
it('generates a stable schema for a representative tool', () => {
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_group_entries')?.inputSchema;
expect(schema).toMatchInlineSnapshot(`
{
"additionalProperties": false,
"properties": {
"colour": {
"description": "Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide",
"type": "string",
},
"custom": {
"additionalProperties": {
"type": "string",
},
"description": "Custom field values keyed by existing project field key",
"propertyNames": {
"type": "string",
},
"type": "object",
},
"ids": {
"description": "Existing top-level entry IDs to group",
"items": {
"type": "string",
},
"minItems": 1,
"type": "array",
},
"note": {
"description": "Free-text group note for production notes or references",
"type": "string",
},
"rundownId": {
"description": "Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.",
"type": "string",
},
"targetDuration": {
"description": "Planned length of the group in ms",
"type": "number",
},
"title": {
"description": "Group title shown in the rundown and views",
"type": "string",
},
},
"required": [
"ids",
],
"type": "object",
}
`);
});
});
@@ -0,0 +1,186 @@
import { dirname, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
import { TOOLS } from '../mcp.tools.js';
const PROJECTS_DIR = resolve('/projects');
function schemaFor(name: string) {
const tool = TOOLS.find((candidate) => candidate.name === name);
if (!tool?.config.inputSchema) {
throw new Error(`No input schema for ${name}`);
}
return tool.config.inputSchema;
}
/** Parses and returns the sanitised arguments, failing the test on invalid input */
function parse(name: string, args: Record<string, unknown>): Record<string, unknown> {
const result = schemaFor(name).safeParse(args);
if (!result.success) {
throw new Error(`Expected ${name} to accept the arguments: ${issuesOf(result.error)}`);
}
return result.data;
}
function issuesOf(error: z.ZodError): string {
return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
}
/** Returns the issue messages of arguments which must not be accepted */
function reject(name: string, args: Record<string, unknown>): string {
const result = schemaFor(name).safeParse(args);
if (result.success) {
throw new Error(`Expected ${name} to reject ${JSON.stringify(args)}`);
}
return issuesOf(result.error);
}
describe('required arguments', () => {
it('rejects an update without an entry id', () => {
expect(reject('ontime_update_entry', { title: 'Keynote' })).toContain('id');
});
it('rejects a reorder without a destination', () => {
expect(reject('ontime_reorder_entry', { entryId: 'entry-1', order: 'after' })).toContain('destinationId');
});
it('accepts a well formed update', () => {
expect(parse('ontime_update_entry', { id: 'entry-1', title: 'Keynote', timeStart: 32400000 })).toEqual({
id: 'entry-1',
title: 'Keynote',
timeStart: 32400000,
});
});
});
describe('argument types', () => {
it('rejects a stringified number where a time is expected', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', timeStart: '09:00' })).toContain('timeStart');
});
it('rejects a non-array list of ids', () => {
expect(reject('ontime_delete_entries', { ids: 'entry-1' })).toContain('ids');
});
it('rejects an empty list of ids to group', () => {
expect(reject('ontime_group_entries', { ids: [] })).toContain('Provide at least one entry ID to group.');
});
it('rejects an unknown reorder position', () => {
expect(reject('ontime_reorder_entry', { entryId: 'a', destinationId: 'b', order: 'sideways' })).toContain('order');
});
it('rejects an unknown entry type', () => {
expect(reject('ontime_create_entry', { type: 'interlude' })).toContain('type');
});
it('rejects non-string custom field values', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', custom: { Camera: 2 } })).toContain('custom.Camera');
});
it('accepts an insert anchor as an id or as true, but not as false', () => {
expect(parse('ontime_create_entry', { after: 'entry-1' })).toEqual({ after: 'entry-1' });
expect(parse('ontime_create_entry', { before: true })).toEqual({ before: true });
// `after: false` has no meaning: the services only check whether the anchor is present
expect(reject('ontime_create_entry', { after: false })).toContain('after');
});
});
describe('unknown keys', () => {
it('rejects fields which are not part of the tool', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', titel: 'typo' })).toContain('titel');
});
/**
* editCurrentProjectData spreads its argument into the stored project, and reacts to a
* `logo` key by deleting the current logo file. Only declared fields may reach it.
*/
it('rejects undeclared writes to project data', () => {
expect(reject('ontime_update_project_info', { title: 'Show', logo: 'other.png' })).toContain('logo');
expect(parse('ontime_update_project_info', { title: 'Show' })).toEqual({ title: 'Show' });
});
it('rejects entry internals which the services must own', () => {
expect(reject('ontime_update_entry', { id: 'entry-1', revision: 99 })).toContain('revision');
expect(reject('ontime_update_entry', { id: 'entry-1', parent: 'group-1' })).toContain('parent');
});
});
describe('project filenames', () => {
/**
* Filenames reach the filesystem through join(projectsDir, name), so the invariant that
* matters is that nothing which could traverse out of that directory survives parsing.
*/
const directoryOf = (filename: unknown) => dirname(resolve(PROJECTS_DIR, String(filename)));
it.each(['../../../etc/passwd', '../sibling.json', '..\\windows\\system32\\show.json', '/etc/shadow', 'show.json'])(
'confines %s to the projects directory',
(filename) => {
expect(directoryOf(parse('ontime_delete_project', { filename }).filename)).toBe(PROJECTS_DIR);
expect(directoryOf(parse('ontime_load_project', { filename }).filename)).toBe(PROJECTS_DIR);
},
);
it('confines both filenames of a rename', () => {
const args = parse('ontime_rename_project', { filename: '../a.json', newFilename: '../../b.json' });
expect(directoryOf(args.filename)).toBe(PROJECTS_DIR);
expect(directoryOf(args.newFilename)).toBe(PROJECTS_DIR);
});
it('appends the extension and trims the name', () => {
expect(parse('ontime_delete_project', { filename: ' show ' })).toEqual({ filename: 'show.json' });
expect(parse('ontime_delete_project', { filename: 'show.json' })).toEqual({ filename: 'show.json' });
});
it('rejects a filename which is empty once sanitised', () => {
expect(reject('ontime_delete_project', { filename: '..' })).toContain('sanitised');
expect(reject('ontime_delete_project', { filename: ' ' })).toBeTruthy();
});
it('does not append an extension when creating a project', () => {
// ProjectService appends the extension itself, and de-duplicates the name
expect(parse('ontime_create_project', { filename: ' my-show ' })).toEqual({ filename: 'my-show' });
expect(directoryOf(parse('ontime_create_project', { filename: '../my-show' }).filename)).toBe(PROJECTS_DIR);
});
});
describe('batch creation', () => {
const entry = { type: 'event', title: 'Talk', duration: 600000 };
it('accepts a group with children', () => {
const args = parse('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [entry] }],
});
expect(args.entries).toHaveLength(1);
});
it('rejects a group nested inside a group', () => {
const issues = reject('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [{ type: 'group', title: 'Nested' }] }],
});
expect(issues).toContain('type');
});
it('rejects children on entries which are not groups', () => {
expect(
reject('ontime_batch_create_entries', { entries: [{ type: 'event', title: 'Talk', children: [entry] }] }),
).toContain('Only group entries can have children.');
});
it('rejects unknown keys inside nested children', () => {
expect(
reject('ontime_batch_create_entries', {
entries: [{ type: 'group', title: 'Morning', children: [{ ...entry, titel: 'typo' }] }],
}),
).toContain('titel');
});
});
describe('lookups which need one of several arguments', () => {
it('requires an id or a cue', () => {
expect(reject('ontime_get_entry', {})).toContain('Provide id or cue');
expect(parse('ontime_get_entry', { cue: '1a' })).toEqual({ cue: '1a' });
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Tool registry for the Ontime MCP server.
*
* This is the only module aware of the low-level `@modelcontextprotocol/sdk` Server API.
* Tools are declared with `defineTool(name, config, handler)` — the same argument shape
* `McpServer.registerTool` takes — so migrating to the SDK v2 `registerTool` API means
* replacing the two functions below with a registration loop, leaving every tool untouched.
*
* Responsibilities kept here (and taken over by the SDK on v2):
* - deriving the advertised JSON Schema from the tool input schema
* - parsing tool arguments before a handler runs
*
* Argument shape is validated here; business rules (does this ID exist, is this custom
* field known) stay in mcp.service.ts.
*/
import {
ErrorCode,
McpError,
type CallToolResult,
type ListToolsResult,
type ToolAnnotations,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
/** Input schemas must describe an object: MCP tool arguments are always a record */
type ToolInput = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
type ToolConfig<Schema extends ToolInput> = {
description: string;
annotations: ToolAnnotations;
/** Tools which take no arguments declare an empty object, so that they too reject unknown keys */
inputSchema: Schema;
};
export type ToolDefinition = {
name: string;
config: ToolConfig<ToolInput>;
handler: (args: never) => Promise<CallToolResult>;
};
/**
* Declares a tool, tying the handler argument to the input schema.
* The generic is what keeps each entry of a heterogeneous tool array individually typed.
*/
export function defineTool<const Schema extends ToolInput>(
name: string,
config: ToolConfig<Schema>,
handler: (args: z.output<Schema>) => Promise<CallToolResult>,
): ToolDefinition {
return { name, config, handler } as ToolDefinition;
}
/**
* Converts a tool input schema to the JSON Schema advertised in tools/list.
*
* `io: 'input'` is required: schemas which sanitise their values with `.transform()`
* have an output type the agent must not be shown, and output mode cannot represent them.
*/
function toInputSchema(schema: ToolInput): ListToolsResult['tools'][number]['inputSchema'] {
// `$schema` is metadata about the document, not part of the tool contract
const { $schema: _$schema, ...jsonSchema } = z.toJSONSchema(schema, {
io: 'input',
target: 'draft-7',
unrepresentable: 'any',
reused: 'inline',
});
return jsonSchema as ListToolsResult['tools'][number]['inputSchema'];
}
/**
* Builds the tools/list payload.
* Call once at module scope: a new Server is created for every MCP request.
*/
export function buildToolList(tools: readonly ToolDefinition[]): ListToolsResult['tools'] {
return tools.map(({ name, config }) => ({
name,
description: config.description,
annotations: config.annotations,
inputSchema: toInputSchema(config.inputSchema),
}));
}
/**
* Flattens validation issues into a single line an agent can act on, eg.
* `id: Invalid input: expected string, received undefined; timeStart: ...`
*/
function formatIssues(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
}
export function makeToolRegistry(tools: readonly ToolDefinition[]) {
const byName = new Map(tools.map((tool) => [tool.name, tool]));
/**
* Validates the arguments of a tool call and runs its handler.
*
* Malformed calls are protocol errors (as they are in the SDK's own registerTool path),
* while failures from the services are returned as tool errors so that the agent can recover.
*/
return async function dispatchToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
const tool = byName.get(name);
if (!tool) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
const result = tool.config.inputSchema.safeParse(args);
if (!result.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: ${formatIssues(result.error)}`);
}
try {
return await tool.handler(result.data as never);
} catch (error) {
return err(error);
}
};
}
// ---- Response helpers ----
const text = (data: unknown): string => JSON.stringify(data);
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
export const err = (e: unknown): CallToolResult => ({
content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }],
isError: true,
});
+58 -51
View File
@@ -9,70 +9,77 @@
* Keep this file concise and update it when MCP-exposed fields change.
*/
// ---- Shared event field JSON schemas ----
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { z } from 'zod';
// ---- Shared event field schemas ----
// Imported by mcp.tools.ts and spread into the tool input schemas.
// The JSON Schema advertised to agents is generated from these, so the description
// of a field is written once and cannot drift from what is enforced at runtime.
export const EVENT_TIMER_FIELDS = {
timerType: {
type: 'string',
enum: ['count-down', 'count-up', 'clock', 'none'],
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
},
endAction: {
type: 'string',
enum: ['none', 'load-next', 'play-next'],
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
},
linkStart: {
type: 'boolean',
description:
timerType: z
.enum(TimerType)
.optional()
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
endAction: z
.enum(EndAction)
.optional()
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
linkStart: z
.boolean()
.optional()
.describe(
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
},
countToEnd: {
type: 'boolean',
description:
),
countToEnd: z
.boolean()
.optional()
.describe(
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
},
timeStrategy: {
type: 'string',
enum: ['lock-duration', 'lock-end'],
description:
),
timeStrategy: z
.enum(TimeStrategy)
.optional()
.describe(
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
},
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
} as const;
),
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
};
export const EVENT_WRITABLE_FIELDS = {
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
title: { type: 'string', description: 'Event title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description:
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
title: z.string().optional().describe('Event title shown in the rundown and views'),
note: z.string().optional().describe('Free-text note for production notes or references'),
colour: z
.string()
.optional()
.describe(
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
flag: {
type: 'boolean',
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
},
custom: {
type: 'object',
additionalProperties: { type: 'string' },
description:
),
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
flag: z
.boolean()
.optional()
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
custom: z
.record(z.string(), z.string())
.optional()
.describe(
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
},
),
...EVENT_TIMER_FIELDS,
} as const;
};
export const RUNDOWN_TARGET_FIELD = {
rundownId: {
type: 'string',
description:
rundownId: z
.string()
.optional()
.describe(
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
},
} as const;
),
};
// ---- Agent-readable schema document ----
// Served at ontime://schema. Agents read this once per session to orient themselves
+2 -7
View File
@@ -12,7 +12,7 @@ import {
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
import { TOOL_LIST, handleToolCall } from './mcp.tools.js';
export function createMcpServer(): Server {
const server = new Server(
@@ -20,12 +20,7 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ tools: TOOL_LIST }));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
File diff suppressed because it is too large Load Diff
+6 -13
View File
@@ -9,52 +9,45 @@ test.describe('test view navigation feature', () => {
test('Timeline', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timeline' }).click();
await expect(page.getByTestId('timeline-view')).toBeVisible();
page.locator('data-testid=timeline-view');
await expect(page).toHaveURL('/timeline');
});
test('Backstage', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Backstage' }).click();
await expect(page.getByTestId('backstage-view')).toBeVisible();
page.locator('data-testid=backstage-view');
await expect(page).toHaveURL('/backstage');
});
test('Studio Clock', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Studio Clock' }).click();
await expect(page.getByTestId('studio-view')).toBeVisible();
page.locator('data-testid=studio-view');
await expect(page).toHaveURL('/studio');
});
test('Countdown', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Countdown' }).click();
await expect(page.getByTestId('countdown-view')).toBeVisible();
page.locator('data-testid=countdown-view');
await expect(page).toHaveURL('/countdown');
});
test('Project Info', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Project Info' }).click();
await expect(page.getByTestId('project-view')).toBeVisible();
page.locator('data-testid=project-view');
await expect(page).toHaveURL('/info');
});
test('Timer', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timer', exact: true }).click();
await expect(page.getByTestId('timer-view')).toBeVisible();
page.locator('data-testid=timer-view');
await expect(page).toHaveURL('/timer');
});
test('Teleprompter', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Teleprompter' }).click();
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(page).toHaveURL('/teleprompter');
});
test('not-found', async ({ page }) => {
await page.goto('/not-found');
@@ -13,25 +13,3 @@ test('View params configures timer view', async ({ page }) => {
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/);
});
test('View params configures teleprompter view', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
await page.goto('/teleprompter?script=note');
const readingMarker = page.locator('.teleprompter__reading-marker');
await expect(readingMarker).toBeVisible();
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
const readingLineSwitch = page.locator('label:has(input[name="readingLine"]) [role="switch"]');
await expect(readingLineSwitch).toHaveAttribute('aria-checked', 'true');
await readingLineSwitch.click();
await page.getByTestId('apply-view-params').click();
await expect(page).toHaveURL(/.*readingLine=false/);
await expect(readingMarker).toHaveCount(0);
});
@@ -1,48 +0,0 @@
import { expect, test } from '@playwright/test';
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
// Earlier feature specs edit the loaded rundown. Restore the real demo project
// instead of manufacturing test-only events: its notes are the script fixture.
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note&flipV=true');
const view = page.getByTestId('teleprompter-view');
const scroller = page.getByTestId('teleprompter-scroller');
const speed = page.getByTestId('teleprompter-speed');
await expect(view).toBeVisible();
await expect(scroller).toBeVisible();
await expect(page.getByText('Music plays, holding slide on screens')).toBeVisible();
await expect(speed).toContainText('14');
await expect(view).toHaveCSS('transform', /^matrix\(1, 0, 0, -1/);
// Following uses layout coordinates, which must remain stable when the view's
// visual coordinates are inverted by a vertical transform.
const loadedBlock = page.locator('.teleprompter__block[data-loaded]');
await expect(loadedBlock).toBeAttached();
const expectedScrollTop = await loadedBlock.evaluate((element) => {
const scroller = element.closest('.teleprompter')?.querySelector<HTMLElement>('.teleprompter__scroller');
if (!scroller) throw new Error('Teleprompter scroller not found');
return Math.max(0, (element as HTMLElement).offsetTop - scroller.clientHeight * 0.25);
});
await expect
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - expectedScrollTop))
.toBeLessThan(2);
await page.goto('/teleprompter?script=note');
await page.keyboard.press('Space');
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await page.keyboard.press('Space');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('f');
await expect(speed).toContainText('15');
await expect(page).toHaveURL(/speed=15/);
await expect(page).toHaveURL(/flipH=true/);
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.13.0-beta.1",
"version": "4.12.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.13.0-beta.1",
"version": "4.12.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -11,7 +11,6 @@ export enum OntimeView {
StudioClock = 'studio',
Countdown = 'countdown',
ProjectInfo = 'info',
Teleprompter = 'teleprompter',
}
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;
+3
View File
@@ -275,6 +275,9 @@ importers:
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies:
'@types/cookie-parser':
specifier: 1.4.10