Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/TranscriptPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ArrowDown,
ArrowUp,
ArrowUpFromLine,
ChevronLast,
Eye,
EyeOff,
Merge,
Expand Down Expand Up @@ -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" ? <ArrowUp size={13} /> : <ArrowDown size={13} />}
{followDirection === "up" && <ArrowUp size={13} />}
{followDirection === "down" && <ArrowDown size={13} />}
{followDirection === null && <ChevronLast size={13} />}
Follow playhead
</button>
)}
Expand Down
90 changes: 54 additions & 36 deletions hooks/useTranscriptPlayheadFollow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,47 +43,46 @@ 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<FollowDirection>("down");
/** Which way to jump to the playhead when unfollowed; null when it is on screen. */
const [followDirection, setFollowDirection] = useState<FollowDirection>(null);

/** Measure whether the active word sits above/below the readable pane. */
const measurePlayheadAnchor = useCallback(() => {
const scroller = scrollRef.current;
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();
const scrollerRect = scroller.getBoundingClientRect();
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;
Expand All @@ -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 });
Expand All @@ -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);
}, [
Expand All @@ -153,11 +174,8 @@ export function useTranscriptPlayheadFollow({
resumeFollowPlayhead,
]);

const showFollowControl =
!followPlayhead && playheadOffscreen && playing;

return {
showFollowControl,
showFollowControl: !followPlayhead && playing,
followDirection,
resumeFollowPlayhead,
markUserScrollGesture,
Expand Down
Loading