feat(telemprompter): remote control

This commit is contained in:
Carlos Valente
2026-09-09 20:33:11 +02:00
parent 150e0c83e9
commit e1190b1ec7
17 changed files with 445 additions and 111 deletions
+25
View File
@@ -42,6 +42,27 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
type SocketListener = (payload: unknown) => void;
const socketListeners = new Map<WsPacketToClient['tag'], Set<SocketListener>>();
export function subscribeSocket<T extends WsPacketToClient['tag']>(
tag: T,
listener: (payload: Extract<WsPacketToClient, { tag: T }>['payload']) => void,
) {
const listeners = socketListeners.get(tag) ?? new Set();
const socketListener = listener as SocketListener;
listeners.add(socketListener);
socketListeners.set(tag, listeners);
return () => {
listeners.delete(socketListener);
};
}
function notifySocketListeners(tag: WsPacketToClient['tag'], payload: unknown) {
socketListeners.get(tag)?.forEach((listener) => listener(payload));
}
const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
@@ -124,6 +145,10 @@ export const connectSocket = () => {
}
switch (tag) {
case MessageTag.TeleprompterCommand: {
notifySocketListeners(tag, payload);
break;
}
case MessageTag.Pong: {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntimeProperty('ping', offset);
@@ -43,6 +43,10 @@
}
}
.teleprompter--controlled .teleprompter__scroller {
overflow-y: hidden;
}
.teleprompter__content {
width: var(--tp-text-width);
margin-inline: auto;
@@ -1,11 +1,12 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useState } from 'react';
import { MessageTag, OntimeView } from 'ontime-types';
import { type CSSProperties, useEffect, useRef, 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 { sendSocket, subscribeSocket } from '../../common/utils/socket';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../common/loader/Loader';
import ControlOverlay from './control-overlay/ControlOverlay';
@@ -15,12 +16,33 @@ import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './tele
import { stepFontSize } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
import { useTeleprompterControls } from './useTeleprompterControls';
import { applyTeleprompterCommand, useTeleprompterControls } from './useTeleprompterControls';
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
import { useTeleprompterScroll } from './useTeleprompterScroll';
import './Teleprompter.scss';
const commandHistoryLimit = 100;
function rememberCommandId(receivedIds: Set<string>, commandId: string): boolean {
if (receivedIds.has(commandId)) return false;
receivedIds.add(commandId);
if (receivedIds.size > commandHistoryLimit) {
for (const oldestCommandId of receivedIds) {
receivedIds.delete(oldestCommandId);
break;
}
}
return true;
}
function getEmptyMessage(scriptSource: string, blockCount: number): string | null {
if (scriptSource === 'none') return 'Select which field holds the script in the view options';
if (blockCount === 0) return 'There is no script text in the selected field';
return null;
}
export default function TeleprompterLoader() {
const { data, status } = useTeleprompterData();
@@ -45,6 +67,10 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [showHelp, setShowHelp] = useState(false);
const [controlMode, setControlMode] = useState(options.controlMode);
const receivedCommandIds = useRef(new Set<string>());
useEffect(() => setControlMode(options.controlMode), [options.controlMode]);
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
@@ -67,7 +93,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
showGroups: options.showGroups,
});
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, parkedAt } =
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, isFollowingLoadedEvent, parkedAt } =
useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
@@ -87,6 +113,8 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
const handleToggleHelp = () => setShowHelp((current) => !current);
const handleToggleControlMode = () => setControlMode((current) => (current === 'free' ? 'controlled' : 'free'));
const isControlled = controlMode === 'controlled';
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
@@ -97,9 +125,29 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
enabled: !isControlled,
});
const hasScriptSource = options.scriptSource !== 'none';
useEffect(() => {
return subscribeSocket(MessageTag.TeleprompterCommand, ({ commandId, command }) => {
if (!isControlled || !rememberCommandId(receivedCommandIds.current, commandId)) return;
applyTeleprompterCommand(command, controller);
});
}, [controller, isControlled]);
useEffect(() => {
sendSocket(MessageTag.ClientSet, {
teleprompter: {
mode: controlMode,
playback: isRunning ? 'playing' : 'paused',
speed,
isFollowingLoadedEvent,
parkedAt,
},
});
}, [controlMode, isFollowingLoadedEvent, isRunning, parkedAt, speed]);
const emptyMessage = getEmptyMessage(options.scriptSource, blocks.length);
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
@@ -114,6 +162,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
<div
className={cx([
'teleprompter',
isControlled && 'teleprompter--controlled',
effectiveFlip.flipH && 'teleprompter--flip-h',
effectiveFlip.flipV && 'teleprompter--flip-v',
// nothing to hold back the eye from when no event is cued
@@ -124,10 +173,8 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
>
<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' />
{emptyMessage ? (
<EmptyPage text={emptyMessage} />
) : (
<>
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
@@ -148,10 +195,11 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
<ControlOverlay
isRunning={isRunning}
speed={speed}
canReengageFollow={canReengageFollow}
parkedAt={parkedAt}
controller={controller}
onToggleHelp={handleToggleHelp}
controlMode={controlMode}
onToggleControlMode={handleToggleControlMode}
/>
</>
)}
@@ -1,70 +1,119 @@
import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import type { MouseEvent, PropsWithChildren } from 'react';
import {
IoAdd,
IoArrowDown,
IoArrowUp,
IoHelpCircleOutline,
IoLockClosed,
IoLockOpen,
IoPause,
IoPlay,
IoRemove,
} from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { ParkedAt, TeleprompterController } from '../teleprompter.types';
import type { ParkedAt, TeleprompterControlMode, TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
canReengageFollow: boolean;
parkedAt: ParkedAt;
controller: TeleprompterController;
onToggleHelp: () => void;
controlMode: TeleprompterControlMode;
onToggleControlMode: () => void;
}
interface ControlButtonProps {
label: string;
accessibleLabel: string;
onPress: () => void;
disabled?: boolean;
isActive?: boolean;
testId?: string;
}
function ControlButton({
label,
accessibleLabel,
onPress,
disabled,
isActive,
testId,
children,
}: PropsWithChildren<ControlButtonProps>) {
// Pointer activation yields focus so the next Space reaches the transport.
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
onPress();
};
return (
<Tooltip
text={label}
render={
<IconButton
variant={isActive ? 'primary' : 'subtle-white'}
size='large'
onClick={handleClick}
disabled={disabled}
data-testid={testId}
aria-label={accessibleLabel}
/>
}
>
{children}
</Tooltip>
);
}
export default function ControlOverlay({
isRunning,
speed,
canReengageFollow,
parkedAt,
controller,
onToggleHelp,
controlMode,
onToggleControlMode,
}: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true);
// Pointer activation yields focus so the next Space reaches the transport.
const press = (action: () => void) => (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
action();
};
const isControlled = controlMode === 'controlled';
return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<Tooltip
text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(controller.togglePlay)}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
/>
}
<ControlButton
label={isControlled ? 'Switch to free mode' : 'Switch to controlled mode'}
accessibleLabel='Toggle control mode'
onPress={onToggleControlMode}
isActive={isControlled}
>
{isControlled ? <IoLockClosed /> : <IoLockOpen />}
</ControlButton>
<ControlButton
label={isRunning ? 'Pause (Space)' : 'Play (Space)'}
accessibleLabel={isRunning ? 'Pause' : 'Play'}
onPress={controller.togglePlay}
disabled={isControlled}
testId='teleprompter-play'
>
{isRunning ? <IoPause /> : <IoPlay />}
</Tooltip>
</ControlButton>
<Tooltip
text='Slow down (Left arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down'
/>
}
<ControlButton
label='Slow down (Left arrow)'
accessibleLabel='Slow down'
onPress={() => controller.changeSpeed(-SPEED_STEP)}
disabled={isControlled}
>
<IoRemove />
</Tooltip>
</ControlButton>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
@@ -77,63 +126,36 @@ export default function ControlOverlay({
</span>
)}
<Tooltip
text='Speed up (Right arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up'
/>
}
<ControlButton
label='Speed up (Right arrow)'
accessibleLabel='Speed up'
onPress={() => controller.changeSpeed(SPEED_STEP)}
disabled={isControlled}
>
<IoAdd />
</Tooltip>
</ControlButton>
<Tooltip
text='Rewind to the top (Home)'
render={
<IconButton
variant={parkedAt === 'script' ? 'primary' : 'subtle-white'}
size='large'
onClick={press(controller.rewind)}
aria-label='Rewind to top'
/>
}
<ControlButton
label='Nudge up one line (Up arrow)'
accessibleLabel='Nudge up one line'
onPress={() => controller.nudge(-1)}
disabled={isControlled}
>
<IoArrowUp />
</Tooltip>
</ControlButton>
<Tooltip
text={canReengageFollow ? 'Resume following the loaded event (L)' : 'Following the loaded event'}
render={
<IconButton
variant={canReengageFollow ? 'primary' : 'subtle-white'}
size='large'
disabled={!canReengageFollow}
onClick={press(controller.reengageFollow)}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
/>
}
<ControlButton
label='Nudge down one line (Down arrow)'
accessibleLabel='Nudge down one line'
onPress={() => controller.nudge(1)}
disabled={isControlled}
>
<IoLocate />
</Tooltip>
<IoArrowDown />
</ControlButton>
<Tooltip
text='Keyboard shortcuts (?)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(onToggleHelp)}
aria-label='Keyboard shortcuts'
/>
}
>
<ControlButton label='Keyboard shortcuts (?)' accessibleLabel='Keyboard shortcuts' onPress={onToggleHelp}>
<IoHelpCircleOutline />
</Tooltip>
</ControlButton>
</div>
);
}
@@ -33,6 +33,7 @@ export const defaults = {
readingLinePos: 25,
flipH: false,
flipV: false,
controlMode: 'free' as const,
};
const bounds = {
@@ -55,6 +56,17 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'controlMode',
title: 'Control mode',
description: 'Free mode accepts local controls. Controlled mode accepts teleprompter API commands.',
type: 'option',
values: [
{ value: 'free', label: 'Free' },
{ value: 'controlled', label: 'Controlled' },
],
defaultValue: defaults.controlMode,
},
{
id: 'script',
title: 'Script',
@@ -219,6 +231,7 @@ export function getOptionsFromParams(
readingLinePos: toNumber(getParam('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getParam('flipH'), defaults.flipH),
flipV: toBoolean(getParam('flipV'), defaults.flipV),
controlMode: toEnum(getParam('controlMode'), ['free', 'controlled'], defaults.controlMode),
};
}
@@ -126,6 +126,24 @@ export function segmentEndFor(block: BlockGeometry, readingOffset: number): numb
return block.top + block.height - readingOffset;
}
/** Applies a signed nudge within the section currently under the reading line. */
export function nudgeTargetFor(
position: number,
distance: number,
readingOffset: number,
blocks: BlockGeometry[],
maxScroll: number,
): number {
const index = indexAtReadPoint(position + readingOffset, blocks);
if (index === -1) return clamp(position + distance, 0, maxScroll);
const block = blocks[index];
return clamp(
position + distance,
Math.max(0, block.top - readingOffset),
Math.min(maxScroll, segmentEndFor(block, readingOffset)),
);
}
/**
* The segment playback should run to, or null past the last one, where the end
* of the script is the bound.
@@ -1,4 +1,4 @@
import type { MaybeString } from 'ontime-types';
import type { MaybeString, TeleprompterCommand, TeleprompterControlMode } from 'ontime-types';
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
@@ -32,8 +32,11 @@ export type TeleprompterOptions = {
readingLinePos: number;
flipH: boolean;
flipV: boolean;
controlMode: TeleprompterControlMode;
};
export type { TeleprompterCommand, TeleprompterControlMode };
export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
@@ -54,7 +57,7 @@ export type TeleprompterController = {
pause: () => void;
/** Local input convenience; remote control should use play or pause. */
togglePlay: () => void;
nudge: (lines: number) => void;
nudge: (lines: number, options?: { preserveFollow?: boolean }) => void;
page: (direction: 1 | -1) => void;
jumpEvent: (direction: 1 | -1) => void;
setSpeed: (speed: number) => void;
@@ -2,7 +2,7 @@ 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';
import type { TeleprompterAction, TeleprompterCommand, TeleprompterController } from './teleprompter.types';
interface TeleprompterActionContext {
controller: TeleprompterController;
@@ -14,6 +14,7 @@ interface TeleprompterActionContext {
interface UseTeleprompterControlsArgs extends TeleprompterActionContext {
isHelpOpen: boolean;
enabled?: boolean;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
@@ -51,6 +52,19 @@ export function applyTeleprompterAction(action: TeleprompterAction, context: Tel
}
}
export function applyTeleprompterCommand(command: TeleprompterCommand, controller: TeleprompterController) {
switch (command.type) {
case 'play':
return controller.play();
case 'pause':
return controller.pause();
case 'setSpeed':
return controller.setSpeed(command.linesPerMinute);
case 'nudge':
return controller.nudge(command.lines, { preserveFollow: true });
}
}
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
useEffect(() => {
@@ -66,6 +80,7 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
return;
}
if (argsRef.current.enabled === false) return;
const action = resolveTeleprompterAction(event);
if (!action) return;
@@ -11,6 +11,7 @@ import {
hasBrokenFollow,
indexAtReadPoint,
linesPerMinuteToPxPerSecond,
nudgeTargetFor,
readPointForAnchor,
type ScrollAnchor,
segmentAfter,
@@ -414,10 +415,19 @@ export function useTeleprompterScroll({
play,
pause,
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
nudge: (lines: number, options) => {
const distance = lines * lineHeightRef.current;
pendingDeltaRef.current += distance;
addReaderDrift(distance);
const from = posRef.current + pendingDeltaRef.current;
const target = nudgeTargetFor(
from,
distance,
readingOffsetRef.current,
geometryRef.current,
maxScrollRef.current,
);
const delta = target - from;
pendingDeltaRef.current += delta;
if (!options?.preserveFollow) addReaderDrift(delta);
setParkedAt(null);
},
page: (direction: 1 | -1) => {
@@ -449,7 +459,7 @@ export function useTeleprompterScroll({
setAutoScrollLocked(false);
},
};
}, [addReaderDrift, setPlaybackRunning]);
}, [addReaderDrift, scrollTargetFor, setPlaybackRunning]);
return {
scrollerRef: attachScroller,
@@ -458,9 +468,7 @@ export function useTeleprompterScroll({
controller,
isRunning,
speed,
// folds followLoaded in, so callers get one ready-to-use signal instead of
// a runtime flag they must remember to AND with the option themselves
canReengageFollow: followLoaded && autoScrollLocked,
isFollowingLoadedEvent: followLoaded && !autoScrollLocked,
parkedAt,
};
}