Compare commits

..

10 Commits

Author SHA1 Message Date
Claude 00b3ba8ed3 refactor(teleprompter): simplify the scroll engine and drop settings that overlap
Three simplifications from a review of where the complexity actually sat.

The animation frame loop now runs for the lifetime of the view instead of being
woken around each action. Waking a sleeping loop meant that every path which
could move the script had to remember to do it, across eight call sites, and
forgetting simply lost the action with no error. An idle frame costs one pass
of arithmetic and the browser suspends the loop entirely while the tab is
hidden. This removes ensureLoop, the frame handle and the work test.

Running always did break something, which is worth recording: any scroll the
loop had not made itself was reverted on the next frame, so a scrollbar drag or
find in page would snap back. The loop now compares the scroller against where
it left it and adopts the DOM position when they differ. That covers every
cause rather than only the gestures which had a handler, so the adopt flag and
its wiring are gone too, and there is an end to end test for it.

Dropped hidePast in favour of dimPast. They read as alternatives but only one
is safe during a read: removing past events shortens the document underneath
the reader, while dimming is only paint. Also dropped the shade reading line
variant, which drew the same gradient over the same box as dimPast, so turning
both on stacked two of them.

The reading line position was carried as two CSS variables, a percentage and a
unitless number. That split caused the padding bug earlier. One unitless
variable now serves both, scaled by 1% for the overlays and 1dvh for the
padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-14 19:17:16 +00:00
Claude 8233261606 fix(teleprompter): apply the flip params on change, not only on mount
The flip options seeded useState and were then never read again, so a change
took effect only on a fresh load. Toggling flip in the view options did
nothing until the page was reloaded.

They now sync when the param changes, the way the speed option already does,
while the F key still owns the state between changes.

This also matters beyond the params editor. Ontime can redirect a specific
client to a path, and the teleprompter keeps its whole configuration in the
query string, so one device can already reconfigure a prompter running on
another. Verified that a same route query change applies live and leaves the
scroll position untouched, which is what makes that usable during a show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-14 17:32:16 +00:00
Claude 23a184244c refactor(teleprompter): let the compiler memoise the view
The client runs babel-plugin-react-compiler in annotation mode, so 'use memo'
is the opt in and the codebase already uses it in eighteen places. The
teleprompter was the odd one out, hand rolling what the compiler does better.

Teleprompter and ScriptBlock now carry the directive, which replaces two
useMemo calls, four useCallback calls and a memo wrapper. Verified against the
compiler rather than assumed: the two files emit 46 and 15 memo cache slots
respectively, finer grained than the six the hand written version had.

The scroll engine and the key handler deliberately keep no directive. They
derive nothing and render nothing, they drive an animation frame loop through
refs, so there is nothing to memoise and no reason to put them through the
compiler.

Also fixes a hazard the conversion surfaced. The follow effect listed the
blocks array in its dependencies, but memoisation is a performance hint React
is free to discard, under the compiler as much as by hand. A rebuilt but
otherwise identical list would re-fire the jump to the loaded event and yank
the script back mid read. It now watches whether the loaded event has a block
at all, which is the condition it actually cared about and does not depend on
identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 19:19:20 +00:00
Claude e9289685fd refactor(navigation): suppress the Space hotkey by prop
Replaces the view hotkey store with a suppressSpaceHotkey prop on
ViewNavigationMenu, alongside the suppressSettings prop which already exists
for exactly this purpose: a view opting out of a navigation chrome behaviour.

The store had one advantage, that it covered the preset route without the
router naming the view. That is a single expression in PresetView, and paying
for it with a global claim was the wrong trade: the claim was lifecycle state
that had to be released on unmount to avoid stranding Space for the whole
client, where a prop simply describes the route.

Verified both routes: /teleprompter and a locked preset targeting the
teleprompter leave Space to the view, while the timer and a preset targeting
the timer still open the navigation menu with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 19:13:02 +00:00
Claude 323b30ea68 refactor(teleprompter): drop code that was not earning its keep
Review pass over the view.

Removed: estimateWordsPerLine and linesPerMinuteToWordsPerMinute, written for
a words-per-minute readout that was never built; play and pause on the
controller, which nothing outside the hook called; and three constants that
were exported but never imported.

The tick function was being reassigned to a ref on every render, which is a
side effect during render. It only ever touched refs and state setters, so it
is now a stable callback and the ref is gone.

The synthetic contentKey string is replaced by the memoised blocks array it
was standing in for. It was also a dependency of the ResizeObserver effect,
which tore the observer down and rebuilt it for no gain: the observer already
covers every reflow that changes the document.

ScriptBlock was memoised but never actually memoising, because the parent
built its ref callback inline and handed it a new identity every render. That
also churned the follow map, unregistering and re-registering every block. The
id is now bound inside the block against a stable callback.

Tests: dropped six that asserted arithmetic identities or wrapped clamps
rather than behaviour, and added three for branches that were untested,
group titles across and back into a group, and a heading with no cue.
The e2e rewind assertion waited 600ms for an eased scroll that needs about
900ms from a nudge and a second from the bottom of a long script; it now
polls. The navigation menu assertion raced app hydration and now waits for
the view, as the existing navigation tests do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 16:42:07 +00:00
Claude 15dac35748 feat(teleprompter): honour the shared Flip Screen toggle
The teleprompter was the only view ignoring the global Flip Screen option,
so it now composes with the per view flips.

The two turn out to be the same mechanism: the shared `.mirror` class is
rotate(180deg), which is the same matrix as scale(-1, -1), so Flip Screen
is exactly a flip on both axes at once. Folding it in with XOR means the
teleprompter matches every other view when Flip Screen is on, and there is
still only one transform on the element.

It cannot replace the per view flips. A rotation preserves handedness, so
it never produces the mirror image a beam splitter reflection needs, and it
cannot address one axis on its own. Those remain URL params, which also
lets a shared link to the talent screen carry the rig's setup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 05:25:44 +00:00
Claude f806244586 feat(teleprompter): add teleprompter view
Adds a teleprompter at /teleprompter which builds its script from the
rundown rather than from an uploaded file, so the read follows the show.

The script comes from a text custom field chosen per view, and the whole
rundown renders as one continuous document with a heading per segment.
That is how broadcast prompters work: the operator scrolls to the right
section as the show moves, so a hard cut on every event change would take
the tail of the line the talent is still reading. Following the loaded
event is a soft jump which releases when the user scrolls by hand, the
same interaction the operator view already uses.

Controls are local, and match the convention shared by prompter software:
space to run, arrows for speed and nudge, home to rewind, F to flip. That
convention doubles as the hardware protocol, since foot pedals and hand
controllers are USB HID devices emitting these keystrokes, so they work
with no setup. Space is claimed back from the navigation menu for the
lifetime of the view via a small store, since the router renders the menu
generically for presets and a prop would not reach it.

Scrolling uses native scrollTop on an overflow container, with the
animation frame loop as its only writer. Position is kept as a float in a
ref: at readable speeds the per frame movement is well under a pixel, so
rounding every frame would stall the scroll, and holding it in state would
re-render the document sixty times a second. Scroll anchoring and smooth
scroll behaviour are both disabled because each would be a second writer.

Notable details:
- flip applies to the view root, so a beam splitter inverts the scroll
  direction along with the text
- content padding is derived from the viewport height, not a percentage,
  which resolves against width and would strand the first line
- image custom fields are refused even when typed into the URL
- script text is rendered as a text node, never as markup

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 05:20:46 +00:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
36 changed files with 2481 additions and 131 deletions
+16 -1
View File
@@ -20,6 +20,7 @@ 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'));
@@ -95,6 +96,15 @@ 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 />} />
@@ -165,6 +175,7 @@ const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
[OntimeView.Teleprompter]: Teleprompter,
};
/**
@@ -214,7 +225,11 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<ViewNavigationMenu
isNavigationLocked={getIsNavigationLocked()}
suppressSettings
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
/>
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
@@ -13,10 +13,12 @@ interface ViewNavigationMenuProps {
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean;
/** leave Space to the view, for views which need the key themselves */
suppressSpaceHotkey?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
@@ -26,7 +28,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
[
'Space',
() => {
if (isNavigationLocked) return;
if (isNavigationLocked || suppressSpaceHotkey) return;
menuHandler.toggle();
},
{ preventDefault: true },
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
width: 100%;
height: 100%;
inset: 0;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={`Ontime ${appVersion}`}
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)}
</Panel.ListItem>
);
@@ -26,6 +26,7 @@ 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';
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
@@ -35,6 +35,7 @@ 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
@@ -5,6 +5,7 @@ export const navigatorConstants = [
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
{ url: 'info', label: 'Project Info' },
{ url: 'teleprompter', label: 'Teleprompter' },
];
// default time format to use for users in 12 hour clocks
@@ -0,0 +1,268 @@
@use '@/theme/viewerDefs' as *;
.teleprompter {
--tp-flip-x: 1;
--tp-flip-y: 1;
position: relative;
height: 100dvh;
width: 100%;
overflow: hidden;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, var(--tp-background, #000000));
color: var(--color-override, var(--tp-color, #ffffff));
/**
* The flip is applied to the whole view rather than to the text alone.
* A beam splitter reflects everything, so inverting only the text would leave
* the scroll direction reading backwards against the words.
* No transition: a flip mid show has to be instant.
*/
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;
/**
* Chrome's scroll anchoring silently adjusts scrollTop when content above the
* viewport changes height, and the rundown refetches on a timer while a show
* is running. It would be a second writer of scrollTop, fighting our loop.
*/
overflow-anchor: none;
/* the animation frame loop owns scrollTop: never let CSS animate our writes */
scroll-behavior: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.teleprompter__content {
width: var(--tp-text-width, 90%);
margin-inline: auto;
font-size: var(--tp-font-size, 64px);
line-height: var(--tp-line-height, 1.5);
text-align: var(--tp-align, left);
/**
* The first line has to be able to reach the reading line, and the last line
* has to be able to scroll all the way up to it. Without the bottom padding
* the script simply cannot be read to the end.
*
* These are expressed against the viewport height rather than as a percentage:
* percentage padding resolves against the containing block's *width*, which
* would put the first line nowhere near the reading line.
*/
padding-top: calc(var(--tp-reading-line, 40) * 1dvh);
padding-bottom: calc(100dvh - var(--tp-reading-line, 40) * 1dvh);
}
.teleprompter__block {
margin-bottom: 1.5em;
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
}
.teleprompter__group {
font-size: 0.4em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $viewer-label-color;
margin-bottom: 0.5em;
}
.teleprompter__heading {
font-size: 0.45em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $viewer-secondary-color;
margin-bottom: 0.4em;
}
.teleprompter__body {
/* operators write their own line breaks, they are part of the read */
white-space: pre-wrap;
overflow-wrap: break-word;
}
/* ---------------------------------------------------------------- overlays */
/**
* All overlays are absolutely positioned, never fixed: the flip transform on the
* view root makes it the containing block for fixed descendants.
*/
.teleprompter__dim {
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--tp-reading-line, 40) * 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;
left: 0;
right: 0;
top: calc(var(--tp-reading-line, 40) * 1%);
pointer-events: none;
&--line {
border-top: 2px solid rgba($accent-color, 0.6);
}
}
.teleprompter__arrow {
position: absolute;
top: -0.6em;
width: 0;
height: 0;
border-top: 0.6em solid transparent;
border-bottom: 0.6em solid transparent;
font-size: clamp(16px, 2vw, 32px);
&--left {
left: 0;
border-left: 0.9em solid $accent-color;
}
&--right {
right: 0;
border-right: 0.9em solid $accent-color;
}
}
.teleprompter__controls {
position: absolute;
bottom: min(2vh, 16px);
left: 50%;
transform: translateX(-50%);
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;
/* stay out of the eyeline while the script is rolling, until pointed at */
&--idle {
opacity: 0;
}
&:hover,
&:focus-within {
opacity: 1;
}
}
.teleprompter__control {
display: grid;
place-items: center;
width: 2em;
height: 2em;
font-size: clamp(14px, 1.4vw, 22px);
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
&:hover {
background: rgba(white, 16%);
}
&--attention {
color: $accent-color;
}
}
.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: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
max-height: 80%;
overflow-y: auto;
padding: clamp(16px, 2vw, 24px);
background: $viewer-card-bg-color;
border-radius: $element-border-radius;
font-size: $base-font-size;
}
.teleprompter__help-title {
font-size: $title-font-size;
margin-bottom: $view-element-gap;
}
.teleprompter__help-row {
display: flex;
gap: clamp(16px, 2vw, 24px);
padding: 0.25em 0;
}
.teleprompter__help-keys {
flex: 0 0 10em;
color: $viewer-color;
font-variant-numeric: tabular-nums;
}
.teleprompter__help-action {
color: $viewer-secondary-color;
}
.teleprompter__help-note {
margin-top: $view-element-gap;
color: $viewer-label-color;
}
.teleprompter__help-close {
margin-top: $view-element-gap;
padding: 0.4em 1em;
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
}
@@ -0,0 +1,175 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useEffect, 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 ReadingLine from './reading-line/ReadingLine';
import ScriptBlockView from './script-block/ScriptBlock';
import { getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { clampFontScale } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
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();
// the shared "Flip Screen" toggle from the navigation menu
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [fontScale, setFontScale] = useState(1);
const [flipH, setFlipH] = useState(options.flipH);
const [flipV, setFlipV] = useState(options.flipV);
const [showHelp, setShowHelp] = useState(false);
/**
* The flip params seed the local state, which the F key then owns.
* Without this the params would only ever apply on mount, so changing them in
* the view options, or from another device by redirecting this client to the
* same view with a different query, would silently do nothing.
* The speed option is kept live the same way, inside useTeleprompterScroll.
*/
useEffect(() => setFlipH(options.flipH), [options.flipH]);
useEffect(() => setFlipV(options.flipV), [options.flipV]);
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,
autoplay: options.autoplay,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const handleFlip = (axis: 'h' | 'v') => {
if (axis === 'h') {
setFlipH((current) => !current);
} else {
setFlipV((current) => !current);
}
};
const handleFontSize = (delta: number) => setFontScale((current) => clampFontScale(current + delta));
const handleResetFontSize = () => setFontScale(1);
const handleToggleHelp = () => setShowHelp((current) => !current);
useTeleprompterControls({
controller,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
});
const hasScriptSource = Boolean(options.scriptSource) && options.scriptSource !== 'none';
// Flip Screen is a flip on both axes, so it folds into the per view flips
const flip = composeFlip(flipH, flipV, isMirrored);
const viewStyles = {
'--tp-font-size': `${options.fontSize * fontScale}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
// unitless, so the stylesheet can scale it by 1% against the view height for
// the overlays and by 1dvh for the content padding. Percentage padding would
// resolve against width and put the first line nowhere near the reading line
'--tp-reading-line': options.readingLinePos,
'--tp-align': options.align,
'--tp-background': options.keyColour ?? '#000000',
'--tp-color': options.textColour ?? '#ffffff',
...(options.font ? { '--font-family-override': options.font } : {}),
} as CSSProperties;
return (
<div
className={cx(['teleprompter', flip.flipH && 'teleprompter--flip-h', flip.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>
<ReadingLine variant={options.readingLine} dimPast={options.dimPast} />
<ControlOverlay
isRunning={isRunning}
speed={speed}
followLocked={followLocked && options.followLoaded}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
</>
)}
{showHelp && <HelpOverlay onClose={handleToggleHelp} />}
</div>
);
}
@@ -0,0 +1,104 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { FONT_SCALE_STEP } 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', () => {
// a held key, or a pressed foot pedal, otherwise toggles playback dozens of times
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: 2 });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -2 });
});
test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed',
delta: 10,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed',
delta: -10,
});
});
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('font size is resolved by key so it survives other layouts', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toMatchObject({ type: 'fontSize' });
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toMatchObject({ type: 'fontSize' });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))?.type).toBe('fontSize');
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
});
test('plus increases and minus decreases', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({
type: 'fontSize',
delta: FONT_SCALE_STEP,
});
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({
type: 'fontSize',
delta: -FONT_SCALE_STEP,
});
});
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', () => {
// mod+, opens the view params and must keep working
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();
});
});
@@ -0,0 +1,89 @@
import { getOptionsFromParams } 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: null,
heading: 'title',
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
autoplay: false,
followLoaded: true,
fontSize: 64,
lineHeight: 1.5,
textWidth: 90,
align: 'left',
dimPast: true,
readingLine: 'line',
readingLinePos: 40,
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&dimPast=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
dimPast: false,
});
});
test('booleans which default to false can be turned on', () => {
const options = getOptionsFromParams(new URLSearchParams('autoplay=true&flipH=true&flipV=true'));
expect(options).toMatchObject({
autoplay: true,
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', () => {
// Number(null) is 0, so a naive parse would silently produce a speed of zero
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(64);
});
test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
expect(getOptionsFromParams(new URLSearchParams('readingLine=banana')).readingLine).toBe('line');
expect(getOptionsFromParams(new URLSearchParams('align=banana')).align).toBe('left');
});
test('adds the hash back to colour params', () => {
const options = getOptionsFromParams(new URLSearchParams('keyColour=112233&textColour=ffee00'));
expect(options.keyColour).toBe('#112233');
expect(options.textColour).toBe('#ffee00');
});
test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=50&script=custom-b'),
);
expect(options.speed).toBe(50);
expect(options.scriptSource).toBe('custom-b');
});
});
@@ -0,0 +1,118 @@
import {
advance,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_SPEED,
} 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('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('clamps at the bounds of the document', () => {
expect(advance(0, -100, 1, 500).position).toBe(0);
expect(advance(490, 100, 1, 500).position).toBe(500);
});
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('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);
});
});
@@ -0,0 +1,221 @@
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: null })).toEqual(
[],
);
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 });
});
});
@@ -0,0 +1,88 @@
import { IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove, IoAdd } from 'react-icons/io5';
import { cx } from '../../../common/utils/styleUtils';
import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
followLocked: boolean;
atEnd: boolean;
controller: TeleprompterController;
onToggleHelp: () => void;
}
/**
* On screen transport, for when nobody is at a keyboard.
* It fades out while the script is rolling so it does not sit in the talent's eyeline.
*/
export default function ControlOverlay({
isRunning,
speed,
followLocked,
atEnd,
controller,
onToggleHelp,
}: ControlOverlayProps) {
return (
<div className={cx(['teleprompter__controls', isRunning && 'teleprompter__controls--idle'])}>
<button
type='button'
className='teleprompter__control'
onClick={controller.togglePlay}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
>
{isRunning ? <IoPause /> : <IoPlay />}
</button>
<button
type='button'
className='teleprompter__control'
onClick={() => controller.changeSpeed(-2)}
aria-label='Slow down'
>
<IoRemove />
</button>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<button
type='button'
className='teleprompter__control'
onClick={() => controller.changeSpeed(2)}
aria-label='Speed up'
>
<IoAdd />
</button>
<button
type='button'
className={cx(['teleprompter__control', atEnd && 'teleprompter__control--attention'])}
onClick={() => controller.rewind()}
aria-label='Rewind to top'
>
<IoArrowUp />
</button>
{followLocked && (
<button
type='button'
className='teleprompter__control teleprompter__control--attention'
onClick={controller.reengageFollow}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
>
<IoLocate />
</button>
)}
<button type='button' className='teleprompter__control' onClick={onToggleHelp} aria-label='Keyboard shortcuts'>
<IoHelpCircleOutline />
</button>
</div>
);
}
@@ -0,0 +1,46 @@
interface HelpOverlayProps {
onClose: () => void;
}
const shortcuts: { keys: string; action: string }[] = [
{ keys: 'Space', action: 'Start / stop scrolling' },
{ keys: '← / →', action: 'Slower / faster (hold Shift for larger steps)' },
{ keys: '↑ / ↓', action: 'Nudge one line' },
{ keys: 'Page Up / Page Down', action: 'Jump a screen' },
{ keys: 'Home', action: 'Rewind to the top' },
{ keys: 'End', action: 'Jump to the end' },
{ keys: 'Esc', action: 'Rewind and stop' },
{ keys: '+ / -', action: 'Font size' },
{ keys: '0', action: 'Reset font size' },
{ keys: 'F / Shift + F', action: 'Flip horizontally / vertically' },
{ keys: 'L', action: 'Follow the loaded event again' },
{ keys: '?', action: 'Show this list' },
];
/**
* Most prompter foot pedals and hand controllers are USB HID devices which send
* these same keystrokes, so this list doubles as the hardware reference.
*/
export default function HelpOverlay({ onClose }: HelpOverlayProps) {
return (
<div className='teleprompter__help' role='dialog' aria-label='Keyboard shortcuts'>
<div className='teleprompter__help-card'>
<div className='teleprompter__help-title'>Controls</div>
<dl className='teleprompter__help-list'>
{shortcuts.map(({ keys, action }) => (
<div key={keys} className='teleprompter__help-row'>
<dt className='teleprompter__help-keys'>{keys}</dt>
<dd className='teleprompter__help-action'>{action}</dd>
</div>
))}
</dl>
<div className='teleprompter__help-note'>
Foot pedals and hand controllers which emit these keys work without any setup.
</div>
<button type='button' className='teleprompter__help-close' onClick={onClose}>
Close
</button>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { cx } from '../../../common/utils/styleUtils';
import type { ReadingLineVariant } from '../teleprompter.types';
interface ReadingLineProps {
variant: ReadingLineVariant;
dimPast: boolean;
}
/**
* The eye-line indicator: it marks where on the screen the talent should read,
* which keeps their eyeline near the lens instead of tracking down the page.
*/
export default function ReadingLine({ variant, dimPast }: ReadingLineProps) {
if (variant === 'none' && !dimPast) {
return null;
}
return (
<>
{dimPast && <div className='teleprompter__dim' />}
{variant !== 'none' && (
<div className={cx(['teleprompter__reading-line', `teleprompter__reading-line--${variant}`])}>
{variant === 'arrows' && (
<>
<span className='teleprompter__arrow teleprompter__arrow--left' />
<span className='teleprompter__arrow teleprompter__arrow--right' />
</>
)}
</div>
)}
</>
);
}
@@ -0,0 +1,28 @@
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 (
// the id is bound here rather than by the parent so the compiler can keep
// the callback stable: React re-runs a ref callback whenever its identity
// changes, which would unregister every block on every parent render
<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>}
{/* the script is user data, it is rendered as text and never as markup */}
<p className='teleprompter__body' data-prompter-body>
{block.text}
</p>
</section>
);
}
@@ -0,0 +1,87 @@
import { FONT_SCALE_STEP } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
/** speed step in lines per minute */
const SPEED_STEP = 2;
const SPEED_STEP_COARSE = 10;
/**
* The subset of a KeyboardEvent the keymap needs.
* Declaring it structurally keeps `resolveTeleprompterAction` testable with plain objects.
*/
export type TeleprompterKeyEvent = {
code: string;
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
repeat: boolean;
};
/**
* Maps a keyboard event onto a teleprompter action.
*
* The mapping deliberately matches the convention shared by prompter software
* (space to run, arrows for speed, home to rewind). That convention is also the
* hardware protocol: foot pedals and hand controllers are USB HID devices which
* emit these very keystrokes, so matching it is what makes them work here.
*
* @returns the action to run, or null when the view should ignore the event
*/
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
// never shadow browser or Ontime shortcuts, notably mod+, which opens the view params
if (event.ctrlKey || event.metaKey || event.altKey) {
return null;
}
switch (event.code) {
case 'Space':
// a held key or a pressed foot pedal repeats: without this guard a single
// press toggles playback dozens of times
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' };
}
// the remaining bindings are resolved by key rather than code so that they
// survive non-QWERTY layouts
switch (event.key) {
case '?':
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', delta: FONT_SCALE_STEP };
case '-':
case '_':
return { type: 'fontSize', delta: -FONT_SCALE_STEP };
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;
}
@@ -0,0 +1,270 @@
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, makeColourString } from '../common/viewUtils';
import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, ReadingLineVariant, TeleprompterOptions } from './teleprompter.types';
const headingOptions = [
{ value: 'title', label: 'Title' },
{ value: 'cue', label: 'Cue' },
{ value: 'both', label: 'Cue and title' },
{ value: 'none', label: 'None' },
];
const readingLineOptions = [
{ value: 'line', label: 'Line' },
{ value: 'arrows', label: 'Arrows' },
{ value: 'none', label: 'None' },
];
const alignOptions = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Centre' },
];
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: 'none',
},
{
id: 'heading',
title: 'Segment heading',
description: 'What to show above each segment of the script',
type: 'option',
values: headingOptions,
defaultValue: 'title',
},
],
},
{
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: DEFAULT_SPEED,
},
{
id: 'autoplay',
title: 'Start scrolling on load',
description: 'Whether the script starts scrolling as soon as the view opens',
type: 'boolean',
defaultValue: false,
},
{
id: 'followLoaded',
title: 'Follow loaded event',
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
type: 'boolean',
defaultValue: true,
},
],
},
{
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: true,
},
{
id: 'showGroups',
title: 'Show group names',
description: 'Shows the group name when the script moves into a new group',
type: 'boolean',
defaultValue: true,
},
],
},
{
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: 64,
},
{
id: 'lineHeight',
title: 'Line height',
description: 'Spacing between lines, as a multiple of the font size',
type: 'number',
defaultValue: 1.5,
},
{
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: 90,
},
{
id: 'align',
title: 'Text alignment',
description: 'Alignment of the script text',
type: 'option',
values: alignOptions,
defaultValue: 'left',
},
{
id: 'readingLine',
title: 'Reading line',
description: 'Style of the eye-line indicator which marks where to read',
type: 'option',
values: readingLineOptions,
defaultValue: 'line',
},
{
id: 'readingLinePos',
title: 'Reading line position',
description: 'Position of the reading line as a percentage from the top of the screen',
type: 'number',
defaultValue: 40,
},
{
id: 'dimPast',
title: 'Dim text already read',
description: 'Fades the text above the reading line',
type: 'boolean',
defaultValue: true,
},
{
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: false,
},
{
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: false,
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Open Sans (default)',
},
{
id: 'keyColour',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'textColour',
title: 'Text Colour',
description: 'Colour of the script text. Default: #ffffff',
type: 'colour',
defaultValue: 'ffffff',
},
],
},
];
};
/**
* Parses a numeric param, guarding against the Number(null) === 0 trap.
*/
function toNumberInRange(value: string | null, min: number, max: 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 toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
const headingSources: readonly HeadingSource[] = ['none', 'title', 'cue', 'both'];
const readingLineVariants: readonly ReadingLineVariant[] = ['none', 'line', 'arrows'];
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with getTeleprompterOptions
*/
export function getOptionsFromParams(
searchParams: URLSearchParams,
defaultValues?: URLSearchParams,
): TeleprompterOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
scriptSource: getValue('script'),
heading: toEnum(getValue('heading'), headingSources, 'title'),
hideEmpty: getValue('hideEmpty') === null ? true : isStringBoolean(getValue('hideEmpty')),
showGroups: getValue('showGroups') === null ? true : isStringBoolean(getValue('showGroups')),
speed: clampSpeed(toNumberInRange(getValue('speed'), MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)),
autoplay: isStringBoolean(getValue('autoplay')),
followLoaded: getValue('followLoaded') === null ? true : isStringBoolean(getValue('followLoaded')),
fontSize: toNumberInRange(getValue('fontSize'), 12, 400, 64),
lineHeight: toNumberInRange(getValue('lineHeight'), 1, 4, 1.5),
textWidth: toNumberInRange(getValue('textWidth'), 20, 100, 90),
align: toEnum(getValue('align'), ['left', 'center'] as const, 'left'),
dimPast: getValue('dimPast') === null ? true : isStringBoolean(getValue('dimPast')),
readingLine: toEnum(getValue('readingLine'), readingLineVariants, 'line'),
readingLinePos: toNumberInRange(getValue('readingLinePos'), 0, 100, 40),
flipH: isStringBoolean(getValue('flipH')),
flipV: isStringBoolean(getValue('flipV')),
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
textColour: makeColourString(getValue('textColour')),
};
}
/**
* Hook exposes the teleprompter view options
*/
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]);
}
@@ -0,0 +1,95 @@
/**
* Pure scroll arithmetic for the teleprompter.
*
* Everything here is side effect free so it can be unit tested without a DOM.
* The hook that owns the requestAnimationFrame loop is the only caller.
*/
/** lines per minute */
export const MIN_SPEED = 2;
export const MAX_SPEED = 200;
export const DEFAULT_SPEED = 30;
/** font size multiplier applied on top of the configured size by the +/- keys */
const MIN_FONT_SCALE = 0.4;
const MAX_FONT_SCALE = 3;
export const FONT_SCALE_STEP = 0.1;
/**
* requestAnimationFrame is suspended in background tabs, so the timestamp can
* jump by minutes when the view becomes visible again.
* We clamp the frame delta so a resume can never teleport the script.
*/
export const MAX_FRAME_DELTA_MS = 100;
/** how aggressively an eased jump converges on its target, per second */
const CATCH_UP_RATE = 8;
/** below this distance an eased jump is considered arrived */
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 clampFontScale(value: number): number {
return clamp(value, MIN_FONT_SCALE, MAX_FONT_SCALE);
}
/**
* Converts a speed in lines per minute into pixels per second.
* Lines per minute is the unit prompter operators think in, and it is
* independent of font size, which is why it is what we persist.
*/
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
return (linesPerMinute / 60) * lineHeightPx;
}
/**
* Clamps a raw frame delta and converts it to seconds.
* @param deltaMs milliseconds since the previous frame
*/
export function frameDeltaSeconds(deltaMs: number): number {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
}
/**
* Advances the scroll position at a constant rate.
*
* The position is kept as a float by the caller: at readable prompter speeds the
* per frame delta is well under a pixel, so rounding on every frame would stall
* the scroll entirely. We return the exact float and let the DOM round on write.
*/
export function advance(
position: number,
pxPerSecond: number,
deltaSeconds: number,
maxScroll: number,
): { position: number; atEnd: boolean } {
const next = clamp(position + pxPerSecond * deltaSeconds, 0, Math.max(maxScroll, 0));
// a document which does not overflow is never "at the end": it may simply not
// have been measured yet, and stopping playback on that would be wrong
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
}
/**
* Moves `current` towards `target` with an exponential ease.
*
* Framerate independent: the same wall clock duration produces the same curve
* regardless of how many frames it was sampled over.
*/
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;
}
export function hasArrived(current: number, target: number): boolean {
return Math.abs(current - target) < CATCH_UP_EPSILON;
}
@@ -0,0 +1,78 @@
import type { MaybeString } from 'ontime-types';
/** What the per-event heading shows above each script block */
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
/**
* Style of the eye-line indicator.
* Darkening what has already been read is the separate dimPast option, so there
* is deliberately no shade variant here: it would be the same element twice.
*/
export type ReadingLineVariant = 'none' | 'line' | 'arrows';
/** A single readable segment of the prompter document */
export type ScriptBlock = {
/** entry id, used as the follow target and the react key */
id: string;
/** heading text, already resolved from the heading option. Empty string when suppressed */
heading: string;
/** the script itself */
text: string;
/** group title, present only on the first block of a new group */
groupTitle: MaybeString;
/** the block belongs to the currently loaded event */
isLoaded: boolean;
};
export type TeleprompterOptions = {
/** 'custom-<key>' | 'note' | 'title' | 'none' | null */
scriptSource: string | null;
heading: HeadingSource;
hideEmpty: boolean;
showGroups: boolean;
/** lines per minute */
speed: number;
autoplay: boolean;
followLoaded: boolean;
fontSize: number;
lineHeight: number;
textWidth: number;
align: 'left' | 'center';
font?: string;
keyColour?: string;
textColour?: string;
dimPast: boolean;
readingLine: ReadingLineVariant;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
};
/**
* A discrete intent resolved from a keyboard event.
* Kept as data so the keymap can be unit tested without a DOM.
*/
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'; delta: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
/** The imperative surface the overlay and the key handler drive */
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;
};
@@ -0,0 +1,106 @@
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'>;
/**
* Resolves the heading shown above a script block.
*/
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 '';
}
}
/**
* An image custom field holds a URL, which is meaningless to read aloud.
* The params editor filters these out of the select, but the URL can be hand typed.
*/
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
if (!scriptSource.startsWith('custom-')) {
return true;
}
const key = scriptSource.slice('custom-'.length);
return customFields[key]?.type === 'text';
}
/**
* Flattens the rundown into the continuous document the prompter scrolls through.
*
* We iterate flatOrder so that events nested in groups arrive in reading order
* without a second pass.
*/
export function buildScript(
rundown: Rundown,
rundownMetadata: RundownMetadataObject,
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, showGroups } = options;
if (!scriptSource || 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;
}
// a group title is emitted once, on the first block that belongs to it
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;
}
/**
* Folds Ontime's global "Flip Screen" toggle into the per view flips.
*
* The shared `.mirror` class is `rotate(180deg)`, which is the same matrix as
* `scale(-1, -1)`: a flip on both axes at once. So the global toggle is exactly
* the pair of flips this view already has, and composing them with XOR keeps the
* teleprompter behaving like every other view without two transforms competing
* for the same property.
*
* It cannot replace the per view flips, though. A rotation preserves handedness,
* so it never yields the mirror image a beam splitter reflection needs; only a
* single axis flip does. That is why both exist.
*/
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
@@ -0,0 +1,82 @@
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;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (delta: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
/**
* Binds the prompter keymap.
*
* Space is the universal run/stop key on prompter software and the centre pedal
* of every three pedal controller, so this view cannot leave it to the
* navigation menu. The routes which render the teleprompter pass
* suppressSpaceHotkey to ViewNavigationMenu so the menu stands down.
*/
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
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.delta);
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;
}
// the params editor is a form, it owns the keyboard while it is open
if (useViewParamsEditorStore.getState().isOpen) {
return;
}
const action = resolveTeleprompterAction(event);
if (!action) return;
// without this the browser also scrolls the container natively and the
// movement is applied twice
event.preventDefault();
applyAction(action);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
@@ -0,0 +1,26 @@
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]),
};
}
@@ -0,0 +1,341 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from '../../common/utils/throttle';
import {
advance,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasArrived,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
/** how much of a screen a page jump moves */
const PAGE_FRACTION = 0.85;
/** how long the user has to stop scrolling before we consider the gesture over */
const FOLLOW_LOCK_THROTTLE = 1000;
/**
* How far the scroller may sit from where we left it before we treat the move as
* somebody else's. Comfortably above the rounding the browser applies to the
* fractional value we write, and well below a line.
*/
const EXTERNAL_SCROLL_EPSILON = 2;
interface UseTeleprompterScrollArgs {
initialSpeed: number;
autoplay: boolean;
followLoaded: boolean;
selectedEventId: string | null;
/** percentage from the top of the screen */
readingLinePos: number;
/** the rendered document, watched so a follow can retarget once its block exists */
blocks: ScriptBlock[];
}
/**
* Owns the scroll position of the teleprompter.
*
* The single most important rule here is that this hook is the *only* writer of
* scrollTop. Everything else (nudges, page jumps, rewinds, following the loaded
* event) writes a target or a delta into a ref, and the animation frame applies
* it. Mixing in a second writer, such as scrollTo({ behavior: 'smooth' }), makes
* the two fight each other and the scroll visibly stutters. That is also why we
* reuse the arithmetic of useFollowComponent rather than the hook itself.
*
* The position is deliberately held in a ref rather than in state: at readable
* prompter speeds the per frame movement is a fraction of a pixel, so this runs
* every animation frame, and re-rendering a long script that often is not viable.
*/
export function useTeleprompterScroll({
initialSpeed,
autoplay,
followLoaded,
selectedEventId,
readingLinePos,
blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const blockRefs = useRef(new Map<string, HTMLElement>());
// authoritative, sub-pixel scroll position
const posRef = useRef(0);
const lastTsRef = useRef(0);
const runningRef = useRef(false);
const speedPxSecRef = useRef(0);
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);
/**
* Advances the scroll by one frame.
*
* Runs on every frame for the lifetime of the view rather than being started
* and stopped around each action. Waking a sleeping loop meant every path that
* could move the script had to remember to do so, and forgetting simply lost
* the action with no error. A frame that has nothing to do costs one pass of
* arithmetic, and the browser suspends the whole loop while the tab is hidden.
*
* Reads and writes only refs, so it never needs rebuilding.
*/
const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
// no scroller while the view is showing an empty state
if (!el) return;
/**
* The scroller is no longer where this loop left it, so something else moved
* it: a wheel, a touch drag, a scrollbar, find in page, or the browser
* clamping us because the document got shorter. Whatever the cause, the DOM
* is now the truth and an eased jump in flight is stale.
*
* Detecting divergence covers every one of those without each having to
* announce itself, which is why there is no adopt flag for handlers to set.
*/
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;
// an explicit nudge overrides an eased jump in progress
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (hasArrived(next, catchUpTargetRef.current)) {
next = catchUpTargetRef.current;
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const result = advance(next, speedPxSecRef.current, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
posRef.current = clamp(next, 0, Math.max(maxScrollRef.current, 0));
// a fractional value is intentional, the browser rounds it for us while we
// keep the remainder, which is what makes slow speeds move at all
el.scrollTop = posRef.current;
}, []);
useEffect(() => {
lastTsRef.current = performance.now();
let frame = requestAnimationFrame(function loop(timestamp) {
tick(timestamp);
frame = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(frame);
}, [tick]);
/** recomputes the scroll bounds and the line height the speed is derived from */
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 sample = content.querySelector('[data-prompter-body]');
if (sample) {
const computed = getComputedStyle(sample);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
// line-height resolves to the string 'normal' when it is not set explicitly
const parsedFontSize = Number.parseFloat(computed.fontSize);
if (Number.isFinite(parsedFontSize)) {
lineHeightRef.current = parsedFontSize * 1.5;
}
}
}
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
}, []);
// keep the derived pixel speed in sync with the lines per minute the user sees
useEffect(() => {
speedRef.current = speed;
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speed, lineHeightRef.current);
}, [speed]);
// the configured speed is the starting point, live changes win afterwards
useEffect(() => {
setSpeed(clampSpeed(initialSpeed));
}, [initialSpeed]);
// the observer covers every reflow that matters, script edits included, so
// nothing else needs to ask for a remeasure
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]);
// webfonts land after first paint and reflow the whole document
useEffect(() => {
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
return () => {
cancelled = true;
};
}, [measure]);
// requestAnimationFrame is suspended while the tab is hidden, so the first
// timestamp on return is stale. Reset the baseline rather than jump.
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
// autoplay once, on mount
useEffect(() => {
if (!autoplay) return;
runningRef.current = true;
setIsRunning(true);
// eslint-disable-next-line react-hooks/exhaustive-deps -- autoplay is a starting condition, not a live one
}, []);
/**
* Whether the loaded event currently has a block to scroll to.
*
* The follow watches this rather than the blocks array, because memoisation is
* a performance hint that React is free to discard: keying off array identity
* would let a rebuilt but otherwise identical list re-fire the jump and yank
* the script back while it is being read.
*/
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
// follow the loaded event
useEffect(() => {
if (!followLoaded || followLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
if (!scroller || !target) return;
// same arithmetic as useFollowComponent, but we hand the result to our own
// loop instead of calling scrollTo
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - offset;
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock]);
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
/**
* Releases the follow when the operator takes over by hand.
*
* Only deliberate gestures count, which is why this listens for wheel and
* touch rather than the scroll event: scroll also fires for the loop's own
* writes, and following the loaded event would switch itself off on its way
* there. Adopting the resulting position is the tick's job, not this one's.
*/
const handleUserScroll = useCallback(() => {
if (followLoaded) {
lockFollow();
}
}, [followLoaded, lockFollow]);
// entry ids are not guaranteed to be valid CSS selectors, so the follow target
// is looked up through this map rather than with querySelector
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) {
// nothing left to read, rewinding is the only sensible way to resume
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, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
catchUpTargetRef.current = 0;
if (alsoPause) pause();
setAtEnd(false);
},
jumpToEnd: () => {
catchUpTargetRef.current = Math.max(maxScrollRef.current, 0);
},
reengageFollow: () => setFollowLocked(false),
};
}, []);
return {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
};
}
+14 -1
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
click: () => redirectWindow('/editor?settings=project__create'),
},
{
label: 'Load...',
@@ -151,6 +151,7 @@ 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`),
@@ -202,6 +203,18 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'),
},
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
],
},
{
@@ -1,7 +1,6 @@
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
@@ -54,12 +53,11 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25);
});
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => {
const state = {
clock: 78480789,
+5 -7
View File
@@ -1,7 +1,6 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import type { RuntimeState } from '../stores/runtimeState.js';
/**
@@ -97,18 +96,17 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock, _now } = state;
const { clock } = state;
const { startedAt } = state.timer;
const { pausedDuration, pausedAt } = state._timer;
const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) {
return null;
}
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
}
@@ -1,11 +1,10 @@
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(newState._now);
expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause();
expect(success).toBe(false);
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
});
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
});
test('runtime offset', async () => {
+16 -21
View File
@@ -63,9 +63,7 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedAt: Maybe<TimeOfDay>;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
@@ -78,12 +76,10 @@ export type RuntimeState = {
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
_now: Instant;
};
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -108,12 +104,6 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
@@ -146,7 +136,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -179,7 +169,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
setClock(state);
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = state._now - state._timer.pausedAt;
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
@@ -454,7 +447,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
}
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
setClock(state);
state._timer.pausedAt = state._now;
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
@@ -554,7 +547,9 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
setClock(runtimeState); // we update the clock on every update call
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -563,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
+10
View File
@@ -48,6 +48,16 @@ test.describe('test view navigation feature', () => {
await expect(page).toHaveURL('/timer');
});
test('Teleprompter', async ({ page }) => {
// note: openNavigationMenu presses Space, which the teleprompter claims for
// playback. Every test starts from the timer view, so reaching it is fine,
// but a test which navigates *away* from it must click the nav button.
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Teleprompter' }).click();
page.locator('data-testid=teleprompter-view');
await expect(page).toHaveURL('/teleprompter');
});
test('not-found', async ({ page }) => {
await page.goto('/not-found');
+135
View File
@@ -0,0 +1,135 @@
import { type Page, expect, test } from '@playwright/test';
/**
* The note field is used as the script source throughout: every event in the
* test fixture has one, which keeps these tests independent of how custom field
* keys happen to be spelled in the fixture.
*/
const teleprompterUrl = '/teleprompter?script=note';
function scroller(page: Page) {
return page.getByTestId('teleprompter-scroller');
}
function scrollTop(page: Page) {
return scroller(page).evaluate((element) => element.scrollTop);
}
test.describe('teleprompter', () => {
test('shows the script from the selected source', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(scroller(page)).toBeVisible();
// the fixture uses cue style notes, the first event is Albania
await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
});
test('asks for a script source when none is selected', async ({ page }) => {
await page.goto('/teleprompter');
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(page.getByText('Select which field holds the script in the view options')).toBeVisible();
});
test('space starts and stops the scroll', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
expect(await scrollTop(page)).toBe(0);
await page.keyboard.press('Space');
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
await page.keyboard.press('Space');
await page.waitForTimeout(200);
const afterPause = await scrollTop(page);
await page.waitForTimeout(500);
expect(await scrollTop(page)).toBe(afterPause);
});
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
// the navigation menu binds Space globally, the teleprompter claims it back
await page.goto('/timer');
await expect(page.locator('data-testid=timer-view')).toBeVisible();
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toHaveCount(0);
// and the claim is released once the view goes away
await page.goto('/timer');
await expect(page.locator('data-testid=timer-view')).toBeVisible();
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
});
test('arrow keys nudge and home rewinds', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
// the rewind eases rather than snapping, so poll rather than guess a duration
await page.keyboard.press('Home');
await expect.poll(() => scrollTop(page)).toBe(0);
});
test('keeps a scroll it did not make itself', async ({ page }) => {
// the animation frame loop runs for the lifetime of the view, so it has to
// notice when something else moves the scroller, otherwise a scrollbar drag
// or find in page would be snapped back on the next frame
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
await scroller(page).evaluate((element) => {
element.scrollTop = 400;
});
await page.waitForTimeout(500);
expect(await scrollTop(page)).toBe(400);
});
test('arrow keys change the speed', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
const readout = page.getByTestId('teleprompter-speed');
const before = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
await page.keyboard.press('ArrowRight');
const faster = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
expect(faster).toBeGreaterThan(before);
await page.keyboard.press('ArrowLeft');
const slower = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
expect(slower).toBe(before);
});
test('f mirrors the view for a beam splitter rig', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
const view = page.getByTestId('teleprompter-view');
await page.keyboard.press('f');
// a horizontal flip is a negative x scale in the computed matrix
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
expect(transform.startsWith('matrix(-1')).toBe(true);
});
test('honours the flip and reading line params', async ({ page }) => {
await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows');
const view = page.getByTestId('teleprompter-view');
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
// a vertical flip leaves x positive and makes the y scale negative
expect(transform).toMatch(/^matrix\(1, 0, 0, -1/);
});
});
@@ -11,6 +11,7 @@ export enum OntimeView {
StudioClock = 'studio',
Countdown = 'countdown',
ProjectInfo = 'info',
Teleprompter = 'teleprompter',
}
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;