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, + }; +}