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,
};
}
+25 -2
View File
@@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { logger } from '../classes/Logger.js';
import { authenticateSocket } from '../middleware/authenticate.js';
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
import { eventStore } from '../stores/EventStore.js';
import getRandomName from '../utils/getRandomName.js';
import type { IAdapter } from './IAdapter.js';
@@ -43,6 +44,7 @@ class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<ClientId, Client>;
private readonly connections: Map<ClientId, WebSocket>;
private lastConnection: Date | null = null;
private shouldShowWelcome = true;
@@ -54,6 +56,7 @@ class SocketServer implements IAdapter {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.clients = new Map<ClientId, Client>();
this.connections = new Map<ClientId, WebSocket>();
this.wss = null;
}
@@ -83,6 +86,8 @@ class SocketServer implements IAdapter {
origin: '',
path: '',
});
this.connections.set(clientId, ws);
teleprompterService.register(clientId);
this.lastConnection = new Date();
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
@@ -98,6 +103,8 @@ class SocketServer implements IAdapter {
ws.on('close', () => {
this.clients.delete(clientId);
this.connections.delete(clientId);
teleprompterService.remove(clientId);
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
this.sendClientList();
});
@@ -114,7 +121,9 @@ class SocketServer implements IAdapter {
}
case MessageTag.ClientSet: {
const previousData = this.getOrCreateClient(clientId);
const updatedClient = { ...previousData, ...payload };
const { teleprompter, ...clientPatch } = payload;
if (teleprompter) teleprompterService.report(clientId, teleprompter);
const updatedClient = { ...previousData, ...clientPatch };
this.clients.set(clientId, updatedClient);
if (this.shouldShowWelcome && updatedClient.path?.toLowerCase().includes('editor')) {
this.shouldShowWelcome = false;
@@ -185,7 +194,11 @@ class SocketServer implements IAdapter {
}
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
const payload = Object.fromEntries(
this.clients
.entries()
.map(([clientId, client]) => [clientId, { ...client, teleprompter: teleprompterService.getState(clientId) }]),
);
this.sendAsJson(MessageTag.ClientList, payload);
}
@@ -193,6 +206,16 @@ class SocketServer implements IAdapter {
return Array.from(this.clients.keys());
}
public sendToClient<T extends MessageTag>(
target: ClientId,
tag: T,
payload: Extract<WsPacketToClient, { tag: T }>['payload'],
) {
const client = this.connections.get(target);
if (!client || client.readyState !== WebSocket.OPEN) throw new Error(`Client "${target}" not found`);
client.send(JSON.stringify({ tag, payload }));
}
public renameClient(target: ClientId, name: string) {
const previousData = this.clients.get(target);
if (!previousData) {
@@ -1,5 +1,6 @@
import {
ApiActionTag,
MessageTag,
MessageState,
OffsetMode,
OntimeEvent,
@@ -19,6 +20,7 @@ import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.j
import * as messageService from '../services/message-service/message.service.js';
import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js';
import { runtimeService } from '../services/runtime-service/runtime.service.js';
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { coerceEnum } from '../utils/coerceType.js';
@@ -307,8 +309,50 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
teleprompter: (payload) => {
assert.isObject(payload);
const request = payload as Record<string, unknown>;
if (
typeof request.target !== 'string' ||
typeof request.commandId !== 'string' ||
request.commandId.trim().length === 0 ||
!isTeleprompterCommand(request.command)
) {
throw new Error('Invalid teleprompter command');
}
teleprompterService.deliver(request.target, request.commandId, request.command, (target, commandId, command) => {
socket.sendToClient(target, MessageTag.TeleprompterCommand, { commandId, command });
});
return { payload: { commandId: request.commandId, status: 'delivered' } };
},
};
export function isTeleprompterCommand(command: unknown): command is import('ontime-types').TeleprompterCommand {
if (!command || typeof command !== 'object' || !('type' in command)) return false;
switch (command.type) {
case 'play':
case 'pause':
return true;
case 'setSpeed':
return (
'linesPerMinute' in command &&
typeof command.linesPerMinute === 'number' &&
Number.isInteger(command.linesPerMinute) &&
command.linesPerMinute >= 1 &&
command.linesPerMinute <= 40
);
case 'nudge':
return (
'lines' in command &&
typeof command.lines === 'number' &&
Number.isInteger(command.lines) &&
Math.abs(command.lines) <= 1000
);
default:
return false;
}
}
/**
* Returns a value of type number, converting if necessary
* Otherwise throws
@@ -0,0 +1,48 @@
import type { TeleprompterCommand, TeleprompterControlState } from 'ontime-types';
type ClientId = string;
type DeliverCommand = (target: ClientId, commandId: string, command: TeleprompterCommand) => void;
/**
* Ephemeral state for connected teleprompter views. The view reports observed
* transport state; this service never attempts to own document geometry or
* replay a relative nudge after a reconnect.
*/
export class TeleprompterService {
private readonly states = new Map<ClientId, TeleprompterControlState>();
private readonly commandReceipts = new Map<ClientId, Map<string, string>>();
register(clientId: ClientId) {
this.commandReceipts.set(clientId, new Map());
}
remove(clientId: ClientId) {
this.states.delete(clientId);
this.commandReceipts.delete(clientId);
}
report(clientId: ClientId, state: TeleprompterControlState) {
this.states.set(clientId, state);
}
getState(clientId: ClientId) {
return this.states.get(clientId);
}
deliver(clientId: ClientId, commandId: string, command: TeleprompterCommand, deliver: DeliverCommand) {
const receipts = this.commandReceipts.get(clientId);
if (!receipts) throw new Error(`Client "${clientId}" not found`);
const serialized = JSON.stringify(command);
const previous = receipts.get(commandId);
if (previous === serialized) return;
if (previous !== undefined)
throw new Error(`Teleprompter command id "${commandId}" was reused with different data`);
deliver(clientId, commandId, command);
receipts.set(commandId, serialized);
if (receipts.size > 100) receipts.delete(receipts.keys().next().value as string);
}
}
export const teleprompterService = new TeleprompterService();
@@ -0,0 +1,23 @@
import { TeleprompterService } from '../TeleprompterService.js';
describe('TeleprompterService', () => {
test('keeps reported state and makes retries idempotent', () => {
const service = new TeleprompterService();
const deliver = vi.fn();
service.register('view');
service.report('view', {
mode: 'controlled',
playback: 'paused',
speed: 14,
isFollowingLoadedEvent: true,
parkedAt: null,
});
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
expect(deliver).toHaveBeenCalledTimes(1);
expect(service.getState('view')?.mode).toBe('controlled');
expect(() => service.deliver('view', 'command-1', { type: 'nudge', lines: 2 }, deliver)).toThrow('reused');
});
});
+15 -2
View File
@@ -6,6 +6,17 @@ import type { MessageState } from '../../definitions/runtime/MessageControl.type
import type { OffsetMode } from '../../definitions/runtime/Offset.type.js';
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
export type TeleprompterCommand =
| { type: 'play' }
| { type: 'pause' }
| { type: 'setSpeed'; linesPerMinute: number }
| { type: 'nudge'; lines: number };
export type TeleprompterAction = {
tag: 'teleprompter';
payload: { target: string; commandId: string; command: TeleprompterCommand };
};
export type TeleprompterResponse = { tag: 'teleprompter'; payload: { commandId: string; status: 'delivered' } };
export type VersionAction = {
tag: 'version';
payload: undefined;
@@ -156,7 +167,8 @@ export type ApiAction =
| AddtimeAction
| AuxtimerAction
| ClientAction
| OffsetmodeAction;
| OffsetmodeAction
| TeleprompterAction;
export type ApiResponse =
| VersionResponse
@@ -172,6 +184,7 @@ export type ApiResponse =
| AddtimeResponse
| AuxtimerResponse
| ClientResponse
| OffsetmodeResponse;
| OffsetmodeResponse
| TeleprompterResponse;
export type ApiActionTag = ApiAction['tag'];
@@ -2,6 +2,7 @@ import type { Client } from '../../definitions/Clients.type.js';
import type { Log } from '../../definitions/runtime/Logger.type.js';
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { TeleprompterCommand } from './api.type.js';
import type { RefetchKey } from './refetch.type.js';
export enum MessageTag {
@@ -17,6 +18,7 @@ export enum MessageTag {
Log = 'log',
RuntimeData = 'runtime-data',
Refetch = 'refetch',
TeleprompterCommand = 'teleprompter-command',
}
// CLIENT TO SERVER
@@ -35,6 +37,10 @@ type ListClientPacket = {
payload: Record<string, Client>;
};
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
type TeleprompterCommandPacket = {
tag: MessageTag.TeleprompterCommand;
payload: { commandId: string; command: TeleprompterCommand };
};
type RefetchPacket = {
tag: MessageTag.Refetch;
@@ -58,4 +64,5 @@ export type WsPacketToClient =
| LogPacket
| ListClientPacket
| RuntimePacket
| RefetchPacket;
| RefetchPacket
| TeleprompterCommandPacket;
@@ -1,11 +1,23 @@
export type ClientType = 'unknown' | 'ontime' | string;
export type TeleprompterControlMode = 'free' | 'controlled';
export type TeleprompterPlayback = 'playing' | 'paused';
export type TeleprompterParkedAt = 'segment' | 'script' | null;
export type TeleprompterControlState = {
mode: TeleprompterControlMode;
playback: TeleprompterPlayback;
speed: number;
isFollowingLoadedEvent: boolean;
parkedAt: TeleprompterParkedAt;
};
export type Client = {
name: string;
type: ClientType;
identify: boolean;
origin: string;
path: string;
teleprompter?: TeleprompterControlState;
};
export type ClientList = Record<string, Client>;
+10 -2
View File
@@ -107,7 +107,7 @@ export type {
export { MessageTag } from './api/websocket/data.type.js';
export type { WsPacketToServer, WsPacketToClient } from './api/websocket/data.type.js';
export { RefetchKey } from './api/websocket/refetch.type.js';
export type { ApiAction, ApiActionTag, ApiResponse } from './api/websocket/api.type.js';
export type { ApiAction, ApiActionTag, ApiResponse, TeleprompterCommand } from './api/websocket/api.type.js';
// SERVER RUNTIME
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
export { Playback } from './definitions/runtime/Playback.type.js';
@@ -125,7 +125,15 @@ export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.ty
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
// CLIENT
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
export type {
Client,
ClientList,
ClientType,
TeleprompterControlMode,
TeleprompterControlState,
TeleprompterParkedAt,
TeleprompterPlayback,
} from './definitions/Clients.type.js';
// TYPE UTILITIES
export {