diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index 98ae2196d..19368c3f1 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -74,7 +74,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa controller, isRunning, speed, - followLocked, + canReengageFollow, atEnd, } = useTeleprompterScroll({ initialSpeed: options.speed, @@ -161,7 +161,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa { }); }); +describe('hasBrokenFollow()', () => { + const lineHeight = 40; + + test('tolerates drift under the threshold, so momentum or a stray touch does not break it', () => { + const underThreshold = lineHeight * FOLLOW_BREAK_LINES - 1; + expect(hasBrokenFollow(underThreshold, 0, lineHeight)).toBe(false); + expect(hasBrokenFollow(-underThreshold, 0, lineHeight)).toBe(false); + }); + + test('counts a deliberate scroll past the threshold, from either direction', () => { + const overThreshold = lineHeight * FOLLOW_BREAK_LINES + 1; + expect(hasBrokenFollow(overThreshold, 0, lineHeight)).toBe(true); + expect(hasBrokenFollow(-overThreshold, 0, lineHeight)).toBe(true); + }); + + test('measures from the follow target, not from zero', () => { + expect(hasBrokenFollow(1000, 1000, lineHeight)).toBe(false); + expect(hasBrokenFollow(1000 + lineHeight * FOLLOW_BREAK_LINES + 1, 1000, lineHeight)).toBe(true); + }); + + test('never breaks follow before the document has been measured', () => { + // an unmeasured line height would make any distance look like a break + expect(hasBrokenFollow(10_000, 0, 0)).toBe(false); + }); +}); + describe('easeCatchUp()', () => { test('approaches the target monotonically from either side', () => { let fromAbove = 500; diff --git a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx index 6f453f549..e19a5a1d7 100644 --- a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx +++ b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx @@ -100,9 +100,7 @@ export default function ControlOverlay({ lineHeightPx * FOLLOW_BREAK_LINES; +} diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts index 9e63ddc58..693918c07 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts @@ -6,6 +6,7 @@ import { clampSpeed, easeCatchUp, frameDeltaSeconds, + hasBrokenFollow, linesPerMinuteToPxPerSecond, } from './teleprompter.scroll'; import type { ScriptBlock, TeleprompterController } from './teleprompter.types'; @@ -73,10 +74,15 @@ export function useTeleprompterScroll({ const maxScrollRef = useRef(0); const catchUpTargetRef = useRef(null); const pendingDeltaRef = useRef(0); + // where following last put (or is easing towards putting) the reader, so a + // user scroll can be measured against it rather than breaking on any input + const followTargetRef = useRef(0); const [isRunning, setIsRunning] = useState(false); const [speed, setSpeed] = useState(initialSpeed); - const [followLocked, setFollowLocked] = useState(false); + // mirrors the operator view's lockAutoScroll: true once the reader has taken + // the scroll over by hand, false while following is doing the driving + const [autoScrollLocked, setAutoScrollLocked] = useState(false); const [atEnd, setAtEnd] = useState(false); const tick = useCallback((timestamp: number) => { @@ -195,7 +201,7 @@ export function useTeleprompterScroll({ const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId); useEffect(() => { - if (!followLoaded || followLocked || !selectedEventId) return; + if (!followLoaded || autoScrollLocked || !selectedEventId) return; const scroller = scrollerRef.current; const target = blockRefs.current.get(selectedEventId); @@ -203,16 +209,30 @@ export function useTeleprompterScroll({ const offset = (scroller.clientHeight * readingLinePos) / 100; const top = getLayoutTop(target) - getLayoutTop(scroller) - offset; + const clamped = clamp(top, 0, maxScrollRef.current); - catchUpTargetRef.current = clamp(top, 0, maxScrollRef.current); + followTargetRef.current = clamped; + catchUpTargetRef.current = clamped; setAtEnd(false); - }, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock]); + }, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock]); + /** + * Only a real scroll away from the follow target takes over: momentum after a + * deliberate gesture, or a stray touch, would otherwise break it on any input, + * which is what made the operator view move to a distance check instead. + * + * Reads scrollTop from the element rather than posRef: a burst of wheel + * events can fire faster than the animation frame that keeps posRef in sync, + * so posRef here can still be reporting where the gesture started. + */ const handleUserScroll = useCallback(() => { - if (followLoaded) { - setFollowLocked(true); + if (!followLoaded || autoScrollLocked) return; + const position = scrollerRef.current?.scrollTop; + if (position === undefined) return; + if (hasBrokenFollow(position, followTargetRef.current, lineHeightRef.current)) { + setAutoScrollLocked(true); } - }, [followLoaded]); + }, [followLoaded, autoScrollLocked]); const registerBlock = useCallback((id: string, element: HTMLElement | null) => { if (element) { @@ -264,7 +284,7 @@ export function useTeleprompterScroll({ setAtEnd(true); } }, - reengageFollow: () => setFollowLocked(false), + reengageFollow: () => setAutoScrollLocked(false), }; }, []); @@ -276,7 +296,9 @@ export function useTeleprompterScroll({ controller, isRunning, speed, - followLocked, + // 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, atEnd, }; } diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts index 8aed34e2c..80bc6006f 100644 --- a/e2e/tests/features/215-teleprompter.spec.ts +++ b/e2e/tests/features/215-teleprompter.spec.ts @@ -46,3 +46,27 @@ test('teleprompter renders and responds to its primary controls', async ({ page, await expect(page).toHaveURL(/flipH=true/); await expect(view).toHaveCSS('transform', /^matrix\(-1/); }); + +test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => { + const response = await request.post('/data/db/demo'); + expect(response.ok()).toBe(true); + const loadResponse = await request.get('/api/load/index/5'); + expect(loadResponse.ok()).toBe(true); + + await page.goto('/teleprompter?script=note'); + + const scroller = page.getByTestId('teleprompter-scroller'); + const follow = page.getByTestId('teleprompter-follow'); + await expect(scroller).toBeVisible(); + await expect(follow).toBeDisabled(); + + await page.mouse.move(960, 500); + await page.mouse.wheel(0, 15); + await expect(follow).toBeDisabled(); + + await page.mouse.wheel(0, 400); + await expect(follow).toBeEnabled(); + + await page.keyboard.press('l'); + await expect(follow).toBeDisabled(); +});