diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx
index 170152f..07620a3 100644
--- a/components/TranscriptPanel.tsx
+++ b/components/TranscriptPanel.tsx
@@ -2,6 +2,8 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
+ ArrowDown,
+ ArrowUp,
ArrowUpFromLine,
Eye,
EyeOff,
@@ -38,6 +40,7 @@ import {
mapSplitsToWords,
} from "@/lib/edits";
import { useTranscriptSelection } from "@/hooks/useTranscriptSelection";
+import { useTranscriptPlayheadFollow } from "@/hooks/useTranscriptPlayheadFollow";
import { useWordAnchorFloating } from "@/hooks/useWordAnchorFloating";
import { useCutRanges } from "@/hooks/useCutRanges";
import { findActiveWordId, groupWordsBySpeaker } from "@/lib/transcript";
@@ -172,6 +175,27 @@ export default function TranscriptPanel() {
freezeSelectionRef,
});
+ const {
+ showFollowControl,
+ followDirection,
+ resumeFollowPlayhead,
+ markUserScrollGesture,
+ } = useTranscriptPlayheadFollow({
+ scrollRef,
+ containerRef,
+ playing,
+ activeWordId,
+ });
+
+ // Clicking a word seeks — resume following so playback stays in view.
+ const onWordClick = useCallback(
+ (word: Word, el: HTMLElement) => {
+ resumeFollowPlayhead();
+ handleWordClick(word, el);
+ },
+ [handleWordClick, resumeFollowPlayhead]
+ );
+
const toolbarOpen = !!(selection && !correcting && !assigningSpeaker);
const { setFloating: setToolbarFloating, floatingStyles: toolbarStyles } =
useWordAnchorFloating({
@@ -326,13 +350,6 @@ export default function TranscriptPanel() {
return () => document.removeEventListener("keydown", handler);
}, [selection, assigningSpeaker, correcting, openSpeakerAssign]);
- // Keep the active word in view during playback.
- useEffect(() => {
- if (!playing || activeWordId < 0) return;
- const el = containerRef.current?.querySelector(`[data-wid="${activeWordId}"]`);
- el?.scrollIntoView({ block: "nearest" });
- }, [activeWordId, playing]);
-
const busy = status === "preparing" || status === "transcribing";
return (
@@ -480,7 +497,7 @@ export default function TranscriptPanel() {
word={w}
cutOut={cutOutIds.has(w.id)}
active={w.id === activeWordId}
- onClick={handleWordClick}
+ onClick={onWordClick}
/>
);
@@ -585,7 +602,22 @@ export default function TranscriptPanel() {
{/* Gradient overlay — must match the transcript panel surface */}
-
+ {showFollowControl && (
+
+ )}
+
);
}
diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx
index fcf2c03..f250698 100644
--- a/components/TranscriptScrollIndicator.tsx
+++ b/components/TranscriptScrollIndicator.tsx
@@ -184,9 +184,12 @@ function topAt(anchors: Anchor[], time: number): number {
export default function TranscriptScrollIndicator({
scrollRef,
contentRef,
+ onUserScroll,
}: {
scrollRef: RefObject;
contentRef: RefObject;
+ /** Fired when the user drags or clicks the rail (not programmatic scroll). */
+ onUserScroll?: () => void;
}) {
const words = useEditorStore((s) => s.words);
const starts = useMemo(() => {
@@ -384,6 +387,7 @@ export default function TranscriptScrollIndicator({
const drag = (clientY: number) => {
if (travel <= 0) return;
+ onUserScroll?.();
const y = clamp(clientY - railRect.top - grab, 0, travel);
scroller.scrollTop = (y / travel) * range;
};
@@ -403,7 +407,7 @@ export default function TranscriptScrollIndicator({
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
- [scrollRef, bump]
+ [scrollRef, bump, onUserScroll]
);
return (
diff --git a/hooks/useTranscriptPlayheadFollow.ts b/hooks/useTranscriptPlayheadFollow.ts
new file mode 100644
index 0000000..aa362b5
--- /dev/null
+++ b/hooks/useTranscriptPlayheadFollow.ts
@@ -0,0 +1,160 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type RefObject,
+} from "react";
+import { useEditorStore } from "@/lib/store";
+import { findActiveWordId } from "@/lib/transcript";
+
+/** Matches the transcript panel's floating header (`h-10` / `pt-10` / `scroll-pt-10`). */
+const HEADER_H = 40;
+
+export type FollowDirection = "up" | "down";
+
+/**
+ * Keep the active transcript word in view during playback, but pause when the
+ * user scrolls it offscreen. Exposes a Follow control until they resume.
+ */
+export function useTranscriptPlayheadFollow({
+ scrollRef,
+ containerRef,
+ playing,
+ activeWordId,
+}: {
+ scrollRef: RefObject;
+ containerRef: RefObject;
+ playing: boolean;
+ activeWordId: number;
+}) {
+ const followPlayheadRef = useRef(true);
+ /** Set by wheel/touch/rail before the matching scroll event. */
+ const userScrollGestureRef = useRef(false);
+ const userScrollGestureTimerRef = useRef(0);
+ 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");
+
+ /** 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}"]`);
+ if (!el) {
+ setPlayheadOffscreen(false);
+ 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);
+ setFollowDirection("up");
+ return { offscreen: true as const, direction: "up" as const };
+ }
+ if (wordRect.top > viewBottom) {
+ setPlayheadOffscreen(true);
+ setFollowDirection("down");
+ return { offscreen: true as const, direction: "down" as const };
+ }
+ setPlayheadOffscreen(false);
+ return { offscreen: false as const };
+ }, [scrollRef, containerRef]);
+
+ /** Rail / gesture hint — actual unfollow waits until scroll leaves the word. */
+ const markUserScrollGesture = useCallback(() => {
+ const scroller = scrollRef.current;
+ if (!scroller || scroller.scrollHeight <= scroller.clientHeight + 1) return;
+ userScrollGestureRef.current = true;
+ // Keep the gesture armed across momentum / rail-drag scroll bursts.
+ window.clearTimeout(userScrollGestureTimerRef.current);
+ userScrollGestureTimerRef.current = window.setTimeout(() => {
+ userScrollGestureRef.current = false;
+ }, 150);
+ }, [scrollRef]);
+
+ const resumeFollowPlayhead = useCallback(() => {
+ followPlayheadRef.current = true;
+ setFollowPlayhead(true);
+ setPlayheadOffscreen(false);
+ }, []);
+
+ // User scroll pauses follow only once the active word actually leaves view.
+ // Wheel over a non-scrollable pane is ignored (no Follow chip).
+ useEffect(() => {
+ const scroller = scrollRef.current;
+ if (!scroller) return;
+ const onGesture = () => markUserScrollGesture();
+ const onScroll = () => {
+ if (!playing) return;
+ const { offscreen } = measurePlayheadAnchor();
+ if (userScrollGestureRef.current && offscreen) {
+ followPlayheadRef.current = false;
+ setFollowPlayhead(false);
+ return;
+ }
+ // Scrolled back onto the playhead (or programmatic nearest kept it in view).
+ if (!offscreen && !followPlayheadRef.current) resumeFollowPlayhead();
+ };
+ scroller.addEventListener("wheel", onGesture, { passive: true });
+ scroller.addEventListener("touchmove", onGesture, { passive: true });
+ scroller.addEventListener("scroll", onScroll, { passive: true });
+ return () => {
+ scroller.removeEventListener("wheel", onGesture);
+ scroller.removeEventListener("touchmove", onGesture);
+ scroller.removeEventListener("scroll", onScroll);
+ window.clearTimeout(userScrollGestureTimerRef.current);
+ };
+ }, [
+ scrollRef,
+ markUserScrollGesture,
+ measurePlayheadAnchor,
+ resumeFollowPlayhead,
+ playing,
+ ]);
+
+ // Keep the active word in view during playback while following.
+ useEffect(() => {
+ if (!playing || !followPlayhead || activeWordId < 0) return;
+ const el = containerRef.current?.querySelector(
+ `[data-wid="${activeWordId}"]`
+ );
+ el?.scrollIntoView({ block: "nearest" });
+ }, [activeWordId, playing, followPlayhead, containerRef]);
+
+ // Refresh offscreen/direction as the playhead advances while unfollowed.
+ useEffect(() => {
+ if (followPlayhead || !playing || activeWordId < 0) return;
+ const { offscreen } = measurePlayheadAnchor();
+ if (!offscreen) resumeFollowPlayhead();
+ }, [
+ followPlayhead,
+ playing,
+ activeWordId,
+ measurePlayheadAnchor,
+ resumeFollowPlayhead,
+ ]);
+
+ const showFollowControl =
+ !followPlayhead && playheadOffscreen && playing && activeWordId >= 0;
+
+ return {
+ showFollowControl,
+ followDirection,
+ resumeFollowPlayhead,
+ markUserScrollGesture,
+ };
+}