mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 12:29:10 +00:00
57362d85de
Two problems were feeding the confusion: - Any wheel, touch, or pointerdown event broke the follow, with no tolerance. A stray touch or trackpad momentum after a deliberate gesture disengaged it as readily as a real scroll away from the read position, with no way to tell the two apart from the outside. The operator view solved the same problem with a distance check against the loaded item's expected position, which this now mirrors: it takes a real scroll (past 1.5 lines) to count as taking over, in a new hasBrokenFollow(), unit tested like the rest of the scroll maths. - The runtime flag and the view option shared adjacent, opposite-valence names (followLocked / followLoaded) and were ANDed together in a third place to get the value the button actually needed. Renamed the runtime flag to autoScrollLocked, matching the operator's own lockAutoScroll for the same concept, and moved the AND into the hook so it exposes one signal the view no longer has to assemble itself. Also fixes the threshold check reading a stale position: a burst of wheel events can fire faster than the animation frame that keeps the scroll ref in sync, so it now reads the element's scrollTop directly rather than the ref a frame behind it. Caught by testing the tolerance in a live browser rather than trusting the unit tests alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
import { OntimeView } from 'ontime-types';
|
|
import { type CSSProperties, 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 { cx } from '../../common/utils/styleUtils';
|
|
import Loader from '../common/loader/Loader';
|
|
import ControlOverlay from './control-overlay/ControlOverlay';
|
|
import HelpOverlay from './help-overlay/HelpOverlay';
|
|
import ScriptBlockView from './script-block/ScriptBlock';
|
|
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
|
|
import { stepFontSize } from './teleprompter.scroll';
|
|
import { buildScript, composeFlip } from './teleprompter.utils';
|
|
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
|
|
import { useTeleprompterControls } from './useTeleprompterControls';
|
|
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
|
|
import { useTeleprompterScroll } from './useTeleprompterScroll';
|
|
|
|
import './Teleprompter.scss';
|
|
|
|
export default function TeleprompterLoader() {
|
|
const { data, status } = useTeleprompterData();
|
|
|
|
useWindowTitle('Teleprompter');
|
|
|
|
if (status === 'pending') {
|
|
return <Loader />;
|
|
}
|
|
|
|
if (status === 'error') {
|
|
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
|
}
|
|
|
|
return <Teleprompter {...data} />;
|
|
}
|
|
|
|
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
|
|
'use memo';
|
|
|
|
const options = useTeleprompterOptions();
|
|
const selectedEventId = useSelectedEventId();
|
|
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
|
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
|
|
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
|
|
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
|
|
|
|
const [live, setLive] = useState(fromParams);
|
|
const [seededFrom, setSeededFrom] = useState(paramsKey);
|
|
// Reset live controls before commit when the URL configuration changes.
|
|
if (seededFrom !== paramsKey) {
|
|
setSeededFrom(paramsKey);
|
|
setLive(fromParams);
|
|
}
|
|
|
|
const viewOptions = getTeleprompterOptions(customFields);
|
|
|
|
const blocks = buildScript(rundown, rundownMetadata, customFields, {
|
|
scriptSource: options.scriptSource,
|
|
heading: options.heading,
|
|
hideEmpty: options.hideEmpty,
|
|
showGroups: options.showGroups,
|
|
});
|
|
|
|
const {
|
|
scrollerRef,
|
|
contentRef,
|
|
registerBlock,
|
|
handleUserScroll,
|
|
controller,
|
|
isRunning,
|
|
speed,
|
|
canReengageFollow,
|
|
atEnd,
|
|
} = useTeleprompterScroll({
|
|
initialSpeed: options.speed,
|
|
followLoaded: options.followLoaded,
|
|
selectedEventId,
|
|
readingLinePos: options.readingLinePos,
|
|
blocks,
|
|
});
|
|
|
|
const handleFlip = (axis: 'h' | 'v') =>
|
|
setLive((current) => {
|
|
const key = axis === 'h' ? 'flipH' : 'flipV';
|
|
return { ...current, [key]: !current[key] };
|
|
});
|
|
|
|
const handleFontSize = (steps: number) =>
|
|
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
|
|
|
|
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
|
|
const handleToggleHelp = () => setShowHelp((current) => !current);
|
|
|
|
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
|
|
|
|
useTeleprompterControls({
|
|
controller,
|
|
isHelpOpen: showHelp,
|
|
onFlip: handleFlip,
|
|
onFontSize: handleFontSize,
|
|
onResetFontSize: handleResetFontSize,
|
|
onToggleHelp: handleToggleHelp,
|
|
});
|
|
|
|
const hasScriptSource = options.scriptSource !== 'none';
|
|
|
|
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
|
|
|
|
const viewStyles = {
|
|
'--tp-configured-font-size': `${live.fontSize}px`,
|
|
'--tp-line-height': options.lineHeight,
|
|
'--tp-text-width': `${options.textWidth}%`,
|
|
'--tp-reading-line': options.readingLinePos,
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<div
|
|
className={cx([
|
|
'teleprompter',
|
|
effectiveFlip.flipH && 'teleprompter--flip-h',
|
|
effectiveFlip.flipV && 'teleprompter--flip-v',
|
|
])}
|
|
style={viewStyles}
|
|
data-testid='teleprompter-view'
|
|
>
|
|
<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' />
|
|
) : (
|
|
<>
|
|
<div
|
|
className='teleprompter__scroller'
|
|
data-testid='teleprompter-scroller'
|
|
ref={scrollerRef}
|
|
onWheel={handleUserScroll}
|
|
onTouchMove={handleUserScroll}
|
|
onPointerDown={handleUserScroll}
|
|
>
|
|
<div className='teleprompter__content' ref={contentRef}>
|
|
{blocks.map((block) => (
|
|
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='teleprompter__dim' />
|
|
{options.readingLine && (
|
|
<div className='teleprompter__reading-line'>
|
|
<span className='teleprompter__reading-marker' />
|
|
</div>
|
|
)}
|
|
|
|
<ControlOverlay
|
|
isRunning={isRunning}
|
|
speed={speed}
|
|
canReengageFollow={canReengageFollow}
|
|
atEnd={atEnd}
|
|
controller={controller}
|
|
onToggleHelp={handleToggleHelp}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
|
|
</div>
|
|
);
|
|
}
|