mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
feat(telemprompter): remote control
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user