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 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user