mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 08:39:34 +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 websocket: WebSocket | null = null;
|
||||||
let reconnectTimeout: NodeJS.Timeout | 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 = {
|
const socketConfig = {
|
||||||
reconnectBaseInterval: 1000, // 1 second
|
reconnectBaseInterval: 1000, // 1 second
|
||||||
reconnectMaxInterval: 30000, // 30 seconds
|
reconnectMaxInterval: 30000, // 30 seconds
|
||||||
@@ -124,6 +145,10 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
|
case MessageTag.TeleprompterCommand: {
|
||||||
|
notifySocketListeners(tag, payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case MessageTag.Pong: {
|
case MessageTag.Pong: {
|
||||||
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||||
patchRuntimeProperty('ping', offset);
|
patchRuntimeProperty('ping', offset);
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teleprompter--controlled .teleprompter__scroller {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.teleprompter__content {
|
.teleprompter__content {
|
||||||
width: var(--tp-text-width);
|
width: var(--tp-text-width);
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
import { MessageTag, OntimeView } from 'ontime-types';
|
||||||
import { type CSSProperties, useState } from 'react';
|
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
|
import { sendSocket, subscribeSocket } from '../../common/utils/socket';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import Loader from '../common/loader/Loader';
|
import Loader from '../common/loader/Loader';
|
||||||
import ControlOverlay from './control-overlay/ControlOverlay';
|
import ControlOverlay from './control-overlay/ControlOverlay';
|
||||||
@@ -15,12 +16,33 @@ import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './tele
|
|||||||
import { stepFontSize } from './teleprompter.scroll';
|
import { stepFontSize } from './teleprompter.scroll';
|
||||||
import { buildScript, composeFlip } from './teleprompter.utils';
|
import { buildScript, composeFlip } from './teleprompter.utils';
|
||||||
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
|
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
|
||||||
import { useTeleprompterControls } from './useTeleprompterControls';
|
import { applyTeleprompterCommand, useTeleprompterControls } from './useTeleprompterControls';
|
||||||
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
|
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
|
||||||
import { useTeleprompterScroll } from './useTeleprompterScroll';
|
import { useTeleprompterScroll } from './useTeleprompterScroll';
|
||||||
|
|
||||||
import './Teleprompter.scss';
|
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() {
|
export default function TeleprompterLoader() {
|
||||||
const { data, status } = useTeleprompterData();
|
const { data, status } = useTeleprompterData();
|
||||||
|
|
||||||
@@ -45,6 +67,10 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||||
|
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
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 fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
|
||||||
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
|
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
|
||||||
@@ -67,7 +93,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
showGroups: options.showGroups,
|
showGroups: options.showGroups,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, parkedAt } =
|
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, isFollowingLoadedEvent, parkedAt } =
|
||||||
useTeleprompterScroll({
|
useTeleprompterScroll({
|
||||||
initialSpeed: options.speed,
|
initialSpeed: options.speed,
|
||||||
followLoaded: options.followLoaded,
|
followLoaded: options.followLoaded,
|
||||||
@@ -87,6 +113,8 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
|
|
||||||
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
|
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
|
||||||
const handleToggleHelp = () => setShowHelp((current) => !current);
|
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 });
|
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
|
||||||
|
|
||||||
@@ -97,9 +125,29 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
onFontSize: handleFontSize,
|
onFontSize: handleFontSize,
|
||||||
onResetFontSize: handleResetFontSize,
|
onResetFontSize: handleResetFontSize,
|
||||||
onToggleHelp: handleToggleHelp,
|
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);
|
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
|
||||||
|
|
||||||
@@ -114,6 +162,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
<div
|
<div
|
||||||
className={cx([
|
className={cx([
|
||||||
'teleprompter',
|
'teleprompter',
|
||||||
|
isControlled && 'teleprompter--controlled',
|
||||||
effectiveFlip.flipH && 'teleprompter--flip-h',
|
effectiveFlip.flipH && 'teleprompter--flip-h',
|
||||||
effectiveFlip.flipV && 'teleprompter--flip-v',
|
effectiveFlip.flipV && 'teleprompter--flip-v',
|
||||||
// nothing to hold back the eye from when no event is cued
|
// 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} />
|
<ViewParamsEditor target={OntimeView.Teleprompter} viewOptions={viewOptions} />
|
||||||
|
|
||||||
{!hasScriptSource ? (
|
{emptyMessage ? (
|
||||||
<EmptyPage text='Select which field holds the script in the view options' />
|
<EmptyPage text={emptyMessage} />
|
||||||
) : blocks.length === 0 ? (
|
|
||||||
<EmptyPage text='There is no script text in the selected field' />
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
|
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
|
||||||
@@ -148,10 +195,11 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
<ControlOverlay
|
<ControlOverlay
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
speed={speed}
|
speed={speed}
|
||||||
canReengageFollow={canReengageFollow}
|
|
||||||
parkedAt={parkedAt}
|
parkedAt={parkedAt}
|
||||||
controller={controller}
|
controller={controller}
|
||||||
onToggleHelp={handleToggleHelp}
|
onToggleHelp={handleToggleHelp}
|
||||||
|
controlMode={controlMode}
|
||||||
|
onToggleControlMode={handleToggleControlMode}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,70 +1,119 @@
|
|||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent, PropsWithChildren } from 'react';
|
||||||
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
|
import {
|
||||||
|
IoAdd,
|
||||||
|
IoArrowDown,
|
||||||
|
IoArrowUp,
|
||||||
|
IoHelpCircleOutline,
|
||||||
|
IoLockClosed,
|
||||||
|
IoLockOpen,
|
||||||
|
IoPause,
|
||||||
|
IoPlay,
|
||||||
|
IoRemove,
|
||||||
|
} from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { SPEED_STEP } from '../teleprompter.scroll';
|
import { SPEED_STEP } from '../teleprompter.scroll';
|
||||||
import type { ParkedAt, TeleprompterController } from '../teleprompter.types';
|
import type { ParkedAt, TeleprompterControlMode, TeleprompterController } from '../teleprompter.types';
|
||||||
|
|
||||||
interface ControlOverlayProps {
|
interface ControlOverlayProps {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
speed: number;
|
speed: number;
|
||||||
canReengageFollow: boolean;
|
|
||||||
parkedAt: ParkedAt;
|
parkedAt: ParkedAt;
|
||||||
controller: TeleprompterController;
|
controller: TeleprompterController;
|
||||||
onToggleHelp: () => void;
|
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({
|
export default function ControlOverlay({
|
||||||
isRunning,
|
isRunning,
|
||||||
speed,
|
speed,
|
||||||
canReengageFollow,
|
|
||||||
parkedAt,
|
parkedAt,
|
||||||
controller,
|
controller,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
|
controlMode,
|
||||||
|
onToggleControlMode,
|
||||||
}: ControlOverlayProps) {
|
}: ControlOverlayProps) {
|
||||||
const isActive = useFadeOutOnInactivity(true);
|
const isActive = useFadeOutOnInactivity(true);
|
||||||
|
const isControlled = controlMode === 'controlled';
|
||||||
// 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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
|
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
|
||||||
<Tooltip
|
<ControlButton
|
||||||
text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
|
label={isControlled ? 'Switch to free mode' : 'Switch to controlled mode'}
|
||||||
render={
|
accessibleLabel='Toggle control mode'
|
||||||
<IconButton
|
onPress={onToggleControlMode}
|
||||||
variant='subtle-white'
|
isActive={isControlled}
|
||||||
size='large'
|
>
|
||||||
onClick={press(controller.togglePlay)}
|
{isControlled ? <IoLockClosed /> : <IoLockOpen />}
|
||||||
data-testid='teleprompter-play'
|
</ControlButton>
|
||||||
aria-label={isRunning ? 'Pause' : 'Play'}
|
|
||||||
/>
|
<ControlButton
|
||||||
}
|
label={isRunning ? 'Pause (Space)' : 'Play (Space)'}
|
||||||
|
accessibleLabel={isRunning ? 'Pause' : 'Play'}
|
||||||
|
onPress={controller.togglePlay}
|
||||||
|
disabled={isControlled}
|
||||||
|
testId='teleprompter-play'
|
||||||
>
|
>
|
||||||
{isRunning ? <IoPause /> : <IoPlay />}
|
{isRunning ? <IoPause /> : <IoPlay />}
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
|
|
||||||
<Tooltip
|
<ControlButton
|
||||||
text='Slow down (Left arrow)'
|
label='Slow down (Left arrow)'
|
||||||
render={
|
accessibleLabel='Slow down'
|
||||||
<IconButton
|
onPress={() => controller.changeSpeed(-SPEED_STEP)}
|
||||||
variant='subtle-white'
|
disabled={isControlled}
|
||||||
size='large'
|
|
||||||
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
|
|
||||||
aria-label='Slow down'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IoRemove />
|
<IoRemove />
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
|
|
||||||
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
|
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
|
||||||
{speed}
|
{speed}
|
||||||
@@ -77,63 +126,36 @@ export default function ControlOverlay({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip
|
<ControlButton
|
||||||
text='Speed up (Right arrow)'
|
label='Speed up (Right arrow)'
|
||||||
render={
|
accessibleLabel='Speed up'
|
||||||
<IconButton
|
onPress={() => controller.changeSpeed(SPEED_STEP)}
|
||||||
variant='subtle-white'
|
disabled={isControlled}
|
||||||
size='large'
|
|
||||||
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
|
|
||||||
aria-label='Speed up'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IoAdd />
|
<IoAdd />
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
|
|
||||||
<Tooltip
|
<ControlButton
|
||||||
text='Rewind to the top (Home)'
|
label='Nudge up one line (Up arrow)'
|
||||||
render={
|
accessibleLabel='Nudge up one line'
|
||||||
<IconButton
|
onPress={() => controller.nudge(-1)}
|
||||||
variant={parkedAt === 'script' ? 'primary' : 'subtle-white'}
|
disabled={isControlled}
|
||||||
size='large'
|
|
||||||
onClick={press(controller.rewind)}
|
|
||||||
aria-label='Rewind to top'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IoArrowUp />
|
<IoArrowUp />
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
|
|
||||||
<Tooltip
|
<ControlButton
|
||||||
text={canReengageFollow ? 'Resume following the loaded event (L)' : 'Following the loaded event'}
|
label='Nudge down one line (Down arrow)'
|
||||||
render={
|
accessibleLabel='Nudge down one line'
|
||||||
<IconButton
|
onPress={() => controller.nudge(1)}
|
||||||
variant={canReengageFollow ? 'primary' : 'subtle-white'}
|
disabled={isControlled}
|
||||||
size='large'
|
|
||||||
disabled={!canReengageFollow}
|
|
||||||
onClick={press(controller.reengageFollow)}
|
|
||||||
data-testid='teleprompter-follow'
|
|
||||||
aria-label='Follow the loaded event'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IoLocate />
|
<IoArrowDown />
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
|
|
||||||
<Tooltip
|
<ControlButton label='Keyboard shortcuts (?)' accessibleLabel='Keyboard shortcuts' onPress={onToggleHelp}>
|
||||||
text='Keyboard shortcuts (?)'
|
|
||||||
render={
|
|
||||||
<IconButton
|
|
||||||
variant='subtle-white'
|
|
||||||
size='large'
|
|
||||||
onClick={press(onToggleHelp)}
|
|
||||||
aria-label='Keyboard shortcuts'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IoHelpCircleOutline />
|
<IoHelpCircleOutline />
|
||||||
</Tooltip>
|
</ControlButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const defaults = {
|
|||||||
readingLinePos: 25,
|
readingLinePos: 25,
|
||||||
flipH: false,
|
flipH: false,
|
||||||
flipV: false,
|
flipV: false,
|
||||||
|
controlMode: 'free' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bounds = {
|
const bounds = {
|
||||||
@@ -55,6 +56,17 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
|
|||||||
title: OptionTitle.DataSources,
|
title: OptionTitle.DataSources,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
options: [
|
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',
|
id: 'script',
|
||||||
title: 'Script',
|
title: 'Script',
|
||||||
@@ -219,6 +231,7 @@ export function getOptionsFromParams(
|
|||||||
readingLinePos: toNumber(getParam('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
|
readingLinePos: toNumber(getParam('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
|
||||||
flipH: toBoolean(getParam('flipH'), defaults.flipH),
|
flipH: toBoolean(getParam('flipH'), defaults.flipH),
|
||||||
flipV: toBoolean(getParam('flipV'), defaults.flipV),
|
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;
|
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
|
* The segment playback should run to, or null past the last one, where the end
|
||||||
* of the script is the bound.
|
* 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';
|
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
|
||||||
|
|
||||||
@@ -32,8 +32,11 @@ export type TeleprompterOptions = {
|
|||||||
readingLinePos: number;
|
readingLinePos: number;
|
||||||
flipH: boolean;
|
flipH: boolean;
|
||||||
flipV: boolean;
|
flipV: boolean;
|
||||||
|
controlMode: TeleprompterControlMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { TeleprompterCommand, TeleprompterControlMode };
|
||||||
|
|
||||||
export type TeleprompterAction =
|
export type TeleprompterAction =
|
||||||
| { type: 'togglePlay' }
|
| { type: 'togglePlay' }
|
||||||
| { type: 'nudge'; lines: number }
|
| { type: 'nudge'; lines: number }
|
||||||
@@ -54,7 +57,7 @@ export type TeleprompterController = {
|
|||||||
pause: () => void;
|
pause: () => void;
|
||||||
/** Local input convenience; remote control should use play or pause. */
|
/** Local input convenience; remote control should use play or pause. */
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
nudge: (lines: number) => void;
|
nudge: (lines: number, options?: { preserveFollow?: boolean }) => void;
|
||||||
page: (direction: 1 | -1) => void;
|
page: (direction: 1 | -1) => void;
|
||||||
jumpEvent: (direction: 1 | -1) => void;
|
jumpEvent: (direction: 1 | -1) => void;
|
||||||
setSpeed: (speed: number) => 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 { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
|
||||||
import { resolveTeleprompterAction } from './teleprompter.keymap';
|
import { resolveTeleprompterAction } from './teleprompter.keymap';
|
||||||
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
|
import type { TeleprompterAction, TeleprompterCommand, TeleprompterController } from './teleprompter.types';
|
||||||
|
|
||||||
interface TeleprompterActionContext {
|
interface TeleprompterActionContext {
|
||||||
controller: TeleprompterController;
|
controller: TeleprompterController;
|
||||||
@@ -14,6 +14,7 @@ interface TeleprompterActionContext {
|
|||||||
|
|
||||||
interface UseTeleprompterControlsArgs extends TeleprompterActionContext {
|
interface UseTeleprompterControlsArgs extends TeleprompterActionContext {
|
||||||
isHelpOpen: boolean;
|
isHelpOpen: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
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) {
|
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
|
||||||
const argsRef = useRef(args);
|
const argsRef = useRef(args);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,6 +80,7 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
|
|||||||
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
|
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (argsRef.current.enabled === false) return;
|
||||||
|
|
||||||
const action = resolveTeleprompterAction(event);
|
const action = resolveTeleprompterAction(event);
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
hasBrokenFollow,
|
hasBrokenFollow,
|
||||||
indexAtReadPoint,
|
indexAtReadPoint,
|
||||||
linesPerMinuteToPxPerSecond,
|
linesPerMinuteToPxPerSecond,
|
||||||
|
nudgeTargetFor,
|
||||||
readPointForAnchor,
|
readPointForAnchor,
|
||||||
type ScrollAnchor,
|
type ScrollAnchor,
|
||||||
segmentAfter,
|
segmentAfter,
|
||||||
@@ -414,10 +415,19 @@ export function useTeleprompterScroll({
|
|||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
togglePlay: () => (runningRef.current ? pause() : play()),
|
togglePlay: () => (runningRef.current ? pause() : play()),
|
||||||
nudge: (lines: number) => {
|
nudge: (lines: number, options) => {
|
||||||
const distance = lines * lineHeightRef.current;
|
const distance = lines * lineHeightRef.current;
|
||||||
pendingDeltaRef.current += distance;
|
const from = posRef.current + pendingDeltaRef.current;
|
||||||
addReaderDrift(distance);
|
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);
|
setParkedAt(null);
|
||||||
},
|
},
|
||||||
page: (direction: 1 | -1) => {
|
page: (direction: 1 | -1) => {
|
||||||
@@ -449,7 +459,7 @@ export function useTeleprompterScroll({
|
|||||||
setAutoScrollLocked(false);
|
setAutoScrollLocked(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [addReaderDrift, setPlaybackRunning]);
|
}, [addReaderDrift, scrollTargetFor, setPlaybackRunning]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollerRef: attachScroller,
|
scrollerRef: attachScroller,
|
||||||
@@ -458,9 +468,7 @@ export function useTeleprompterScroll({
|
|||||||
controller,
|
controller,
|
||||||
isRunning,
|
isRunning,
|
||||||
speed,
|
speed,
|
||||||
// folds followLoaded in, so callers get one ready-to-use signal instead of
|
isFollowingLoadedEvent: followLoaded && !autoScrollLocked,
|
||||||
// a runtime flag they must remember to AND with the option themselves
|
|
||||||
canReengageFollow: followLoaded && autoScrollLocked,
|
|
||||||
parkedAt,
|
parkedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws';
|
|||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { authenticateSocket } from '../middleware/authenticate.js';
|
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||||
|
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import getRandomName from '../utils/getRandomName.js';
|
import getRandomName from '../utils/getRandomName.js';
|
||||||
import type { IAdapter } from './IAdapter.js';
|
import type { IAdapter } from './IAdapter.js';
|
||||||
@@ -43,6 +44,7 @@ class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
private wss: WebSocketServer | null;
|
private wss: WebSocketServer | null;
|
||||||
private readonly clients: Map<ClientId, Client>;
|
private readonly clients: Map<ClientId, Client>;
|
||||||
|
private readonly connections: Map<ClientId, WebSocket>;
|
||||||
private lastConnection: Date | null = null;
|
private lastConnection: Date | null = null;
|
||||||
private shouldShowWelcome = true;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||||
instance = this;
|
instance = this;
|
||||||
this.clients = new Map<ClientId, Client>();
|
this.clients = new Map<ClientId, Client>();
|
||||||
|
this.connections = new Map<ClientId, WebSocket>();
|
||||||
this.wss = null;
|
this.wss = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +86,8 @@ class SocketServer implements IAdapter {
|
|||||||
origin: '',
|
origin: '',
|
||||||
path: '',
|
path: '',
|
||||||
});
|
});
|
||||||
|
this.connections.set(clientId, ws);
|
||||||
|
teleprompterService.register(clientId);
|
||||||
|
|
||||||
this.lastConnection = new Date();
|
this.lastConnection = new Date();
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
|
||||||
@@ -98,6 +103,8 @@ class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
|
this.connections.delete(clientId);
|
||||||
|
teleprompterService.remove(clientId);
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
||||||
this.sendClientList();
|
this.sendClientList();
|
||||||
});
|
});
|
||||||
@@ -114,7 +121,9 @@ class SocketServer implements IAdapter {
|
|||||||
}
|
}
|
||||||
case MessageTag.ClientSet: {
|
case MessageTag.ClientSet: {
|
||||||
const previousData = this.getOrCreateClient(clientId);
|
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);
|
this.clients.set(clientId, updatedClient);
|
||||||
if (this.shouldShowWelcome && updatedClient.path?.toLowerCase().includes('editor')) {
|
if (this.shouldShowWelcome && updatedClient.path?.toLowerCase().includes('editor')) {
|
||||||
this.shouldShowWelcome = false;
|
this.shouldShowWelcome = false;
|
||||||
@@ -185,7 +194,11 @@ class SocketServer implements IAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendClientList(): void {
|
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);
|
this.sendAsJson(MessageTag.ClientList, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +206,16 @@ class SocketServer implements IAdapter {
|
|||||||
return Array.from(this.clients.keys());
|
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) {
|
public renameClient(target: ClientId, name: string) {
|
||||||
const previousData = this.clients.get(target);
|
const previousData = this.clients.get(target);
|
||||||
if (!previousData) {
|
if (!previousData) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ApiActionTag,
|
ApiActionTag,
|
||||||
|
MessageTag,
|
||||||
MessageState,
|
MessageState,
|
||||||
OffsetMode,
|
OffsetMode,
|
||||||
OntimeEvent,
|
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 * as messageService from '../services/message-service/message.service.js';
|
||||||
import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js';
|
import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js';
|
||||||
import { runtimeService } from '../services/runtime-service/runtime.service.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 { eventStore } from '../stores/EventStore.js';
|
||||||
import * as assert from '../utils/assert.js';
|
import * as assert from '../utils/assert.js';
|
||||||
import { coerceEnum } from '../utils/coerceType.js';
|
import { coerceEnum } from '../utils/coerceType.js';
|
||||||
@@ -307,8 +309,50 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
|
|||||||
runtimeService.setOffsetMode(mode);
|
runtimeService.setOffsetMode(mode);
|
||||||
return { payload: 'success' };
|
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
|
* Returns a value of type number, converting if necessary
|
||||||
* Otherwise throws
|
* 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 { OffsetMode } from '../../definitions/runtime/Offset.type.js';
|
||||||
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.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 = {
|
export type VersionAction = {
|
||||||
tag: 'version';
|
tag: 'version';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
@@ -156,7 +167,8 @@ export type ApiAction =
|
|||||||
| AddtimeAction
|
| AddtimeAction
|
||||||
| AuxtimerAction
|
| AuxtimerAction
|
||||||
| ClientAction
|
| ClientAction
|
||||||
| OffsetmodeAction;
|
| OffsetmodeAction
|
||||||
|
| TeleprompterAction;
|
||||||
|
|
||||||
export type ApiResponse =
|
export type ApiResponse =
|
||||||
| VersionResponse
|
| VersionResponse
|
||||||
@@ -172,6 +184,7 @@ export type ApiResponse =
|
|||||||
| AddtimeResponse
|
| AddtimeResponse
|
||||||
| AuxtimerResponse
|
| AuxtimerResponse
|
||||||
| ClientResponse
|
| ClientResponse
|
||||||
| OffsetmodeResponse;
|
| OffsetmodeResponse
|
||||||
|
| TeleprompterResponse;
|
||||||
|
|
||||||
export type ApiActionTag = ApiAction['tag'];
|
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 { Log } from '../../definitions/runtime/Logger.type.js';
|
||||||
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
|
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
|
||||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||||
|
import type { TeleprompterCommand } from './api.type.js';
|
||||||
import type { RefetchKey } from './refetch.type.js';
|
import type { RefetchKey } from './refetch.type.js';
|
||||||
|
|
||||||
export enum MessageTag {
|
export enum MessageTag {
|
||||||
@@ -17,6 +18,7 @@ export enum MessageTag {
|
|||||||
Log = 'log',
|
Log = 'log',
|
||||||
RuntimeData = 'runtime-data',
|
RuntimeData = 'runtime-data',
|
||||||
Refetch = 'refetch',
|
Refetch = 'refetch',
|
||||||
|
TeleprompterCommand = 'teleprompter-command',
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIENT TO SERVER
|
// CLIENT TO SERVER
|
||||||
@@ -35,6 +37,10 @@ type ListClientPacket = {
|
|||||||
payload: Record<string, Client>;
|
payload: Record<string, Client>;
|
||||||
};
|
};
|
||||||
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
|
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
|
||||||
|
type TeleprompterCommandPacket = {
|
||||||
|
tag: MessageTag.TeleprompterCommand;
|
||||||
|
payload: { commandId: string; command: TeleprompterCommand };
|
||||||
|
};
|
||||||
|
|
||||||
type RefetchPacket = {
|
type RefetchPacket = {
|
||||||
tag: MessageTag.Refetch;
|
tag: MessageTag.Refetch;
|
||||||
@@ -58,4 +64,5 @@ export type WsPacketToClient =
|
|||||||
| LogPacket
|
| LogPacket
|
||||||
| ListClientPacket
|
| ListClientPacket
|
||||||
| RuntimePacket
|
| RuntimePacket
|
||||||
| RefetchPacket;
|
| RefetchPacket
|
||||||
|
| TeleprompterCommandPacket;
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
export type ClientType = 'unknown' | 'ontime' | string;
|
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 = {
|
export type Client = {
|
||||||
name: string;
|
name: string;
|
||||||
type: ClientType;
|
type: ClientType;
|
||||||
identify: boolean;
|
identify: boolean;
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
teleprompter?: TeleprompterControlState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClientList = Record<string, Client>;
|
export type ClientList = Record<string, Client>;
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export type {
|
|||||||
export { MessageTag } from './api/websocket/data.type.js';
|
export { MessageTag } from './api/websocket/data.type.js';
|
||||||
export type { WsPacketToServer, WsPacketToClient } 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 { 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
|
// SERVER RUNTIME
|
||||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||||
export { Playback } from './definitions/runtime/Playback.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';
|
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||||
|
|
||||||
// CLIENT
|
// 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
|
// TYPE UTILITIES
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user