diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index a9366cc36..85d05738b 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -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>(); + +export function subscribeSocket( + tag: T, + listener: (payload: Extract['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); diff --git a/apps/client/src/views/teleprompter/Teleprompter.scss b/apps/client/src/views/teleprompter/Teleprompter.scss index 3db72a64b..d5f2f3828 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.scss +++ b/apps/client/src/views/teleprompter/Teleprompter.scss @@ -43,6 +43,10 @@ } } +.teleprompter--controlled .teleprompter__scroller { + overflow-y: hidden; +} + .teleprompter__content { width: var(--tp-text-width); margin-inline: auto; diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index 990c0bf22..94b2678c0 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -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, 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()); + + 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
- {!hasScriptSource ? ( - - ) : blocks.length === 0 ? ( - + {emptyMessage ? ( + ) : ( <>
@@ -148,10 +195,11 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa )} diff --git a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx index 7637fceb2..9bb2559b8 100644 --- a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx +++ b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx @@ -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) { + // Pointer activation yields focus so the next Space reaches the transport. + const handleClick = (event: MouseEvent) => { + if (event.detail > 0) { + event.currentTarget.blur(); + } + onPress(); + }; + + return ( + + } + > + {children} + + ); } 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) => { - if (event.detail > 0) { - event.currentTarget.blur(); - } - action(); - }; + const isControlled = controlMode === 'controlled'; return (
- - } + + {isControlled ? : } + + + {isRunning ? : } - + - controller.changeSpeed(-SPEED_STEP))} - aria-label='Slow down' - /> - } + controller.changeSpeed(-SPEED_STEP)} + disabled={isControlled} > - +
{speed} @@ -77,63 +126,36 @@ export default function ControlOverlay({ )} - controller.changeSpeed(SPEED_STEP))} - aria-label='Speed up' - /> - } + controller.changeSpeed(SPEED_STEP)} + disabled={isControlled} > - + - - } + controller.nudge(-1)} + disabled={isControlled} > - + - - } + controller.nudge(1)} + disabled={isControlled} > - - + + - - } - > + - +
); } diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts index c4ae01cd4..36ca03bc0 100644 --- a/apps/client/src/views/teleprompter/teleprompter.options.ts +++ b/apps/client/src/views/teleprompter/teleprompter.options.ts @@ -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), }; } diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts index 541330c47..11936c8b3 100644 --- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts +++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts @@ -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. diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts index 23ec509b8..b2ff3112f 100644 --- a/apps/client/src/views/teleprompter/teleprompter.types.ts +++ b/apps/client/src/views/teleprompter/teleprompter.types.ts @@ -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; diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts index 21f4f0c84..bd417e027 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterControls.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts @@ -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; diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts index a17e6f3e0..29fb9b207 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts @@ -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, }; } diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index e786c2f03..c3916711b 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -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; + private readonly connections: Map; 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(); + this.connections = new Map(); 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( + target: ClientId, + tag: T, + payload: Extract['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) { diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 6286b107e..fb249ca2a 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -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 = { runtimeService.setOffsetMode(mode); return { payload: 'success' }; }, + teleprompter: (payload) => { + assert.isObject(payload); + const request = payload as Record; + 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 diff --git a/apps/server/src/services/teleprompter-service/TeleprompterService.ts b/apps/server/src/services/teleprompter-service/TeleprompterService.ts new file mode 100644 index 000000000..c191dfaa4 --- /dev/null +++ b/apps/server/src/services/teleprompter-service/TeleprompterService.ts @@ -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(); + private readonly commandReceipts = new Map>(); + + 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(); diff --git a/apps/server/src/services/teleprompter-service/__tests__/TeleprompterService.test.ts b/apps/server/src/services/teleprompter-service/__tests__/TeleprompterService.test.ts new file mode 100644 index 000000000..3e6586afd --- /dev/null +++ b/apps/server/src/services/teleprompter-service/__tests__/TeleprompterService.test.ts @@ -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'); + }); +}); diff --git a/packages/types/src/api/websocket/api.type.ts b/packages/types/src/api/websocket/api.type.ts index a165e8c77..7b3d7d711 100644 --- a/packages/types/src/api/websocket/api.type.ts +++ b/packages/types/src/api/websocket/api.type.ts @@ -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']; diff --git a/packages/types/src/api/websocket/data.type.ts b/packages/types/src/api/websocket/data.type.ts index 3d09f2158..84ba8e235 100644 --- a/packages/types/src/api/websocket/data.type.ts +++ b/packages/types/src/api/websocket/data.type.ts @@ -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; }; type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial }; +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; diff --git a/packages/types/src/definitions/Clients.type.ts b/packages/types/src/definitions/Clients.type.ts index 7d7d80f86..c3f959eee 100644 --- a/packages/types/src/definitions/Clients.type.ts +++ b/packages/types/src/definitions/Clients.type.ts @@ -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; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..4f05a72d2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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 {