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,