From 91f986a53fd10acdcca70490b66a64f61dad75a6 Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 5 Aug 2026 20:16:25 -0700 Subject: [PATCH] fix playhead following --- components/TranscriptPanel.tsx | 5 +- hooks/useTranscriptPlayheadFollow.ts | 90 +++++++++++++++++----------- 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index 766c704..b5243a5 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, ArrowUpFromLine, + ChevronLast, Eye, EyeOff, Merge, @@ -571,7 +572,9 @@ export default function TranscriptPanel() { title="Scroll with the playhead" className="absolute bottom-5 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full border border-zinc-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-zinc-700 backdrop-blur-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/95 dark:text-zinc-200 dark:hover:bg-zinc-700" > - {followDirection === "up" ? : } + {followDirection === "up" && } + {followDirection === "down" && } + {followDirection === null && } Follow playhead )} diff --git a/hooks/useTranscriptPlayheadFollow.ts b/hooks/useTranscriptPlayheadFollow.ts index af0762b..88b0f5a 100644 --- a/hooks/useTranscriptPlayheadFollow.ts +++ b/hooks/useTranscriptPlayheadFollow.ts @@ -12,12 +12,21 @@ import { findActiveWordId } from "@/lib/transcript"; /** Matches the transcript panel's floating header (`h-10` / `pt-10` / `scroll-pt-10`). */ const HEADER_H = 40; +/** Comfort band at the edges of the readable pane. The playhead is only + * re-centred once it drifts out of the band, so ordinary word-to-word + * progress reads without the text sliding under the eye every few hundred ms. + * Kept a little taller than the panel's bottom gradient (`h-20`). */ +const EDGE_PAD = 84; -export type FollowDirection = "up" | "down"; +export type FollowDirection = "up" | "down" | null; /** - * Keep the active transcript word in view during playback, but pause when the - * user scrolls it offscreen. Exposes a Follow control until they resume. + * Keep the active transcript word in view during playback, and get out of the + * way the moment the user scrolls. Any deliberate scroll pauses following — + * waiting for the playhead to leave the pane first means small scrolls get + * yanked back on the next word, which reads as the panel fighting the user. + * Following resumes on the Follow control, on a word click, or when playback + * catches back up to whatever the user scrolled to. */ export function useTranscriptPlayheadFollow({ scrollRef, @@ -34,12 +43,12 @@ export function useTranscriptPlayheadFollow({ /** Set by wheel/touch/rail before the matching scroll event. */ const userScrollGestureRef = useRef(false); const userScrollGestureTimerRef = useRef(0); + /** The playhead has left the pane since following was paused — until it + * does, "back in view" means nothing and must not resume following. */ + const leftViewRef = useRef(false); const [followPlayhead, setFollowPlayhead] = useState(true); - /** Playhead outside the readable viewport — gates the Follow control. */ - const [playheadOffscreen, setPlayheadOffscreen] = useState(false); - /** Which way to jump to the playhead when unfollowed. */ - const [followDirection, setFollowDirection] = - useState("down"); + /** Which way to jump to the playhead when unfollowed; null when it is on screen. */ + const [followDirection, setFollowDirection] = useState(null); /** Measure whether the active word sits above/below the readable pane. */ const measurePlayheadAnchor = useCallback(() => { @@ -47,13 +56,12 @@ export function useTranscriptPlayheadFollow({ if (!scroller) return { offscreen: false as const }; const { words, currentTime } = useEditorStore.getState(); const activeId = findActiveWordId(words, currentTime); - if (activeId < 0) { - setPlayheadOffscreen(false); - return { offscreen: false as const }; - } - const el = containerRef.current?.querySelector(`[data-wid="${activeId}"]`); + const el = + activeId < 0 + ? null + : containerRef.current?.querySelector(`[data-wid="${activeId}"]`); if (!el) { - setPlayheadOffscreen(false); + setFollowDirection(null); return { offscreen: false as const }; } const wordRect = el.getBoundingClientRect(); @@ -61,20 +69,20 @@ export function useTranscriptPlayheadFollow({ const viewTop = scrollerRect.top + HEADER_H; const viewBottom = scrollerRect.bottom; if (wordRect.bottom < viewTop) { - setPlayheadOffscreen(true); + leftViewRef.current = true; setFollowDirection("up"); return { offscreen: true as const, direction: "up" as const }; } if (wordRect.top > viewBottom) { - setPlayheadOffscreen(true); + leftViewRef.current = true; setFollowDirection("down"); return { offscreen: true as const, direction: "down" as const }; } - setPlayheadOffscreen(false); + setFollowDirection(null); return { offscreen: false as const }; }, [scrollRef, containerRef]); - /** Rail / gesture hint — actual unfollow waits until scroll leaves the word. */ + /** Rail / wheel / touch hint, so the next scroll event is known to be theirs. */ const markUserScrollGesture = useCallback(() => { const scroller = scrollRef.current; if (!scroller || scroller.scrollHeight <= scroller.clientHeight + 1) return; @@ -88,26 +96,25 @@ export function useTranscriptPlayheadFollow({ const resumeFollowPlayhead = useCallback(() => { followPlayheadRef.current = true; + leftViewRef.current = false; setFollowPlayhead(true); - setPlayheadOffscreen(false); + setFollowDirection(null); }, []); - // User scroll pauses follow only once the active word actually leaves view. - // Wheel over a non-scrollable pane is ignored (no Follow chip). + // Any user scroll pauses following immediately — wherever they land is where + // they want to be. Wheel over a non-scrollable pane never arms the gesture. useEffect(() => { const scroller = scrollRef.current; if (!scroller) return; const onGesture = () => markUserScrollGesture(); const onScroll = () => { if (!playing) return; - const { offscreen } = measurePlayheadAnchor(); - if (userScrollGestureRef.current && offscreen) { + if (userScrollGestureRef.current && followPlayheadRef.current) { followPlayheadRef.current = false; + leftViewRef.current = false; setFollowPlayhead(false); - return; } - // Scrolled back onto the playhead (or programmatic nearest kept it in view). - if (!offscreen && !followPlayheadRef.current) resumeFollowPlayhead(); + measurePlayheadAnchor(); }; scroller.addEventListener("wheel", onGesture, { passive: true }); scroller.addEventListener("touchmove", onGesture, { passive: true }); @@ -122,27 +129,41 @@ export function useTranscriptPlayheadFollow({ scrollRef, markUserScrollGesture, measurePlayheadAnchor, - resumeFollowPlayhead, playing, ]); - // Keep the active word in view during playback while following. + // Keep the active word inside the comfort band during playback. useEffect(() => { if (!playing || !followPlayhead || activeWordId < 0) return; + const scroller = scrollRef.current; const el = containerRef.current?.querySelector( `[data-wid="${activeWordId}"]` ); - el?.scrollIntoView({ block: "center" }); - }, [activeWordId, playing, followPlayhead, containerRef]); + if (!scroller || !el) return; + const wordRect = el.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + const viewTop = scrollerRect.top + HEADER_H; + const viewBottom = scrollerRect.bottom; + if (wordRect.top >= viewTop + EDGE_PAD && wordRect.bottom <= viewBottom - EDGE_PAD) + return; + const wordCentre = wordRect.top + wordRect.height / 2; + const paneCentre = (viewTop + viewBottom) / 2; + scroller.scrollTo({ + top: scroller.scrollTop + wordCentre - paneCentre, + behavior: "smooth", + }); + }, [activeWordId, playing, followPlayhead, scrollRef, containerRef]); - // Refresh offscreen/direction as the playhead advances while unfollowed. + // While unfollowed, track where the playhead sits so the control points the + // right way, and hand following back once playback catches up to the reader. // Measured in a frame callback so layout has settled for the newly active // word (and so the state update stays out of the render pass). useEffect(() => { if (followPlayhead || !playing || activeWordId < 0) return; const raf = requestAnimationFrame(() => { const { offscreen } = measurePlayheadAnchor(); - if (!offscreen) resumeFollowPlayhead(); + if (!offscreen && leftViewRef.current && !userScrollGestureRef.current) + resumeFollowPlayhead(); }); return () => cancelAnimationFrame(raf); }, [ @@ -153,11 +174,8 @@ export function useTranscriptPlayheadFollow({ resumeFollowPlayhead, ]); - const showFollowControl = - !followPlayhead && playheadOffscreen && playing; - return { - showFollowControl, + showFollowControl: !followPlayhead && playing, followDirection, resumeFollowPlayhead, markUserScrollGesture,