mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 03:49:11 +00:00
a8be8ee97c
The scroll position was a pixel offset into a document that changes while it is being read. Growing an event above the reader left scrollTop where it was, so the text under the reading line slid: measured going from "Lucas Bennett" to "Lunch" on an unchanged offset of 900. Hold the position as a place in the script instead — the block under the reading line plus an offset into it — and re-resolve it to pixels whenever the document is measured. This follows how professional prompters cue a story plus an offset rather than a scroll offset, so a reorder carries the reader with the block and an edit above them changes nothing. A deleted block falls back to the end of the nearest surviving event before it. Add event-to-event stepping on Shift with the vertical arrows, matching Shift being the coarser step on the horizontal ones, and take the step from where the scroll is headed so pressing again mid-ease moves on rather than re-aiming at the same event. Free scrolling and the existing nudge and page keys are unchanged, but they now hand over the scroll by one rule: how far the reader moved the script themselves, accumulated. Distance from the follow target could not tell a catch-up still running from a reader who had moved, so touching the wheel while the prompter eased towards a newly loaded event stopped it following. Accumulating also measures a wheel gesture the browser spreads over many frames, which the old per-event check sampled only once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
160 lines
5.6 KiB
TypeScript
160 lines
5.6 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, 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}>
|
|
<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>
|
|
);
|
|
}
|